diff --git a/.changeset/friendly-tips-grow.md b/.changeset/friendly-tips-grow.md new file mode 100644 index 000000000..7da535eb5 --- /dev/null +++ b/.changeset/friendly-tips-grow.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-worker-mailer": minor +--- + +Adds the Worker Mailer SMTP plugin and updates it to use `@workermailer/smtp` with STARTTLS and implicit TLS configuration. diff --git a/demos/cloudflare/README.md b/demos/cloudflare/README.md index 34e22fbfa..fc38dd51c 100644 --- a/demos/cloudflare/README.md +++ b/demos/cloudflare/README.md @@ -51,6 +51,16 @@ pnpm deploy This builds and deploys to Cloudflare Workers. EmDash handles migrations automatically on startup. +## Email + +This demo includes `@emdash-cms/plugin-worker-mailer` in `astro.config.mjs`. + +In this demo, `workerMailerPlugin()` runs as an isolated plugin and exposes its +SMTP settings page in EmDash so you can configure the connection in the admin UI. + +Cloudflare Workers SMTP connections must start secure, so this plugin uses an +implicit TLS / SMTPS endpoint instead of upgrading a plaintext connection. + ## Notes - `astro dev` now uses `workerd` (the real Workers runtime) - development matches production diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index de86808ce..a3e375c51 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -12,6 +12,7 @@ import { } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; @@ -74,7 +75,14 @@ export default defineConfig({ formsPlugin(), ], // Sandboxed plugins (run in isolated workers) - sandboxed: [webhookNotifierPlugin()], + sandboxed: [ + // SMTP delivery with Block Kit settings in an isolated worker. + // Configure credentials in the plugin settings page. Cloudflare + // Workers must start SMTP connections secure, so this uses an + // implicit TLS / SMTPS endpoint, usually on port 465. + workerMailerPlugin(), + webhookNotifierPlugin(), + ], // Sandbox runner for Cloudflare sandboxRunner: sandbox(), // Plugin marketplace diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index c4e53d242..1d758b3b6 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -13,14 +13,15 @@ "db:reset:remote": "./scripts/reset-db.sh", "typecheck": "astro check" }, - "dependencies": { - "@astrojs/cloudflare": "catalog:", - "@astrojs/react": "catalog:", - "@emdash-cms/cloudflare": "workspace:*", - "@emdash-cms/plugin-forms": "workspace:*", - "@emdash-cms/plugin-webhook-notifier": "workspace:*", - "@tanstack/react-query": "catalog:", - "@tanstack/react-router": "catalog:", + "dependencies": { + "@astrojs/cloudflare": "catalog:", + "@astrojs/react": "catalog:", + "@emdash-cms/cloudflare": "workspace:*", + "@emdash-cms/plugin-forms": "workspace:*", + "@emdash-cms/plugin-worker-mailer": "workspace:*", + "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "catalog:", "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", diff --git a/packages/plugins/worker-mailer/CHANGELOG.md b/packages/plugins/worker-mailer/CHANGELOG.md new file mode 100644 index 000000000..6c899c416 --- /dev/null +++ b/packages/plugins/worker-mailer/CHANGELOG.md @@ -0,0 +1,8 @@ +# @emdash-cms/plugin-worker-mailer + +## 0.1.0 + +### Minor Changes + +- Initial release of the Worker Mailer email provider plugin for EmDash. +- Support secure SMTP over implicit TLS / SMTPS on Cloudflare Workers. diff --git a/packages/plugins/worker-mailer/README.md b/packages/plugins/worker-mailer/README.md new file mode 100644 index 000000000..a8afd55fc --- /dev/null +++ b/packages/plugins/worker-mailer/README.md @@ -0,0 +1,38 @@ +# @emdash-cms/plugin-worker-mailer + +SMTP provider plugin for EmDash on Cloudflare Workers using `@workermailer/smtp`. + +Cloudflare Workers SMTP connections must start secure, so this plugin uses +implicit TLS / SMTPS and does not expose plaintext or STARTTLS upgrade flows. + +## Usage + +Register the plugin in `astro.config.mjs`: + +```js +import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [workerMailerPlugin()], + }), + ], +}); +``` + +Configure the SMTP connection in the EmDash admin UI at the plugin's settings page. +On install, the plugin seeds secure defaults for: + +- `port = 465` +- `authType = "plain"` + +## Settings + +- `host`: SMTP hostname +- `port`: SMTP port, usually `465` for implicit TLS / SMTPS +- `authType`: `plain`, `login`, or `cram-md5` +- `username`: SMTP username +- `password`: SMTP password +- `fromEmail`: sender email override, defaults to `username` +- `fromName`: optional sender display name diff --git a/packages/plugins/worker-mailer/package.json b/packages/plugins/worker-mailer/package.json new file mode 100644 index 000000000..d9f3e9ef4 --- /dev/null +++ b/packages/plugins/worker-mailer/package.json @@ -0,0 +1,48 @@ +{ + "name": "@emdash-cms/plugin-worker-mailer", + "version": "0.1.0", + "description": "SMTP provider plugin for EmDash CMS on Cloudflare Workers using @workermailer/smtp", + "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/sandbox-entry.mjs" + }, + "files": [ + "dist" + ], + "keywords": [ + "emdash", + "cms", + "plugin", + "email", + "smtp", + "cloudflare", + "worker-mailer" + ], + "author": "Andre Ribas (@RibasSu)", + "license": "MIT", + "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", + "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", + "test": "vitest run", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "@workermailer/smtp": "^0.1.0", + "emdash": "workspace:*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/plugins/worker-mailer" + } +} diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts new file mode 100644 index 000000000..98aa837fc --- /dev/null +++ b/packages/plugins/worker-mailer/src/index.ts @@ -0,0 +1,17 @@ +import type { PluginDescriptor } from "emdash"; + +import { PLUGIN_ID, VERSION } from "./shared.js"; + +/** + * Standard descriptor for isolated Block Kit configuration and runtime delivery. + */ +export function workerMailerPlugin(): PluginDescriptor { + return { + id: PLUGIN_ID, + version: VERSION, + format: "standard", + entrypoint: "@emdash-cms/plugin-worker-mailer/sandbox", + capabilities: ["email:provide"], + adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], + }; +} diff --git a/packages/plugins/worker-mailer/src/sandbox-entry.ts b/packages/plugins/worker-mailer/src/sandbox-entry.ts new file mode 100644 index 000000000..b9ddd1398 --- /dev/null +++ b/packages/plugins/worker-mailer/src/sandbox-entry.ts @@ -0,0 +1,210 @@ +/** + * Sandbox Entry Point -- Worker Mailer SMTP + * + * Standard-format runtime entry for isolated / marketplace-style use. + * Configuration comes from plugin KV settings and Block Kit admin pages. + */ + +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +import { + DEFAULT_AUTH_TYPE, + DEFAULT_SECURE_PORT, + SECURE_CONNECTION_MESSAGE, + createWorkerMailerHooks, +} from "./shared.js"; + +interface AdminInteraction { + type: string; + page?: string; + action_id?: string; + values?: Record; +} + +export default definePlugin({ + hooks: createWorkerMailerHooks(), + routes: { + admin: { + handler: async (routeCtx: { input: unknown }, ctx: PluginContext) => { + const interaction = (routeCtx.input ?? {}) as AdminInteraction; + + if (interaction.type === "page_load" && interaction.page === "/settings") { + return buildSettingsPage(ctx); + } + + if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { + return saveSettings(ctx, interaction.values ?? {}); + } + + return { blocks: [] }; + }, + }, + }, +}); + +function toNonEmpty(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function toPortNumber(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +async function buildSettingsPage(ctx: PluginContext) { + const host = (await ctx.kv.get("settings:host")) ?? ""; + const username = (await ctx.kv.get("settings:username")) ?? ""; + const fromEmail = (await ctx.kv.get("settings:fromEmail")) ?? ""; + const fromName = (await ctx.kv.get("settings:fromName")) ?? ""; + const authType = (await ctx.kv.get("settings:authType")) ?? DEFAULT_AUTH_TYPE; + const port = toPortNumber( + await ctx.kv.get("settings:port"), + DEFAULT_SECURE_PORT, + ); + const hasPassword = !!(await ctx.kv.get("settings:password")); + + return { + blocks: [ + { type: "header", text: "SMTP Settings" }, + { + type: "context", + text: "Configure Worker Mailer for isolated SMTP delivery with Block Kit settings.", + }, + { + type: "fields", + fields: [ + { label: "Connection", value: "Implicit TLS / SMTPS" }, + { label: "Port", value: String(port) }, + { label: "Host", value: host || "Not configured" }, + { label: "Password", value: hasPassword ? "Stored" : "Not set" }, + ], + }, + { type: "divider" }, + { + type: "form", + block_id: "worker-mailer-settings", + fields: [ + { + type: "text_input", + action_id: "host", + label: "SMTP Host", + initial_value: host, + }, + { + type: "number_input", + action_id: "port", + label: "SMTP Port", + initial_value: port, + min: 1, + max: 65535, + }, + { + type: "select", + action_id: "authType", + label: "Auth Type", + options: [ + { label: "PLAIN", value: "plain" }, + { label: "LOGIN", value: "login" }, + { label: "CRAM-MD5", value: "cram-md5" }, + ], + initial_value: authType, + }, + { + type: "text_input", + action_id: "username", + label: "SMTP Username", + initial_value: username, + }, + { + type: "secret_input", + action_id: "password", + label: "SMTP Password", + }, + { + type: "text_input", + action_id: "fromEmail", + label: "From Email", + initial_value: fromEmail, + }, + { + type: "text_input", + action_id: "fromName", + label: "From Name", + initial_value: fromName, + }, + ], + submit: { label: "Save Settings", action_id: "save_settings" }, + }, + { + type: "context", + text: + `${SECURE_CONNECTION_MESSAGE} ` + + "Leave From Email blank to fall back to the SMTP username. " + + "Leave Password blank to keep the stored secret.", + }, + ], + }; +} + +async function saveSettings(ctx: PluginContext, values: Record) { + const port = toPortNumber(values.port, DEFAULT_SECURE_PORT); + + if (!Number.isFinite(port) || port < 1 || port > 65535) { + return { + ...(await buildSettingsPage(ctx)), + toast: { message: "Port must be between 1 and 65535", type: "error" }, + }; + } + + await ctx.kv.delete("settings:transportSecurity"); + await ctx.kv.delete("settings:transportSecurityMode"); + await ctx.kv.delete("settings:startTls"); + await ctx.kv.delete("settings:secure"); + await ctx.kv.set("settings:port", port); + await ctx.kv.set("settings:authType", toNonEmpty(values.authType) ?? DEFAULT_AUTH_TYPE); + + const host = toNonEmpty(values.host); + if (host) { + await ctx.kv.set("settings:host", host); + } else { + await ctx.kv.delete("settings:host"); + } + + const username = toNonEmpty(values.username); + if (username) { + await ctx.kv.set("settings:username", username); + } else { + await ctx.kv.delete("settings:username"); + } + + const password = toNonEmpty(values.password); + if (password) { + await ctx.kv.set("settings:password", password); + } + + const fromEmail = toNonEmpty(values.fromEmail); + if (fromEmail) { + await ctx.kv.set("settings:fromEmail", fromEmail); + } else { + await ctx.kv.delete("settings:fromEmail"); + } + + const fromName = toNonEmpty(values.fromName); + if (fromName) { + await ctx.kv.set("settings:fromName", fromName); + } else { + await ctx.kv.delete("settings:fromName"); + } + + return { + ...(await buildSettingsPage(ctx)), + toast: { message: "Settings saved", type: "success" }, + }; +} diff --git a/packages/plugins/worker-mailer/src/shared.ts b/packages/plugins/worker-mailer/src/shared.ts new file mode 100644 index 000000000..ecedced5e --- /dev/null +++ b/packages/plugins/worker-mailer/src/shared.ts @@ -0,0 +1,160 @@ +import type { AuthType, EmailOptions, WorkerMailerOptions } from "@workermailer/smtp"; +import type { PluginContext } from "emdash"; +import type { PluginHooks } from "emdash"; + +export const PLUGIN_ID = "worker-mailer"; +export const VERSION = "0.1.0"; +export const DEFAULT_AUTH_TYPE = "plain"; +export const DEFAULT_SECURE_PORT = 465; +export const SECURE_CONNECTION_MESSAGE = + "Cloudflare Workers SMTP connections must start secure. Configure an implicit TLS / SMTPS endpoint, usually on port 465."; + +export interface WorkerMailerConfig { + host: string; + port: number; + authType: AuthType; + username: string; + password: string; + fromEmail: string; + fromName: string | undefined; +} + +function isAuthType(value: string): value is AuthType { + return value === "plain" || value === "login" || value === "cram-md5"; +} + +function coerceAuthType(value: unknown, fallback: AuthType): AuthType { + if (typeof value !== "string") return fallback; + return isAuthType(value) ? value : fallback; +} + +function coerceNumber(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function toNonEmpty(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export async function readConfig(ctx: PluginContext): Promise { + const host = toNonEmpty(await ctx.kv.get("settings:host")); + const port = coerceNumber(await ctx.kv.get("settings:port"), DEFAULT_SECURE_PORT); + const authType = coerceAuthType(await ctx.kv.get("settings:authType"), DEFAULT_AUTH_TYPE); + const username = toNonEmpty(await ctx.kv.get("settings:username")); + const password = toNonEmpty(await ctx.kv.get("settings:password")); + + const explicitFrom = toNonEmpty(await ctx.kv.get("settings:fromEmail")); + const fromEmail = explicitFrom ?? username; + const fromName = toNonEmpty(await ctx.kv.get("settings:fromName")); + + const missing: string[] = []; + if (!host) missing.push("host"); + if (!username) missing.push("username"); + if (!password) missing.push("password"); + if (!fromEmail) missing.push("fromEmail (or username)"); + if (!Number.isFinite(port) || port <= 0 || port > 65535) missing.push("port"); + + if (missing.length > 0) { + throw new Error( + `Worker Mailer is not configured. Missing/invalid setting(s): ${missing.join(", ")}.`, + ); + } + + return { + host: host!, + port, + authType, + username: username!, + password: password!, + fromEmail: fromEmail!, + fromName, + }; +} + +async function setDefault( + ctx: PluginContext, + key: string, + value: string | number | boolean | undefined, +): Promise { + if (value === undefined) return; + const existing = await ctx.kv.get(key); + if (existing !== null) return; + await ctx.kv.set(key, value); +} + +export async function installDefaults(ctx: PluginContext): Promise { + await setDefault(ctx, "settings:port", DEFAULT_SECURE_PORT); + await ctx.kv.delete("settings:transportSecurity"); + await ctx.kv.delete("settings:transportSecurityMode"); + await ctx.kv.delete("settings:startTls"); + await ctx.kv.delete("settings:secure"); + await setDefault(ctx, "settings:authType", DEFAULT_AUTH_TYPE); +} + +async function loadWorkerMailer(): Promise { + try { + return await import("@workermailer/smtp"); + } catch (error) { + throw new Error( + `Failed to load @workermailer/smtp. ` + + `Ensure this plugin runs on Cloudflare Workers with TCP sockets available.`, + { cause: error }, + ); + } +} + +export async function sendWithWorkerMailer( + ctx: PluginContext, + config: WorkerMailerConfig, + message: { to: string; subject: string; text: string; html?: string }, +): Promise { + const { WorkerMailer } = await loadWorkerMailer(); + + const mailerOptions: WorkerMailerOptions = { + host: config.host, + port: config.port, + secure: true, + startTls: false, + authType: config.authType, + credentials: { + username: config.username, + password: config.password, + }, + }; + + const emailOptions: EmailOptions = { + from: config.fromName ? { name: config.fromName, email: config.fromEmail } : config.fromEmail, + to: message.to, + subject: message.subject, + text: message.text, + html: message.html, + }; + + await WorkerMailer.send(mailerOptions, emailOptions); + + ctx.log.info(`Delivered email to ${message.to} via Worker Mailer (implicit TLS)`); +} + +export function createWorkerMailerHooks(): Pick { + return { + "plugin:install": { + handler: async (_event, ctx) => { + await installDefaults(ctx); + }, + }, + "email:deliver": { + exclusive: true, + handler: async (event, ctx) => { + const config = await readConfig(ctx); + await sendWithWorkerMailer(ctx, config, event.message); + }, + }, + }; +} diff --git a/packages/plugins/worker-mailer/tests/plugin.test.ts b/packages/plugins/worker-mailer/tests/plugin.test.ts new file mode 100644 index 000000000..0939a533f --- /dev/null +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -0,0 +1,387 @@ +import type { PluginContext } from "emdash"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { sendMock } = vi.hoisted(() => ({ + sendMock: vi.fn(), +})); + +vi.mock("@workermailer/smtp", () => ({ + WorkerMailer: { + send: sendMock, + }, +})); + +import { workerMailerPlugin } from "../src/index.js"; +import sandboxEntry from "../src/sandbox-entry.js"; + +function createMockContext(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + const kv = { + get: vi.fn(async (key: string) => (store.has(key) ? store.get(key)! : null)), + set: vi.fn(async (key: string, value: unknown) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => store.delete(key)), + list: vi.fn(async (prefix = "") => + [...store.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => ({ key, value })), + ), + }; + const log = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + return { + store, + kv, + log, + ctx: { + kv, + log, + } as unknown as PluginContext, + }; +} + +function getHook(name: "plugin:install" | "email:deliver") { + const hook = sandboxEntry.hooks?.[name]; + if (!hook) { + throw new Error(`Expected hook ${name} to be defined`); + } + return hook as { + exclusive?: boolean; + handler: (event: unknown, ctx: PluginContext) => Promise; + }; +} + +function getAdminRoute() { + const route = sandboxEntry.routes?.admin; + if (!route) { + throw new Error("Expected admin route to be defined"); + } + return route.handler as ( + routeCtx: { input: unknown; request?: { url: string } }, + ctx: PluginContext, + ) => Promise<{ + blocks: Array>; + toast?: { message: string; type: string }; + }>; +} + +describe("workerMailerPlugin descriptor", () => { + it("returns a valid standard plugin descriptor", () => { + const descriptor = workerMailerPlugin(); + + expect(descriptor).toMatchObject({ + id: "worker-mailer", + version: "0.1.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-worker-mailer/sandbox", + capabilities: ["email:provide"], + }); + expect(descriptor.adminPages).toHaveLength(1); + }); +}); + +describe("sandbox entry hooks", () => { + beforeEach(() => { + sendMock.mockReset(); + sendMock.mockResolvedValue(undefined); + }); + + it("declares install and exclusive delivery hooks", () => { + const deliverHook = getHook("email:deliver"); + + expect(sandboxEntry.hooks).toHaveProperty("email:deliver"); + expect(sandboxEntry.hooks).toHaveProperty("plugin:install"); + expect(deliverHook.exclusive).toBe(true); + }); + + it("seeds install defaults and removes legacy transport settings", async () => { + const { ctx, kv, store } = createMockContext({ + "settings:transportSecurity": "starttls", + "settings:transportSecurityMode": "legacy", + "settings:startTls": true, + "settings:secure": true, + }); + + await getHook("plugin:install").handler({}, ctx); + + expect(store.has("settings:transportSecurity")).toBe(false); + expect(store.get("settings:port")).toBe(465); + expect(store.get("settings:authType")).toBe("plain"); + expect(store.has("settings:host")).toBe(false); + expect(store.has("settings:username")).toBe(false); + expect(store.has("settings:password")).toBe(false); + expect(store.has("settings:fromEmail")).toBe(false); + expect(store.has("settings:fromName")).toBe(false); + expect(store.has("settings:transportSecurity")).toBe(false); + expect(store.has("settings:transportSecurityMode")).toBe(false); + expect(store.has("settings:startTls")).toBe(false); + expect(store.has("settings:secure")).toBe(false); + expect(kv.delete).toHaveBeenCalledWith("settings:transportSecurity"); + expect(kv.delete).toHaveBeenCalledWith("settings:transportSecurityMode"); + expect(kv.delete).toHaveBeenCalledWith("settings:startTls"); + expect(kv.delete).toHaveBeenCalledWith("settings:secure"); + }); + + it("does not overwrite existing settings during install", async () => { + const { ctx, store } = createMockContext({ + "settings:host": "smtp.saved.example.com", + "settings:port": 2525, + "settings:authType": "cram-md5", + "settings:username": "saved-user", + "settings:password": "saved-pass", + "settings:fromEmail": "saved@example.com", + "settings:fromName": "Saved Name", + }); + + await getHook("plugin:install").handler({}, ctx); + + expect(store.get("settings:host")).toBe("smtp.saved.example.com"); + expect(store.get("settings:port")).toBe(2525); + expect(store.get("settings:authType")).toBe("cram-md5"); + expect(store.get("settings:username")).toBe("saved-user"); + expect(store.get("settings:password")).toBe("saved-pass"); + expect(store.get("settings:fromEmail")).toBe("saved@example.com"); + expect(store.get("settings:fromName")).toBe("Saved Name"); + }); + + it("delivers secure SMTP email and falls back fromEmail to the username", async () => { + const { ctx, log } = createMockContext({ + "settings:host": "smtp.example.com", + "settings:username": "mailer@example.com", + "settings:password": "secret", + }); + + await getHook("email:deliver").handler( + { + message: { + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + }, + }, + ctx, + ); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock).toHaveBeenCalledWith( + { + host: "smtp.example.com", + port: 465, + secure: true, + startTls: false, + authType: "plain", + credentials: { + username: "mailer@example.com", + password: "secret", + }, + }, + { + from: "mailer@example.com", + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + html: undefined, + }, + ); + expect(log.info).toHaveBeenCalledWith( + "Delivered email to hello@example.com via Worker Mailer (implicit TLS)", + ); + }); + + it("delivers implicit TLS email with stored settings", async () => { + const { ctx } = createMockContext({ + "settings:host": "smtp.saved.example.com", + "settings:port": "465", + "settings:authType": "login", + "settings:username": "saved-user", + "settings:password": "saved-pass", + "settings:fromEmail": "sender@example.com", + "settings:fromName": "Support Team", + }); + + await getHook("email:deliver").handler( + { + message: { + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + html: "

