feat: add application productivity foundations
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import type { DefinedJob } from "@wrnexus/queue";
|
||||
|
||||
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 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 function defineMail(options: MailOptions) {
|
||||
const allowed = new Set(
|
||||
(options.sandbox?.allowlist ?? []).map((value) => value.trim().toLowerCase()),
|
||||
);
|
||||
const assertAllowed = (recipients: readonly string[]) => {
|
||||
if (!options.sandbox?.enabled) return;
|
||||
const denied = recipients.find((recipient) => !allowed.has(recipient.trim().toLowerCase()));
|
||||
if (denied) throw new Error(`WRN-MAIL-SANDBOX: recipient '${denied}' is not allow-listed`);
|
||||
};
|
||||
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user