Compare commits

..

29 commits
0.1.1 ... main

Author SHA1 Message Date
Julien Oculi a090186c54 fix: update compile and task permissions 2025-02-20 18:13:20 +01:00
Julien Oculi 9c948f1f3e chore: fix deno.json version 2025-02-20 17:30:30 +01:00
Julien Oculi ee0c106a2c feat: update local files base path 2025-02-20 16:38:54 +01:00
Julien Oculi 2f095b79c5 doc: add project license 2025-02-20 16:33:50 +01:00
Julien Oculi 52f9438f6c fix: prevent error if no local config exists 2025-02-20 16:21:54 +01:00
Julien Oculi 64fe0ab3d7 feat: fix default templates fetch and allow local override 2025-02-20 16:06:23 +01:00
Julien Oculi d33dd4539a feat: load config files from local fs and fallback if needed 2025-02-20 15:38:00 +01:00
Julien Oculi 486a3da213 fix: jsr import map don't update deno types directive 2024-07-15 22:37:38 +02:00
Julien Oculi 34764ad6b2 fix: jsr import map don't update deno types directive 2024-07-15 22:34:59 +02:00
Julien Oculi 7006664fc4 refactor: fix errors from deno publish 2024-07-15 15:08:00 +02:00
Julien Oculi b4954163b2 chore: bump package minor 2024-07-15 14:59:17 +02:00
Julien Oculi b033031d39 doc: update readme 2024-07-15 14:58:40 +02:00
Julien Oculi 6659fe5ee6 feat: add upgrade command 2024-07-15 14:58:15 +02:00
Julien Oculi cbb26e87ce refactor: deno lint fix slow-type-missing-explicit-return-type 2024-07-15 14:27:53 +02:00
Julien Oculi 54ceae3e71 refactor: deno lint fix slow-type-missing-explicit-return-type 2024-07-15 14:25:38 +02:00
Julien Oculi b437863f8b refactor: deno lint fix slow-type-missing-explicit-return-type 2024-07-15 14:23:17 +02:00
Julien Oculi 6eaf3b6083 refactor: fix deno lint no-unused-vars and verbatim-module-syntax 2024-07-15 14:22:27 +02:00
Julien Oculi a88178c2ce feat: check domain name of account.json against dkim config 2024-07-15 14:07:09 +02:00
Julien Oculi 09f84fc493 refactor: rename package to @cohabit/mailer 2024-07-15 12:19:37 +02:00
Julien Oculi 27519378e5 chore(deno.json): remove old task 2024-07-15 12:11:55 +02:00
Julien Oculi 2b29a4c9e8 refactor: switch cliffy imports to jsr 2024-07-15 12:10:57 +02:00
Julien Oculi dc000ac4de chore: update deno package version 2024-06-17 12:12:18 +02:00
Julien Oculi 5e126ebcaf feat(templates): add MagicLink template for connection links 2024-06-17 12:11:14 +02:00
Julien Oculi 92b8ece1c3 refactor(templates): move some css styles to avoid duplication 2024-06-17 12:06:12 +02:00
Julien Oculi 495e5e2e7e refactor(templates): simplify ternary condition for undefined 2024-06-17 12:01:56 +02:00
Julien Oculi 901c8fd672 fix(templates): resize header logo in Welcome 2024-06-17 12:00:37 +02:00
Julien Oculi fd1011619c refactor: add to templates package export 2024-06-17 11:02:49 +02:00
Julien Oculi 2f663f0e08 refactor: extract inlined dkim options to config file 2024-06-17 10:55:45 +02:00
Julien Oculi 3e429732c8 refactor: export templates and types from mod.ts 2024-06-15 17:48:25 +02:00
20 changed files with 4163 additions and 4042 deletions

View file

