From f52e1d50e4d955c3680d8942417f69ed247ea96f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sun, 23 Aug 2026 21:51:37 +0530 Subject: [PATCH] feat: centralize mail credential and sandbox policy --- bun.lock | 7 +-- packages/mail/package.json | 3 +- packages/mail/src/index.ts | 94 +++++++++++++++++++++++++++++---- packages/mail/test/mail.test.ts | 28 +++++++++- 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index f1fa015f..aa753ade 100644 --- a/bun.lock +++ b/bun.lock @@ -487,8 +487,9 @@ }, "packages/mail": { "name": "@wrnexus/mail", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { + "@wrnexus/encryption": "workspace:*", "@wrnexus/queue": "workspace:*", }, }, @@ -530,7 +531,7 @@ }, "packages/payment": { "name": "@wrnexus/payment", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@wrnexus/db": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -657,7 +658,7 @@ }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.8.19", + "version": "0.8.20", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", diff --git a/packages/mail/package.json b/packages/mail/package.json index 8648252d..71c3564f 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mail", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", @@ -8,6 +8,7 @@ ".": "./src/index.ts" }, "dependencies": { + "@wrnexus/encryption": "workspace:*", "@wrnexus/queue": "workspace:*" } } diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 1bfa50bb..9223cfd7 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -1,4 +1,5 @@ import type { DefinedJob } from "@wrnexus/queue"; +import { createKeyring, open, seal, type EncryptionKeyring } from "@wrnexus/encryption"; export interface MailMessage { from?: string; @@ -32,10 +33,12 @@ export function sealedMailDriver(options: SealedMailDriverOptions): MailDriver { let cached: Promise | 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; - })); + options.cache === false + ? create() + : (cached ??= create().catch((error) => { + cached = undefined; + throw error; + })); return { async send(message) { return (await driver()).send(message); @@ -59,14 +62,87 @@ export interface MailOptions { sandbox?: { enabled?: boolean; allowlist?: readonly string[] }; } -export function defineMail(options: MailOptions) { +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( - (options.sandbox?.allowlist ?? []).map((value) => value.trim().toLowerCase()), + (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 = 0; + 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[]) => { - 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`); + 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) { diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index 3039d1ad..03de89dd 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "bun:test"; -import { defineMail, defineMailTemplate, sealedMailDriver } from "../src/index.ts"; +import { + defineMail, + defineMailTemplate, + defineSealedMailCredentials, + mailSandboxDecision, + sealedMailDriver, +} from "../src/index.ts"; test("mail templates render and sandbox blocks accidental recipients", async () => { const sent: unknown[] = []; @@ -17,6 +23,26 @@ test("mail templates render and sandbox blocks accidental recipients", async () await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX"); }); +test("mail owns fail-closed sandbox decisions and sealed credential setup", async () => { + expect( + mailSandboxDecision({ enabled: 1, allowlist: '["Test@Example.com"]' }, "test@example.com"), + ).toEqual({ allowed: true }); + expect( + mailSandboxDecision({ enabled: true, allowlist: "not-json" }, "x@example.com").allowed, + ).toBe(false); + const previous = process.env.TEST_MAIL_ENCRYPTION_KEY; + process.env.TEST_MAIL_ENCRYPTION_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))); + try { + const credentials = defineSealedMailCredentials({ env: "TEST_MAIL_ENCRYPTION_KEY" }); + const sealed = await credentials.seal("smtp-secret"); + expect(sealed).not.toContain("smtp-secret"); + expect(await credentials.open(sealed)).toBe("smtp-secret"); + } finally { + if (previous === undefined) delete process.env.TEST_MAIL_ENCRYPTION_KEY; + else process.env.TEST_MAIL_ENCRYPTION_KEY = previous; + } +}); + test("sealed drivers decrypt lazily once and delegate send/test", async () => { let opens = 0; const sent: string[] = [];