@meith/drivers 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/package.json +47 -0
- package/src/cache/memory-cache.ts +61 -0
- package/src/cache/next-cache.ts +29 -0
- package/src/cache/redis-cache.ts +128 -0
- package/src/files/local-file-store.ts +59 -0
- package/src/files/s3-file-store.ts +190 -0
- package/src/highlighting/index.ts +1 -0
- package/src/highlighting/shiki-highlighter.ts +92 -0
- package/src/images/codec.ts +76 -0
- package/src/images/index.ts +9 -0
- package/src/images/locate-wasm.ts +108 -0
- package/src/images/processor.ts +40 -0
- package/src/index.ts +17 -0
- package/src/mail/index.ts +113 -0
- package/src/mail/sender.ts +12 -0
- package/src/mail/smtp.ts +77 -0
- package/src/queue/memory-queue.ts +137 -0
- package/src/queue/postgres-queue.ts +218 -0
- package/src/resolve.ts +89 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHighlighter, type Highlighter } from 'shiki'
|
|
2
|
+
|
|
3
|
+
const LANGUAGES = [
|
|
4
|
+
'bash',
|
|
5
|
+
'c',
|
|
6
|
+
'cpp',
|
|
7
|
+
'csharp',
|
|
8
|
+
'css',
|
|
9
|
+
'diff',
|
|
10
|
+
'dockerfile',
|
|
11
|
+
'go',
|
|
12
|
+
'graphql',
|
|
13
|
+
'html',
|
|
14
|
+
'http',
|
|
15
|
+
'ini',
|
|
16
|
+
'java',
|
|
17
|
+
'json',
|
|
18
|
+
'kotlin',
|
|
19
|
+
'markdown',
|
|
20
|
+
'php',
|
|
21
|
+
'python',
|
|
22
|
+
'ruby',
|
|
23
|
+
'rust',
|
|
24
|
+
'sql',
|
|
25
|
+
'swift',
|
|
26
|
+
'toml',
|
|
27
|
+
'tsx',
|
|
28
|
+
'typescript',
|
|
29
|
+
'yaml',
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
type Language = (typeof LANGUAGES)[number]
|
|
33
|
+
|
|
34
|
+
const ALIASES: Readonly<Record<string, Language>> = {
|
|
35
|
+
sh: 'bash',
|
|
36
|
+
shell: 'bash',
|
|
37
|
+
zsh: 'bash',
|
|
38
|
+
console: 'bash',
|
|
39
|
+
'c++': 'cpp',
|
|
40
|
+
cs: 'csharp',
|
|
41
|
+
js: 'typescript',
|
|
42
|
+
jsx: 'tsx',
|
|
43
|
+
javascript: 'typescript',
|
|
44
|
+
mjs: 'typescript',
|
|
45
|
+
cjs: 'typescript',
|
|
46
|
+
ts: 'typescript',
|
|
47
|
+
py: 'python',
|
|
48
|
+
python3: 'python',
|
|
49
|
+
rb: 'ruby',
|
|
50
|
+
rs: 'rust',
|
|
51
|
+
md: 'markdown',
|
|
52
|
+
yml: 'yaml',
|
|
53
|
+
env: 'ini',
|
|
54
|
+
docker: 'dockerfile',
|
|
55
|
+
golang: 'go',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveLanguage(requested: string | undefined): Language | null {
|
|
59
|
+
if (requested === undefined) return null
|
|
60
|
+
const lower = requested.toLowerCase()
|
|
61
|
+
const resolved = ALIASES[lower] ?? lower
|
|
62
|
+
return (LANGUAGES as readonly string[]).includes(resolved) ? (resolved as Language) : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let highlighter: Promise<Highlighter> | null = null
|
|
66
|
+
|
|
67
|
+
function getHighlighter(): Promise<Highlighter> {
|
|
68
|
+
highlighter ??= createHighlighter({
|
|
69
|
+
themes: ['vitesse-light', 'vitesse-dark'],
|
|
70
|
+
langs: [...LANGUAGES],
|
|
71
|
+
})
|
|
72
|
+
return highlighter
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CodeHighlighter {
|
|
76
|
+
highlight(code: string, language: string | undefined): Promise<string | null>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const codeHighlighter: CodeHighlighter = {
|
|
80
|
+
async highlight(code, language) {
|
|
81
|
+
const resolved = resolveLanguage(language)
|
|
82
|
+
if (resolved === null) return null
|
|
83
|
+
|
|
84
|
+
const shiki = await getHighlighter()
|
|
85
|
+
return shiki.codeToHtml(code, {
|
|
86
|
+
lang: resolved,
|
|
87
|
+
themes: { light: 'vitesse-light', dark: 'vitesse-dark' },
|
|
88
|
+
defaultColor: false,
|
|
89
|
+
structure: 'inline',
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import decodeJpeg, { init as initJpegDecode } from '@jsquash/jpeg/decode.js'
|
|
2
|
+
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js'
|
|
3
|
+
import decodePng, { init as initPngDecode } from '@jsquash/png/decode.js'
|
|
4
|
+
import encodePng, { init as initPngEncode } from '@jsquash/png/encode.js'
|
|
5
|
+
import resizeImage, { initResize } from '@jsquash/resize'
|
|
6
|
+
|
|
7
|
+
import { compileAsset } from './locate-wasm'
|
|
8
|
+
|
|
9
|
+
const WASM = {
|
|
10
|
+
png: '@jsquash/png/codec/pkg/squoosh_png_bg.wasm',
|
|
11
|
+
jpegDecode: '@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm',
|
|
12
|
+
jpegEncode: '@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm',
|
|
13
|
+
resize: '@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm',
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
let ready: Promise<void> | null = null
|
|
17
|
+
|
|
18
|
+
async function initOne(
|
|
19
|
+
init: (module: WebAssembly.Module) => Promise<unknown>,
|
|
20
|
+
specifier: string,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
await init(await compileAsset(specifier))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function initialise(): Promise<void> {
|
|
26
|
+
ready ??= (async () => {
|
|
27
|
+
await Promise.all([
|
|
28
|
+
initOne(initPngDecode, WASM.png),
|
|
29
|
+
initOne(initPngEncode, WASM.png),
|
|
30
|
+
initOne(initJpegDecode, WASM.jpegDecode),
|
|
31
|
+
initOne(initJpegEncode, WASM.jpegEncode),
|
|
32
|
+
initOne(initResize, WASM.resize),
|
|
33
|
+
])
|
|
34
|
+
})().catch((error: unknown) => {
|
|
35
|
+
ready = null
|
|
36
|
+
throw error
|
|
37
|
+
})
|
|
38
|
+
return ready
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DecodedImage {
|
|
42
|
+
readonly width: number
|
|
43
|
+
readonly height: number
|
|
44
|
+
readonly data: Uint8ClampedArray<ArrayBuffer>
|
|
45
|
+
readonly colorSpace: PredefinedColorSpace
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ImageFormat = 'png' | 'jpeg'
|
|
49
|
+
|
|
50
|
+
export async function decodeImage(bytes: ArrayBuffer, format: ImageFormat): Promise<DecodedImage> {
|
|
51
|
+
await initialise()
|
|
52
|
+
return format === 'png' ? decodePng(bytes) : decodeJpeg(bytes)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function encodeImage(
|
|
56
|
+
image: DecodedImage,
|
|
57
|
+
format: ImageFormat,
|
|
58
|
+
quality = 82,
|
|
59
|
+
): Promise<ArrayBuffer> {
|
|
60
|
+
await initialise()
|
|
61
|
+
return format === 'png' ? encodePng(image) : encodeJpeg(image, { quality })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resizeToFit(
|
|
65
|
+
image: DecodedImage,
|
|
66
|
+
max: { readonly width: number; readonly height: number },
|
|
67
|
+
): Promise<DecodedImage> {
|
|
68
|
+
await initialise()
|
|
69
|
+
const scale = Math.min(max.width / image.width, max.height / image.height, 1)
|
|
70
|
+
if (scale >= 1) return image
|
|
71
|
+
|
|
72
|
+
return resizeImage(image, {
|
|
73
|
+
width: Math.max(1, Math.round(image.width * scale)),
|
|
74
|
+
height: Math.max(1, Math.round(image.height * scale)),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { access, readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, parse } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const found = new Map<string, string>()
|
|
7
|
+
|
|
8
|
+
const cacheKey = (specifier: string, from: string) => `${from}\u0000${specifier}`
|
|
9
|
+
|
|
10
|
+
export function moduleFile(metaUrl: unknown, filename: unknown): string | undefined {
|
|
11
|
+
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) return fileURLToPath(metaUrl)
|
|
12
|
+
return typeof filename === 'string' && filename !== '' ? filename : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ownRequire(): NodeRequire | undefined {
|
|
16
|
+
try {
|
|
17
|
+
const here = moduleFile(
|
|
18
|
+
import.meta.url,
|
|
19
|
+
typeof __filename === 'string' ? __filename : undefined,
|
|
20
|
+
)
|
|
21
|
+
return here === undefined ? undefined : createRequire(here)
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function exists(path: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await access(path)
|
|
30
|
+
return true
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function storePrefix(specifier: string): string {
|
|
37
|
+
const parts = specifier.split('/')
|
|
38
|
+
return specifier.startsWith('@') ? `${parts[0]}+${parts[1]}@` : `${parts[0]}@`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ancestors(from: string): string[] {
|
|
42
|
+
const root = parse(from).root
|
|
43
|
+
const chain: string[] = []
|
|
44
|
+
let current = from
|
|
45
|
+
while (true) {
|
|
46
|
+
chain.push(current)
|
|
47
|
+
if (current === root) return chain
|
|
48
|
+
const next = dirname(current)
|
|
49
|
+
if (next === current) return chain
|
|
50
|
+
current = next
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function candidatesUnder(nodeModules: string, specifier: string): Promise<string[]> {
|
|
55
|
+
const paths = [join(nodeModules, specifier)]
|
|
56
|
+
|
|
57
|
+
const prefix = storePrefix(specifier)
|
|
58
|
+
let entries: string[]
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(join(nodeModules, '.pnpm'))
|
|
61
|
+
} catch {
|
|
62
|
+
return paths
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const entry of entries
|
|
66
|
+
.filter((e) => e.startsWith(prefix))
|
|
67
|
+
.sort()
|
|
68
|
+
.reverse()) {
|
|
69
|
+
paths.push(join(nodeModules, '.pnpm', entry, 'node_modules', specifier))
|
|
70
|
+
}
|
|
71
|
+
return paths
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function locateAsset(specifier: string, from = process.cwd()): Promise<string> {
|
|
75
|
+
const key = cacheKey(specifier, from)
|
|
76
|
+
const cached = found.get(key)
|
|
77
|
+
if (cached !== undefined) return cached
|
|
78
|
+
|
|
79
|
+
const required = ownRequire()
|
|
80
|
+
if (required !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
const path = required.resolve(specifier)
|
|
83
|
+
found.set(key, path)
|
|
84
|
+
return path
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const dir of ancestors(from)) {
|
|
91
|
+
for (const candidate of await candidatesUnder(join(dir, 'node_modules'), specifier)) {
|
|
92
|
+
if (await exists(candidate)) {
|
|
93
|
+
found.set(key, candidate)
|
|
94
|
+
return candidate
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Could not find "${specifier}" in any node_modules above ${from}. ` +
|
|
101
|
+
'The deployment did not copy the package — check `serverExternalPackages`.',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function compileAsset(specifier: string): Promise<WebAssembly.Module> {
|
|
106
|
+
const path = await locateAsset(specifier)
|
|
107
|
+
return WebAssembly.compile(await readFile(/* turbopackIgnore: true */ path))
|
|
108
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ImageProcessor, MAX_IMAGE, type ProcessedImage, THUMBNAIL } from '@meith/attachments'
|
|
2
|
+
|
|
3
|
+
import { decodeImage, encodeImage, resizeToFit } from './codec'
|
|
4
|
+
|
|
5
|
+
const ATTACHMENT_QUALITY = 85
|
|
6
|
+
|
|
7
|
+
const THUMBNAIL_QUALITY = 70
|
|
8
|
+
|
|
9
|
+
function toBytes(buffer: ArrayBuffer): Uint8Array {
|
|
10
|
+
return new Uint8Array(buffer)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const imageProcessor: ImageProcessor = {
|
|
14
|
+
async process(input) {
|
|
15
|
+
const decoded = await decodeImage(input.bytes.slice().buffer as ArrayBuffer, input.codec)
|
|
16
|
+
|
|
17
|
+
const fitted = await resizeToFit(decoded, input.fit ?? MAX_IMAGE)
|
|
18
|
+
const bytes = toBytes(await encodeImage(fitted, input.codec, ATTACHMENT_QUALITY))
|
|
19
|
+
|
|
20
|
+
const wantsThumbnail = input.thumbnail ?? true
|
|
21
|
+
const preview = wantsThumbnail ? await resizeToFit(fitted, THUMBNAIL) : fitted
|
|
22
|
+
|
|
23
|
+
const processed: ProcessedImage = {
|
|
24
|
+
bytes,
|
|
25
|
+
contentType: input.codec === 'png' ? 'image/png' : 'image/jpeg',
|
|
26
|
+
width: fitted.width,
|
|
27
|
+
height: fitted.height,
|
|
28
|
+
...(preview === fitted
|
|
29
|
+
? {}
|
|
30
|
+
: {
|
|
31
|
+
thumbnail: {
|
|
32
|
+
bytes: toBytes(await encodeImage(preview, 'jpeg', THUMBNAIL_QUALITY)),
|
|
33
|
+
contentType: 'image/jpeg',
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return processed
|
|
39
|
+
},
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { MemoryCache } from './cache/memory-cache'
|
|
2
|
+
export { NextCacheDriver } from './cache/next-cache'
|
|
3
|
+
export { RedisCacheDriver, type RedisCacheOptions } from './cache/redis-cache'
|
|
4
|
+
export { LocalFileStore } from './files/local-file-store'
|
|
5
|
+
export { S3FileStore, type S3FileStoreConfig, type S3Like } from './files/s3-file-store'
|
|
6
|
+
export {
|
|
7
|
+
ConfiguredMailDriver,
|
|
8
|
+
createMailDriver,
|
|
9
|
+
formatSender,
|
|
10
|
+
HttpMailDriver,
|
|
11
|
+
LogMailDriver,
|
|
12
|
+
MemoryMailDriver,
|
|
13
|
+
SmtpMailDriver,
|
|
14
|
+
} from './mail'
|
|
15
|
+
export { MemoryQueue } from './queue/memory-queue'
|
|
16
|
+
export { PostgresQueue } from './queue/postgres-queue'
|
|
17
|
+
export { currentMailConfig, drivers, resetDriversForTests } from './resolve'
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ConfigurationError, logger, type MailDriver, type OutgoingMail } from '@meith/core'
|
|
2
|
+
import {
|
|
3
|
+
canSendMail,
|
|
4
|
+
describeMailConfig,
|
|
5
|
+
type HttpMailConfig,
|
|
6
|
+
type MailConfig,
|
|
7
|
+
mailConfigProblems,
|
|
8
|
+
} from '@meith/settings'
|
|
9
|
+
|
|
10
|
+
import { formatSender } from './sender'
|
|
11
|
+
import { SmtpMailDriver } from './smtp'
|
|
12
|
+
|
|
13
|
+
export { formatSender } from './sender'
|
|
14
|
+
export { SmtpMailDriver } from './smtp'
|
|
15
|
+
|
|
16
|
+
export class LogMailDriver implements MailDriver {
|
|
17
|
+
send(mail: OutgoingMail): Promise<void> {
|
|
18
|
+
logger({ driver: 'log' }).info(
|
|
19
|
+
{ to: mail.to, subject: mail.subject },
|
|
20
|
+
'mail (not actually sent)',
|
|
21
|
+
)
|
|
22
|
+
return Promise.resolve()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MemoryMailDriver implements MailDriver {
|
|
27
|
+
readonly sent: OutgoingMail[] = []
|
|
28
|
+
|
|
29
|
+
send(mail: OutgoingMail): Promise<void> {
|
|
30
|
+
this.sent.push(mail)
|
|
31
|
+
return Promise.resolve()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
reset(): void {
|
|
35
|
+
this.sent.length = 0
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class HttpMailDriver implements MailDriver {
|
|
40
|
+
constructor(private readonly config: HttpMailConfig) {}
|
|
41
|
+
|
|
42
|
+
async send(mail: OutgoingMail): Promise<void> {
|
|
43
|
+
const controller = new AbortController()
|
|
44
|
+
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(this.config.endpoint, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
authorization: `Bearer ${this.config.token}`,
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
from: formatSender(this.config.from, mail.fromName),
|
|
55
|
+
to: mail.to,
|
|
56
|
+
subject: mail.subject,
|
|
57
|
+
text: mail.text,
|
|
58
|
+
...(mail.html ? { html: mail.html } : {}),
|
|
59
|
+
...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
|
|
60
|
+
}),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const body = await response.text().catch(() => '')
|
|
66
|
+
const detail = `${response.status} ${body.slice(0, 200)}`
|
|
67
|
+
|
|
68
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
69
|
+
throw new ConfigurationError(`Mail provider rejected the message: ${detail}`)
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Mail provider error: ${detail}`)
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timeout)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createMailDriver(config: MailConfig): MailDriver {
|
|
80
|
+
if (config.transport === 'log') return new LogMailDriver()
|
|
81
|
+
|
|
82
|
+
const problems = mailConfigProblems(config)
|
|
83
|
+
if (problems.length > 0) {
|
|
84
|
+
throw new ConfigurationError(`Mail is not fully configured: ${problems.join(' ')}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config.transport === 'http' ? new HttpMailDriver(config) : new SmtpMailDriver(config)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ConfiguredMailDriver implements MailDriver {
|
|
91
|
+
private cached: { readonly fingerprint: string; readonly driver: MailDriver } | null = null
|
|
92
|
+
|
|
93
|
+
constructor(private readonly resolve: () => Promise<MailConfig>) {}
|
|
94
|
+
|
|
95
|
+
async send(mail: OutgoingMail): Promise<void> {
|
|
96
|
+
const config = await this.resolve()
|
|
97
|
+
|
|
98
|
+
if (!canSendMail(config)) {
|
|
99
|
+
logger({ driver: 'mail' }).warn(
|
|
100
|
+
{ to: mail.to, subject: mail.subject, config: describeMailConfig(config) },
|
|
101
|
+
'mail not sent: this board has no working mail configuration',
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fingerprint = JSON.stringify(config)
|
|
107
|
+
if (this.cached === null || this.cached.fingerprint !== fingerprint) {
|
|
108
|
+
this.cached = { fingerprint, driver: createMailDriver(config) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await this.cached.driver.send(mail)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function formatSender(address: string, name?: string): string {
|
|
2
|
+
const cleaned = stripControlCharacters(name ?? '').trim()
|
|
3
|
+
if (cleaned === '') return address
|
|
4
|
+
|
|
5
|
+
const escaped = cleaned.replace(/([\\"])/g, '\\$1')
|
|
6
|
+
return `"${escaped}" <${address}>`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function stripControlCharacters(value: string): string {
|
|
10
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is this function's job
|
|
11
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
|
12
|
+
}
|
package/src/mail/smtp.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import nodemailer, { type Transporter } from 'nodemailer'
|
|
2
|
+
|
|
3
|
+
import { ConfigurationError, logger, type MailDriver, type OutgoingMail } from '@meith/core'
|
|
4
|
+
import type { SmtpMailConfig } from '@meith/settings'
|
|
5
|
+
|
|
6
|
+
import { formatSender } from './sender'
|
|
7
|
+
|
|
8
|
+
const CONNECTION_TIMEOUT_MS = 10_000
|
|
9
|
+
const GREETING_TIMEOUT_MS = 10_000
|
|
10
|
+
const SOCKET_TIMEOUT_MS = 20_000
|
|
11
|
+
|
|
12
|
+
function isPermanent(error: unknown): boolean {
|
|
13
|
+
if (typeof error !== 'object' || error === null) return false
|
|
14
|
+
const candidate = error as { responseCode?: unknown; code?: unknown }
|
|
15
|
+
|
|
16
|
+
if (typeof candidate.responseCode === 'number') {
|
|
17
|
+
return candidate.responseCode >= 500 && candidate.responseCode < 600
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return candidate.code === 'EAUTH'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describe(error: unknown): string {
|
|
24
|
+
if (typeof error !== 'object' || error === null) return String(error)
|
|
25
|
+
const candidate = error as { responseCode?: unknown; code?: unknown; message?: unknown }
|
|
26
|
+
|
|
27
|
+
const code =
|
|
28
|
+
typeof candidate.responseCode === 'number'
|
|
29
|
+
? String(candidate.responseCode)
|
|
30
|
+
: typeof candidate.code === 'string'
|
|
31
|
+
? candidate.code
|
|
32
|
+
: ''
|
|
33
|
+
const message = typeof candidate.message === 'string' ? candidate.message : String(error)
|
|
34
|
+
|
|
35
|
+
return (code === '' ? message : `${code} ${message}`).slice(0, 300)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SmtpMailDriver implements MailDriver {
|
|
39
|
+
private readonly transport: Transporter
|
|
40
|
+
|
|
41
|
+
constructor(private readonly config: SmtpMailConfig) {
|
|
42
|
+
this.transport = nodemailer.createTransport({
|
|
43
|
+
host: config.host,
|
|
44
|
+
port: config.port,
|
|
45
|
+
secure: config.security === 'tls',
|
|
46
|
+
requireTLS: config.security === 'starttls',
|
|
47
|
+
...(config.username === '' ? {} : { auth: { user: config.username, pass: config.password } }),
|
|
48
|
+
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
49
|
+
greetingTimeout: GREETING_TIMEOUT_MS,
|
|
50
|
+
socketTimeout: SOCKET_TIMEOUT_MS,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async send(mail: OutgoingMail): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
await this.transport.sendMail({
|
|
57
|
+
from: formatSender(this.config.from, mail.fromName),
|
|
58
|
+
to: mail.to,
|
|
59
|
+
subject: mail.subject,
|
|
60
|
+
text: mail.text,
|
|
61
|
+
...(mail.html === undefined ? {} : { html: mail.html }),
|
|
62
|
+
...(mail.replyTo === undefined ? {} : { replyTo: mail.replyTo }),
|
|
63
|
+
})
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const detail = describe(error)
|
|
66
|
+
logger({ driver: 'smtp', host: this.config.host }).warn(
|
|
67
|
+
{ to: mail.to, err: detail },
|
|
68
|
+
'smtp send failed',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (isPermanent(error)) {
|
|
72
|
+
throw new ConfigurationError(`The SMTP server rejected the message: ${detail}`)
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`SMTP error: ${detail}`, { cause: error })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { EnqueueOptions, Job, QueueDriver } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
interface Row {
|
|
6
|
+
id: string
|
|
7
|
+
kind: string
|
|
8
|
+
payload: unknown
|
|
9
|
+
runAt: number
|
|
10
|
+
attempts: number
|
|
11
|
+
maxAttempts: number
|
|
12
|
+
status: 'pending' | 'running' | 'done' | 'dead'
|
|
13
|
+
dedupeKey?: string
|
|
14
|
+
lastError?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryQueue implements QueueDriver {
|
|
18
|
+
private rows: Row[] = []
|
|
19
|
+
|
|
20
|
+
private backoffMs(attempt: number): number {
|
|
21
|
+
return Math.min(10 * attempt * attempt, 3600) * 1000
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
enqueue<TPayload>(
|
|
25
|
+
kind: string,
|
|
26
|
+
payload: TPayload,
|
|
27
|
+
options: EnqueueOptions = {},
|
|
28
|
+
): Promise<{ id: string; deduplicated: boolean }> {
|
|
29
|
+
if (options.dedupeKey) {
|
|
30
|
+
const live = this.rows.find(
|
|
31
|
+
(r) => r.dedupeKey === options.dedupeKey && r.status === 'pending',
|
|
32
|
+
)
|
|
33
|
+
if (live) return Promise.resolve({ id: live.id, deduplicated: true })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const row: Row = {
|
|
37
|
+
id: randomUUID(),
|
|
38
|
+
kind,
|
|
39
|
+
payload,
|
|
40
|
+
runAt: options.runAt?.getTime() ?? Date.now(),
|
|
41
|
+
attempts: 0,
|
|
42
|
+
maxAttempts: options.maxAttempts ?? 5,
|
|
43
|
+
status: 'pending',
|
|
44
|
+
}
|
|
45
|
+
if (options.dedupeKey) row.dedupeKey = options.dedupeKey
|
|
46
|
+
|
|
47
|
+
this.rows.push(row)
|
|
48
|
+
return Promise.resolve({ id: row.id, deduplicated: false })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async drain(
|
|
52
|
+
limit: number,
|
|
53
|
+
handler: (job: Job) => Promise<void>,
|
|
54
|
+
options: { readonly signal?: AbortSignal } = {},
|
|
55
|
+
): Promise<{ processed: number; failed: number }> {
|
|
56
|
+
const now = Date.now()
|
|
57
|
+
|
|
58
|
+
const claimed = this.rows
|
|
59
|
+
.filter((r) => r.status === 'pending' && r.runAt <= now && r.attempts < r.maxAttempts)
|
|
60
|
+
.slice(0, limit)
|
|
61
|
+
|
|
62
|
+
for (const row of claimed) {
|
|
63
|
+
row.status = 'running'
|
|
64
|
+
row.attempts += 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let processed = 0
|
|
68
|
+
let failed = 0
|
|
69
|
+
|
|
70
|
+
for (const [index, row] of claimed.entries()) {
|
|
71
|
+
if (options.signal?.aborted === true) {
|
|
72
|
+
for (const unrun of claimed.slice(index)) {
|
|
73
|
+
unrun.status = 'pending'
|
|
74
|
+
unrun.attempts -= 1
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await handler({
|
|
81
|
+
id: row.id,
|
|
82
|
+
kind: row.kind,
|
|
83
|
+
payload: row.payload,
|
|
84
|
+
attempt: row.attempts,
|
|
85
|
+
})
|
|
86
|
+
row.status = 'done'
|
|
87
|
+
processed += 1
|
|
88
|
+
} catch (error) {
|
|
89
|
+
failed += 1
|
|
90
|
+
row.lastError = error instanceof Error ? error.message : String(error)
|
|
91
|
+
row.status = row.attempts >= row.maxAttempts ? 'dead' : 'pending'
|
|
92
|
+
row.runAt = Date.now() + this.backoffMs(row.attempts)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { processed, failed }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
deadLettered(limit: number): Promise<readonly Job[]> {
|
|
100
|
+
return Promise.resolve(
|
|
101
|
+
this.rows
|
|
102
|
+
.filter((r) => r.status === 'dead')
|
|
103
|
+
.slice(0, limit)
|
|
104
|
+
.map((r) => ({
|
|
105
|
+
id: r.id,
|
|
106
|
+
kind: r.kind,
|
|
107
|
+
payload: r.payload,
|
|
108
|
+
attempt: r.attempts,
|
|
109
|
+
})),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
retry(jobId: string): Promise<boolean> {
|
|
114
|
+
const row = this.rows.find((r) => r.id === jobId && r.status === 'dead')
|
|
115
|
+
if (!row) return Promise.resolve(false)
|
|
116
|
+
|
|
117
|
+
row.status = 'pending'
|
|
118
|
+
row.attempts = 0
|
|
119
|
+
row.runAt = Date.now()
|
|
120
|
+
delete row.lastError
|
|
121
|
+
return Promise.resolve(true)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
size(): number {
|
|
125
|
+
return this.rows.length
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pending(): readonly Job[] {
|
|
129
|
+
return this.rows
|
|
130
|
+
.filter((r) => r.status === 'pending')
|
|
131
|
+
.map((r) => ({ id: r.id, kind: r.kind, payload: r.payload, attempt: r.attempts }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
reset(): void {
|
|
135
|
+
this.rows = []
|
|
136
|
+
}
|
|
137
|
+
}
|