@ -8,6 +8,20 @@ Mail cli for Coh@bit.
## Installation
### From [jsr](https://jsr.io)
```sh
deno install \
--allow-read \
--allow-env \
--allow-net=0.0.0.0 \
--allow-sys=osRelease,networkInterfaces \
--allow-run=/usr/sbin/sendmail,whoami \
--global --force --name=cohamail jsr:@cohabit/mailer/cli
```
### From sources
> [!WARNING]
>
> Currently bin manually added to [releases](./releases) tab. Prefer `git clone`
@ -28,3 +42,8 @@ Mail cli for Coh@bit.
# OR
./cohamail <subcommand> -h
```
## Configuration
You can override default config and templates with your own in
`/etc/cohabit/mailer/(config|templates)/`.

22
cli.ts
View file

@ -1,14 +1,34 @@
import config from './deno.json' with { type: 'json' }
import { Command } from '@cliffy/command/command.ts'
import { Command } from '@cliffy/command'
import { UpgradeCommand } from '@cliffy/command/upgrade'
import { JsrProvider } from '@cliffy/command/upgrade/provider/jsr'
import { cmd as send } from './cli/send.ts'
import { cmd as preview } from './cli/preview.ts'
const upgradeCommand = new UpgradeCommand({
args: [
'--allow-read',
'--allow-env',
'--allow-net=0.0.0.0',
'--allow-sys=osRelease,networkInterfaces',
'--allow-run=/usr/sbin/sendmail,whoami',
'--name=cohamail',
],
provider: [
new JsrProvider({
package: config.name as `@${string}/${string}`,
main: 'cli',
}),
],
})
const cli = new Command()
.name('cohamail')
.description('Mail cli for coh@bit.')
.version(config.version)
.command('preview', preview)
.command('send', send)
.command('upgrade', upgradeCommand)
if (import.meta.main) {
if (Deno.args.length) {

View file

@ -1,4 +1,4 @@
import { Input } from '@cliffy/prompt/input.ts'
import { Input } from '@cliffy/prompt'
import type { Template } from '../types.ts'
import type { JSX } from 'preact'

View file

@ -1,7 +1,8 @@
import { fromFileUrl } from '@std/path'
import { exists } from '@std/fs/exists'
import type { JSX } from 'preact'
import { EnumType } from '@cliffy/command/mod.ts'
import { EnumType } from '@cliffy/command'
import type { Template } from '../types.ts'
import * as defaultTemplates from '../templates/mod.ts'
export const templates: Map<
string,
@ -11,25 +12,38 @@ export const templates: Map<
>
> = new Map()
const templatesDirUrl = import.meta.resolve('../templates')
const templatesDir = fromFileUrl(templatesDirUrl)
//Load default templates
for (const template of Object.values(defaultTemplates)) {
//@ts-expect-error types are checked at runtime later
templates.set(template.name, template)
}
//Load templates dynamicaly
for await (
const template of Deno.readDir(templatesDir)
) {
if (
template.isFile &&
template.name.endsWith('.tsx') &&
!template.name.startsWith('_')
) {
const modPath = new URL(
template.name,
`${import.meta.resolve('../templates')}/`,
//Load local templates
const basePath = '/etc/cohabit/mailer/templates'
if (await exists(basePath)) {
for await (const template of Deno.readDir(basePath)) {
if (!template.isFile) continue
if (!template.name.endsWith('.tsx')) continue
if (template.name.startsWith('_')) continue
const mod = await import(`${basePath}/${template.name}`).catch(
checkFsErrors,
)
const mod = await import(modPath.href)
templates.set(mod.default.name, mod.default)
}
}
function checkFsErrors(error: Error) {
if (error instanceof Deno.errors.PermissionDenied) {
throw new Error(
'unable to load config file due to read access permissions issues',
{
cause: error,
},
)
}
throw error
}
export const templateType = new EnumType([...templates.keys()])

View file

@ -1,4 +1,4 @@
import { Command } from '@cliffy/command/mod.ts'
import { Command } from '@cliffy/command'
import { renderTemplate } from '../src/template.tsx'
import { templates, templateType } from './_templates_loader.ts'
import { promptProps } from './_prompt_template.ts'

View file

@ -1,4 +1,4 @@
import { Command } from '@cliffy/command/mod.ts'
import { Command } from '@cliffy/command'
import { Contact } from '../src/contact.ts'
import { send } from '../src/send.ts'
import type { Mail } from '../types.ts'

5
config/dkim.json Normal file
View file

@ -0,0 +1,5 @@
{
"domainName": "cohabit.fr",
"keySelector": "sendmailY2024M03",
"privateKey": "/etc/ssl/certs/dkim_keys/dkimMailKey.pem"
}

49
config/loader.ts Normal file
View file

@ -0,0 +1,49 @@
import type { JsonValue } from '@std/json'
import defaultAccounts from './account.json' with { type: 'json' }
import defaultDkim from './dkim.json' with { type: 'json' }
function checkFsErrors(error: Error) {
if (error instanceof Deno.errors.NotFound) {
//use default config file
return
}
if (error instanceof Deno.errors.PermissionDenied) {
throw new Error(
'unable to load config file due to read access permissions issues',
{
cause: error,
},
)
}
throw error
}
type JsonRecord = Record<string, JsonValue>
export type DkimRecord = {
domainName: string
keySelector: string
privateKey: string
}
export type AccountRecord = Record<string, {
name: string
address: string
}>
async function readJsonFile<
T extends JsonRecord,
>(path: string, defaultValue: T): Promise<T> {
const file = await Deno.readTextFile(path).catch(checkFsErrors)
const json = JSON.parse(file ?? '{}')
return { ...defaultValue, ...json }
}
const basePath = '/ect/cohabit/mailer/config'
export const accounts = await readJsonFile<AccountRecord>(
`${basePath}/account.json`,
defaultAccounts,
)
export const dkim = await readJsonFile<DkimRecord>(
`${basePath}/dkim.json`,
defaultDkim,
)

View file

@ -1,15 +1,15 @@
{
"name": "@cohabit/cohamail",
"version": "0.1.1",
"name": "@cohabit/mailer",
"version": "0.5.2",
"exports": {
".": "./mod.ts",
"./cli": "./cli.ts",
"./types": "./types.ts"
"./types": "./types.ts",
"./templates": "./templates/mod.ts"
},
"tasks": {
"compile": "deno compile --allow-read --allow-env --allow-net=0.0.0.0 --allow-sys=osRelease,networkInterfaces --allow-run=/usr/sbin/sendmail,whoami --output=bin/cohamail --target=x86_64-unknown-linux-gnu ./cli.ts",
"cli": "deno run --allow-read --allow-env --allow-net=0.0.0.0 --allow-sys=osRelease,networkInterfaces --allow-run=/usr/sbin/sendmail,whoami ./cli.ts",
"utils:scp": "scp -O -P 55555 -i C:/Users/Julien/.ssh/id_ed25519_cohabit -r $(pwd) julien@cohabit.fr:/home/julien/cohabit_mail"
"compile": "deno compile --allow-read --allow-env --allow-net=0.0.0.0,jsr.io --allow-sys=osRelease,networkInterfaces --allow-run=/usr/sbin/sendmail,whoami --output=bin/mailer --target=x86_64-unknown-linux-gnu ./cli.ts",
"cli": "deno run --allow-read --allow-env --allow-net=0.0.0.0,jsr.io --allow-sys=osRelease,networkInterfaces --allow-run=/usr/sbin/sendmail,whoami ./cli.ts"
},
"fmt": {
"singleQuote": true,
@ -17,8 +17,11 @@
"useTabs": true
},
"imports": {
"@cliffy/": "https://deno.land/x/cliffy@v1.0.0-rc.3/",
"@std/path": "jsr:@std/path@^0.221.0",
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.5",
"@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.5",
"@std/fs": "jsr:@std/fs@^1.0.13",
"@std/json": "jsr:@std/json@^1.0.1",
"@types/nodemailer": "npm:@types/nodemailer@^6.4.15",
"jsx-email": "npm:jsx-email@^1.10.12",
"nodemailer": "npm:nodemailer@^6.9.13",
"nodemailer-smime": "npm:nodemailer-smime@^1.1.0",
@ -28,5 +31,6 @@
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
},
"license": "MIT"
}

5222
deno.lock

File diff suppressed because it is too large Load diff

3
mod.ts
View file

@ -1,2 +1,5 @@
export { send } from './src/send.ts'
export { Contact } from './src/contact.ts'
export type { Mail, Template } from './types.ts'
export { default as Message } from './templates/Message.tsx'
export { default as Welcome } from './templates/Welcome.tsx'

View file

@ -1,13 +1,15 @@
import custom from '../config/account.json' with { type: 'json' }
import { accounts, dkim } from '../config/loader.ts'
export type AddressString = `${string}@${string}.${string}`
export class Contact {
#name: string
#address: `${string}@${string}.${string}`
#address: AddressString
constructor(
{ name, address }: {
name: string
address: `${string}@${string}.${string}`
address: AddressString
},
) {
this.#name = name
@ -15,11 +17,11 @@ export class Contact {
}
static expand(shortName: string): Contact {
if (!(shortName in custom)) {
if (!(shortName in accounts)) {
throw new Error('unknown short name contact')
}
const { name, address } = custom[shortName as keyof typeof custom]
const { name, address } = accounts[shortName]
if (typeof name !== 'string') {
throw new SyntaxError(
@ -31,7 +33,9 @@ export class Contact {
`missing key "address" in contact short name config for "${shortName}"`,
)
}
if (!(/\w+@(\w+\.)?cohabit\.fr/.test(address))) {
const addressRegExp = new RegExp(String.raw`\w+@(\w+\.)?${dkim.domainName}`)
if (!(addressRegExp.test(address))) {
throw new SyntaxError(
`invalid "address" in contact short name config for "${shortName}"`,
)
@ -57,18 +61,18 @@ export class Contact {
return `${this.#name} <${this.#address}>`
}
toJSON() {
toJSON(): { name: string; address: AddressString } {
return {
name: this.#name,
address: this.#address,
} as const
}
get name() {
get name(): string {
return this.#name
}
get address() {
get address(): AddressString {
return this.#address
}
}

View file

@ -1,8 +1,11 @@
import type SendmailTransport from 'nodemailer'
import type { Mail } from '../types.ts'
import { renderTemplate } from './template.tsx'
import { transporter } from './transporter.ts'
export async function send(mail: Mail) {
export async function send(
mail: Mail,
): Promise<SendmailTransport.SentMessageInfo> {
const { html, text } = await renderTemplate(mail.body)
return (await transporter()).sendMail({

View file

@ -1,9 +1,11 @@
import React from 'preact/compat' //for jsx-email
import { render } from 'jsx-email'
// @deno-types="npm:turndown"
// @deno-types="npm:turndown@^7.1.3"
import Turndown from 'turndown'
import type { JSX } from 'preact'
console.assert(React !== undefined)
const htmlToMd = new Turndown({
headingStyle: 'atx',
codeBlockStyle: 'fenced',

View file

@ -1,9 +1,9 @@
// @deno-types="npm:@types/nodemailer"
// @deno-types="npm:@types/nodemailer@^6.4.15"
import nodemailer from 'nodemailer'
import { dkim } from '../config/loader.ts'
export async function transporter() {
const dkimPath =
'/home/julien/dkim_sendmail_keys/dkim_sendmail_cohabit_fr.pem'
const dkimPath = dkim.privateKey
const dkimPrivateKey = await Deno.readTextFile(dkimPath).catch((cause) => {
throw new Error(`unable to load DKIM private key from "${dkimPath}"`, {
cause,
@ -15,8 +15,8 @@ export async function transporter() {
newline: 'unix',
path: '/usr/sbin/sendmail',
dkim: {
domainName: 'cohabit.fr',
keySelector: 'sendmailY2024M03',
domainName: dkim.domainName,
keySelector: dkim.keySelector,
privateKey: dkimPrivateKey,
},
})

140
templates/MagicLink.tsx Normal file
View file

@ -0,0 +1,140 @@
import {
Body,
Button,
Container,
Heading,
Html,
Preview,
Section,
Text,
} from 'jsx-email'
import { Signature } from './components/Signature.tsx'
import type { Template } from '../types.ts'
import type { JSX } from 'preact'
import {
bodyCss,
buttonCss,
headingCss,
messageCss,
rootCss,
textCss,
} from './styles/base.tsx'
import { BaseStyle } from './styles/base.tsx'
function MagicLink(
{ message, device, ip, endpoint }: {
message?: string
device?: string
ip?: string
endpoint: string
},
): JSX.Element {
return (
<Html lang='fr' style={{ fontSize: '14px' }}>
<BaseStyle />
<Preview>Nouveau lien de connection</Preview>
<Body style={bodyCss}>
<Container style={messageCss}>
<Section>
<Heading as='h1' style={headingCss}>
Nouveau lien de connection
</Heading>
<Text style={textCss}>
Une nouvelle demande de connection à é effectuée sur votre
compte.
</Text>
<Text style={detailsCss}>
<span>
<span style={{ fontWeight: 'bolder' }}>{'Date : '}</span>
{dateNow()}
</span>
<br />
<span>
<span style={{ fontWeight: 'bolder' }}>{'Appareil : '}</span>
{device?.length ? device : 'Iconnue'}
</span>
<br />
<span>
<span style={{ fontWeight: 'bolder' }}>{'Ip : '}</span>
{ip?.length ? ip : 'Inconnue'}
</span>
</Text>
{message?.length ? <Text style={textCss}>{message}</Text> : ''}
<Text style={infoCss}>
Si vous n'êtes pas à l'origine de cette demande vous pouvez
ignorer ce mail en toute sécurité.
</Text>
<Container
style={{ width: '100%', textAlign: 'center', padding: '1rem' }}
>
<Button href={endpoint} style={buttonCss}>Me connecter</Button>
</Container>
</Section>
</Container>
<Signature />
</Body>
</Html>
)
}
function dateNow() {
return new Date().toLocaleString('fr', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
})
}
const infoCss: JSX.CSSProperties = {
...textCss,
opacity: 0.7,
}
const detailsCss: JSX.CSSProperties = {
...textCss,
backgroundColor: rootCss.backgroundColor,
padding: '0.5rem',
borderRadius: '0.4rem',
}
const template: Template<typeof MagicLink, Parameters<typeof MagicLink>[0]> = {
props: [
{
name: 'Message',
description: "Message à afficher à l'utilisateur.",
required: false,
multiline: false,
tag: 'message',
},
{
name: 'Device',
description: 'User-Agent (ou equiv.) de la demande.',
required: false,
multiline: false,
tag: 'device',
},
{
name: 'Ip',
description: 'Addresse ip de la demande.',
required: false,
multiline: false,
tag: 'ip',
},
{
name: 'Endpoint',
description: 'Endpoint du lien "Me connecter".',
required: true,
multiline: false,
tag: 'endpoint',
},
],
name: 'magic-link',
description: 'Coh@bit connection magic link.',
builder: MagicLink,
}
export default template

View file

@ -2,10 +2,11 @@ import { Body, Container, Html, Markdown, Preview } from 'jsx-email'
import { Signature } from './components/Signature.tsx'
import type { Template } from '../types.ts'
import { BaseStyle, bodyCss, messageCss, textCss } from './styles/base.tsx'
import type { JSX } from 'preact'
function Message(
{ summary, body }: { summary?: string; body: string },
) {
): JSX.Element {
return (
<Html lang='fr' style={{ fontSize: '14px' }}>
<BaseStyle />

View file

@ -12,7 +12,14 @@ import {
import { Signature } from './components/Signature.tsx'
import type { Template } from '../types.ts'
import type { JSX } from 'preact'
import { bodyCss, messageCss, rootCss, textCss } from './styles/base.tsx'
import {
bodyCss,
buttonCss,
headingCss,
messageCss,
rootCss,
textCss,
} from './styles/base.tsx'
import { BaseStyle } from './styles/base.tsx'
function Welcome(
@ -22,7 +29,7 @@ function Welcome(
login: string
endpoint?: string
},
) {
): JSX.Element {
return (
<Html lang='fr' style={{ fontSize: '14px' }}>
<BaseStyle />
@ -90,9 +97,7 @@ function Welcome(
style={{ width: '100%', textAlign: 'center', padding: '1rem' }}
>
<Button
href={(endpoint && endpoint.length > 1)
? endpoint
: 'https://cohabit.fr/profil'}
href={endpoint?.length ? endpoint : 'https://cohabit.fr/profil'}
style={buttonCss}
>
Accéder à mon compte
@ -106,14 +111,6 @@ function Welcome(
)
}
const headingCss: JSX.CSSProperties = {
fontFamily: 'Garamond, serif',
color: rootCss.accentColor,
textAlign: 'center',
margin: '-1rem 0 3rem',
fontSize: '2.5rem',
}
const preCss: JSX.CSSProperties = {
fontFamily: 'monospace',
fontSize: '1rem',
@ -122,18 +119,11 @@ const preCss: JSX.CSSProperties = {
backgroundColor: rootCss.backgroundColor,
}
const buttonCss: JSX.CSSProperties = {
color: 'white',
padding: '1rem',
borderRadius: '0.4rem',
fontSize: '1.2rem',
backgroundColor: rootCss.accentColor,
}
const imgCss: JSX.CSSProperties = {
display: 'inline',
padding: '1rem',
transform: 'translateY(40%)',
maxHeight: '3rem',
}
const template: Template<typeof Welcome, Parameters<typeof Welcome>[0]> = {

3
templates/mod.ts Normal file
View file

@ -0,0 +1,3 @@
export { default as magicLinkTemplate } from './MagicLink.tsx'
export { default as messageTemplate } from './Message.tsx'
export { default as welcomeTemplate } from './Welcome.tsx'

View file

@ -19,10 +19,26 @@ export const messageCss: JSX.CSSProperties = {
textWrap: 'balance',
}
export const headingCss: JSX.CSSProperties = {
fontFamily: 'Garamond, serif',
color: rootCss.accentColor,
textAlign: 'center',
margin: '-1rem 0 3rem',
fontSize: '2.5rem',
}
export const textCss: JSX.CSSProperties = {
fontSize: '1rem',
}
export const buttonCss: JSX.CSSProperties = {
color: 'white',
padding: '1rem',
borderRadius: '0.4rem',
fontSize: '1.2rem',
backgroundColor: rootCss.accentColor,
}
export const rawCss = `
a {
color: ${rootCss.accentColor};