198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
import type { DefinedJob } from "@wrnexus/queue";
|
|
import { createKeyring, open, seal, type EncryptionKeyring } from "@wrnexus/encryption";
|
|
|
|
export interface MailMessage {
|
|
from?: string;
|
|
to: string | readonly string[];
|
|
subject: string;
|
|
html?: string;
|
|
text?: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
export interface MailDriver {
|
|
send(message: MailMessage): Promise<unknown>;
|
|
test?(): Promise<{ ok: boolean; error?: string }>;
|
|
}
|
|
|
|
export interface SealedMailDriverOptions {
|
|
/** Ciphertext stored by the application; plaintext is never retained here. */
|
|
sealedCredential: string;
|
|
unseal(value: string): Promise<string>;
|
|
create(credential: string): MailDriver | Promise<MailDriver>;
|
|
/** Cache the initialized driver. Defaults to true. */
|
|
cache?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Lazily opens a sealed credential only at the transport boundary. This keeps
|
|
* encryption policy and SMTP/vendor choice independent while preventing every
|
|
* app from rebuilding the same decrypt-on-send lifecycle.
|
|
*/
|
|
export function sealedMailDriver(options: SealedMailDriverOptions): MailDriver {
|
|
let cached: Promise<MailDriver> | undefined;
|
|
const create = async () => options.create(await options.unseal(options.sealedCredential));
|
|
const driver = () =>
|
|
options.cache === false
|
|
? create()
|
|
: (cached ??= create().catch((error) => {
|
|
cached = undefined;
|
|
throw error;
|
|
}));
|
|
return {
|
|
async send(message) {
|
|
return (await driver()).send(message);
|
|
},
|
|
async test() {
|
|
const resolved = await driver();
|
|
return resolved.test?.() ?? { ok: true };
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface MailTemplate<T = Record<string, unknown>> {
|
|
subject(data: T): string;
|
|
html?(data: T): string;
|
|
text?(data: T): string;
|
|
}
|
|
|
|
export interface MailOptions {
|
|
driver: MailDriver;
|
|
from?: string;
|
|
sandbox?: { enabled?: boolean; allowlist?: readonly string[] };
|
|
}
|
|
|
|
export interface MailSandboxDecision {
|
|
allowed: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
/** Fail-closed, case-insensitive sandbox policy shared by mail workers and defineMail. */
|
|
export function mailSandboxDecision(
|
|
sandbox: { enabled?: boolean | number; allowlist?: readonly string[] | string },
|
|
recipient: unknown,
|
|
): MailSandboxDecision {
|
|
if (!sandbox.enabled) return { allowed: true };
|
|
if (typeof recipient !== "string" || !recipient.trim()) {
|
|
return {
|
|
allowed: false,
|
|
reason: "sandbox mode is on and no valid recipient address was given",
|
|
};
|
|
}
|
|
let source: unknown = sandbox.allowlist ?? [];
|
|
if (typeof source === "string") {
|
|
try {
|
|
source = JSON.parse(source || "[]");
|
|
} catch {
|
|
source = [];
|
|
}
|
|
}
|
|
const allowed = new Set(
|
|
(Array.isArray(source) ? source : [])
|
|
.filter((value): value is string => typeof value === "string")
|
|
.map((value) => value.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
const normalized = recipient.trim().toLowerCase();
|
|
return allowed.has(normalized)
|
|
? { allowed: true }
|
|
: {
|
|
allowed: false,
|
|
reason: `sandbox mode is on and ${recipient} is not in the sandbox allow-list`,
|
|
};
|
|
}
|
|
|
|
export interface SealedMailCredentialsOptions {
|
|
/** Environment variable holding a base64 AES key. */
|
|
env?: string;
|
|
keyId?: string;
|
|
}
|
|
|
|
/** Owns validated, lazy encryption-key setup for credentials stored at rest. */
|
|
export function defineSealedMailCredentials(options: SealedMailCredentialsOptions = {}) {
|
|
const env = options.env ?? "APP_ENCRYPTION_KEY";
|
|
let cached: EncryptionKeyring | undefined;
|
|
const keyring = () => {
|
|
if (cached) return cached;
|
|
const secret = process.env[env];
|
|
if (!secret)
|
|
throw new Error(`${env} is not set; it is required to seal mail credentials at rest.`);
|
|
let length: number;
|
|
try {
|
|
length = atob(secret).length;
|
|
} catch {
|
|
throw new Error(`${env} must be a valid base64-encoded 16, 24 or 32 byte key.`);
|
|
}
|
|
if (![16, 24, 32].includes(length)) {
|
|
throw new Error(
|
|
`${env} must decode to 16, 24 or 32 bytes; the current value decodes to ${length}.`,
|
|
);
|
|
}
|
|
return (cached = createKeyring([{ id: options.keyId ?? "primary", secret, active: true }]));
|
|
};
|
|
return {
|
|
keyring,
|
|
seal: (plain: string) => seal(plain, keyring()),
|
|
open: (sealed: string) => open(sealed, keyring()),
|
|
};
|
|
}
|
|
|
|
export function defineMail(options: MailOptions) {
|
|
const assertAllowed = (recipients: readonly string[]) => {
|
|
for (const recipient of recipients) {
|
|
const decision = mailSandboxDecision(options.sandbox ?? {}, recipient);
|
|
if (!decision.allowed) throw new Error(`WRN-MAIL-SANDBOX: ${decision.reason}`);
|
|
}
|
|
};
|
|
return {
|
|
async send(message: MailMessage) {
|
|
const recipients = typeof message.to === "string" ? [message.to] : [...message.to];
|
|
assertAllowed(recipients);
|
|
return options.driver.send({ ...message, from: message.from ?? options.from });
|
|
},
|
|
async render<T>(template: MailTemplate<T>, data: T, to: string | readonly string[]) {
|
|
return {
|
|
from: options.from,
|
|
to,
|
|
subject: template.subject(data),
|
|
html: template.html?.(data),
|
|
text: template.text?.(data),
|
|
} satisfies MailMessage;
|
|
},
|
|
test: () => options.driver.test?.() ?? Promise.resolve({ ok: true }),
|
|
};
|
|
}
|
|
|
|
export function defineMailTemplate<T>(template: MailTemplate<T>): MailTemplate<T> {
|
|
return template;
|
|
}
|
|
|
|
export function interpolateTemplate(source: string, data: Record<string, unknown>): string {
|
|
return source.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (_match, path: string) => {
|
|
let value: unknown = data;
|
|
for (const part of path.split("."))
|
|
value =
|
|
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined;
|
|
return value == null ? "" : String(value);
|
|
});
|
|
}
|
|
|
|
export function storedMailTemplate(template: {
|
|
subject: string;
|
|
html?: string;
|
|
text?: string;
|
|
}): MailTemplate<Record<string, unknown>> {
|
|
return {
|
|
subject: (data) => interpolateTemplate(template.subject, data),
|
|
html:
|
|
template.html === undefined ? undefined : (data) => interpolateTemplate(template.html!, data),
|
|
text:
|
|
template.text === undefined ? undefined : (data) => interpolateTemplate(template.text!, data),
|
|
};
|
|
}
|
|
|
|
/** Queue a fully rendered message without coupling the mail package to one queue backend. */
|
|
export function queuedMail<T extends MailMessage>(job: Pick<DefinedJob<T>, "add">) {
|
|
return { send: (message: T) => job.add(message) };
|
|
}
|