Files
WRNexusJS/packages/mail/src/index.ts
T

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) };
}