Hello

", + }, + }, + ctx, + ); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock).toHaveBeenCalledWith( + { + host: "smtp.saved.example.com", + port: 465, + secure: true, + startTls: false, + authType: "login", + credentials: { + username: "saved-user", + password: "saved-pass", + }, + }, + { + from: { + name: "Support Team", + email: "sender@example.com", + }, + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + html: "

Hello

", + }, + ); + }); + + it("fails fast when required SMTP settings are missing", async () => { + const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", + }); + + await expect( + getHook("email:deliver").handler( + { + message: { + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + }, + }, + ctx, + ), + ).rejects.toThrow( + "Worker Mailer is not configured. Missing/invalid setting(s): username, password, fromEmail (or username).", + ); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("fails fast when the configured port is invalid", async () => { + const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", + "settings:username": "mailer@example.com", + "settings:password": "secret", + "settings:port": 70000, + }); + + await expect( + getHook("email:deliver").handler( + { + message: { + to: "hello@example.com", + subject: "Hello", + text: "Plain text body", + }, + }, + ctx, + ), + ).rejects.toThrow("Worker Mailer is not configured. Missing/invalid setting(s): port."); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); + +describe("sandbox entry admin route", () => { + it("renders the SMTP settings page via Block Kit", async () => { + const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", + "settings:port": 465, + "settings:authType": "login", + "settings:username": "mailer@example.com", + "settings:fromEmail": "sender@example.com", + "settings:password": "secret", + }); + + const result = await getAdminRoute()( + { + input: { + type: "page_load", + page: "/settings", + }, + }, + ctx, + ); + + expect(result.blocks[0]).toMatchObject({ + type: "header", + text: "SMTP Settings", + }); + expect(result.blocks).toContainEqual( + expect.objectContaining({ + type: "form", + submit: { + label: "Save Settings", + action_id: "save_settings", + }, + }), + ); + expect(result.blocks).toContainEqual( + expect.objectContaining({ + type: "fields", + fields: expect.arrayContaining([ + { label: "Connection", value: "Implicit TLS / SMTPS" }, + { label: "Password", value: "Stored" }, + ]), + }), + ); + const formBlock = result.blocks.find((block) => block.type === "form") as { + fields?: Array<{ action_id?: string }>; + }; + expect(formBlock.fields).not.toContainEqual( + expect.objectContaining({ action_id: "transportSecurity" }), + ); + }); + + it("saves SMTP settings from a Block Kit form submission", async () => { + const { ctx, store } = createMockContext({ + "settings:transportSecurity": "starttls", + "settings:transportSecurityMode": "legacy", + "settings:startTls": true, + "settings:secure": true, + "settings:password": "existing-secret", + }); + + const result = await getAdminRoute()( + { + input: { + type: "form_submit", + action_id: "save_settings", + values: { + host: "smtp.example.com", + port: "465", + authType: "login", + username: "mailer@example.com", + password: "", + fromEmail: "", + fromName: "Support", + }, + }, + }, + ctx, + ); + + expect(store.get("settings:host")).toBe("smtp.example.com"); + expect(store.has("settings:transportSecurity")).toBe(false); + expect(store.has("settings:transportSecurityMode")).toBe(false); + expect(store.has("settings:startTls")).toBe(false); + expect(store.has("settings:secure")).toBe(false); + expect(store.get("settings:port")).toBe(465); + expect(store.get("settings:authType")).toBe("login"); + expect(store.get("settings:username")).toBe("mailer@example.com"); + expect(store.get("settings:password")).toBe("existing-secret"); + expect(store.has("settings:fromEmail")).toBe(false); + expect(store.get("settings:fromName")).toBe("Support"); + expect(result.toast).toEqual({ + message: "Settings saved", + type: "success", + }); + }); +}); diff --git a/packages/plugins/worker-mailer/tsconfig.json b/packages/plugins/worker-mailer/tsconfig.json new file mode 100644 index 000000000..f7304871d --- /dev/null +++ b/packages/plugins/worker-mailer/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/worker-mailer/vitest.config.ts b/packages/plugins/worker-mailer/vitest.config.ts new file mode 100644 index 000000000..8881a0450 --- /dev/null +++ b/packages/plugins/worker-mailer/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f182f3bd0..a047c27d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier + '@emdash-cms/plugin-worker-mailer': + specifier: workspace:* + version: link:../../packages/plugins/worker-mailer '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -1253,6 +1256,25 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/plugins/worker-mailer: + dependencies: + '@workermailer/smtp': + specifier: ^0.1.0 + version: 0.1.0 + emdash: + specifier: workspace:* + version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + packages/x402: dependencies: '@x402/core': @@ -4979,6 +5001,9 @@ packages: resolution: {integrity: sha512-iHqqlvuPGIz9ycU4Kce/zgti7zvDC+9i1hG5RIiMWAOX1Fwor4CPy9R1jZMXR64cBnW+nntP4mK0+KJeKmusiw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@workermailer/smtp@0.1.0': + resolution: {integrity: sha512-ArvjLpVjURks1BLVHUKtJcFXvQL9DtbD4H4zImEVpoe11MNGdaOx+aUupuDpHjbCOyNyzXOXiUdFTt0BNeyo4Q==} + '@x402/core@2.8.0': resolution: {integrity: sha512-ppsvSzyWzlqG+26dcNzOXo/YLaHreWc3lmdv9W81mEhLDWMdPpiGyRjSUyO9BQFuQJMQppvqA7ujUbLE/EaDkg==} @@ -12425,6 +12450,8 @@ snapshots: '@wordpress/block-serialization-default-parser@5.38.0': {} + '@workermailer/smtp@0.1.0': {} + '@x402/core@2.8.0': dependencies: zod: 3.25.76