From 51f8554b8fea184223e9772386375ee2909a2b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Thu, 2 Apr 2026 12:01:59 -0300 Subject: [PATCH 01/12] feat(i18n): add pt-br locale to demos, fixture, and docs --- demos/cloudflare/astro.config.mjs | 3 ++- demos/simple/astro.config.mjs | 7 +++++++ docs/src/content/docs/guides/internationalization.mdx | 4 ++-- packages/core/tests/integration/fixture/astro.config.mjs | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index de86808ce..91b6423ab 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -22,10 +22,11 @@ export default defineConfig({ }), i18n: { defaultLocale: "en", - locales: ["en", "fr", "es"], + locales: ["en", "fr", "es", "pt-br"], fallback: { fr: "en", es: "en", + "pt-br": "en", }, }, image: { diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index e84e9400f..82035e466 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -10,6 +10,13 @@ export default defineConfig({ adapter: node({ mode: "standalone", }), + i18n: { + defaultLocale: "en", + locales: ["en", "pt-br"], + fallback: { + "pt-br": "en", + }, + }, image: { layout: "constrained", responsiveStyles: true, diff --git a/docs/src/content/docs/guides/internationalization.mdx b/docs/src/content/docs/guides/internationalization.mdx index 34b3b09ef..8d4442e26 100644 --- a/docs/src/content/docs/guides/internationalization.mdx +++ b/docs/src/content/docs/guides/internationalization.mdx @@ -21,8 +21,8 @@ import { sqlite } from "emdash/db"; export default defineConfig({ i18n: { defaultLocale: "en", - locales: ["en", "fr", "es"], - fallback: { fr: "en", es: "en" }, + locales: ["en", "fr", "es", "pt-br"], + fallback: { fr: "en", es: "en", "pt-br": "en" }, }, integrations: [ emdash({ diff --git a/packages/core/tests/integration/fixture/astro.config.mjs b/packages/core/tests/integration/fixture/astro.config.mjs index c361e7048..4e50f0113 100644 --- a/packages/core/tests/integration/fixture/astro.config.mjs +++ b/packages/core/tests/integration/fixture/astro.config.mjs @@ -25,8 +25,8 @@ export default defineConfig({ ], i18n: { defaultLocale: "en", - locales: ["en", "fr", "es"], - fallback: { fr: "en", es: "en" }, + locales: ["en", "fr", "es", "pt-br"], + fallback: { fr: "en", es: "en", "pt-br": "en" }, }, devToolbar: { enabled: false }, vite: { From 4df33098279066ac9f006a9ea6166f2737ccc838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Thu, 2 Apr 2026 13:06:55 -0300 Subject: [PATCH 02/12] feat(email): add worker-mailer plugin --- demos/cloudflare/astro.config.mjs | 5 + demos/cloudflare/package.json | 17 +- packages/plugins/worker-mailer/CHANGELOG.md | 9 + packages/plugins/worker-mailer/package.json | 38 +++ packages/plugins/worker-mailer/src/index.ts | 285 +++++++++++++++++++ packages/plugins/worker-mailer/tsconfig.json | 9 + pnpm-lock.yaml | 80 +++++- 7 files changed, 423 insertions(+), 20 deletions(-) create mode 100644 packages/plugins/worker-mailer/CHANGELOG.md create mode 100644 packages/plugins/worker-mailer/package.json create mode 100644 packages/plugins/worker-mailer/src/index.ts create mode 100644 packages/plugins/worker-mailer/tsconfig.json diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 91b6423ab..e0c8d2851 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"; @@ -71,6 +72,10 @@ export default defineConfig({ ], // Trusted plugins (run in host worker) plugins: [ + // SMTP delivery from the host Worker. Configure credentials in the + // plugin settings page or seed defaults here. Workers requires an + // SMTP endpoint that starts already secure (implicit TLS / SMTPS). + workerMailerPlugin(), // Test plugin that exercises all v2 APIs formsPlugin(), ], 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..9e7ab6423 --- /dev/null +++ b/packages/plugins/worker-mailer/CHANGELOG.md @@ -0,0 +1,9 @@ +# @emdash-cms/plugin-worker-mailer + +## 0.1.0 + +### Minor Changes + +- Initial release of the Worker Mailer email provider plugin for EmDash. +- Document and enforce the Cloudflare Workers requirement for SMTP connections that start already secure + (implicit TLS / SMTPS). STARTTLS is not exposed by the plugin. diff --git a/packages/plugins/worker-mailer/package.json b/packages/plugins/worker-mailer/package.json new file mode 100644 index 000000000..06676b6a0 --- /dev/null +++ b/packages/plugins/worker-mailer/package.json @@ -0,0 +1,38 @@ +{ + "name": "@emdash-cms/plugin-worker-mailer", + "version": "0.1.0", + "description": "Implicit TLS SMTP provider plugin for EmDash CMS on Cloudflare Workers", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "keywords": [ + "emdash", + "cms", + "plugin", + "email", + "smtp", + "cloudflare", + "worker-mailer" + ], + "author": "Andre Ribas (@RibasSu)", + "license": "MIT", + "peerDependencies": { + "emdash": "workspace:*" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@ribassu/worker-mailer": "^1.3.3" + }, + "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..29b2e986f --- /dev/null +++ b/packages/plugins/worker-mailer/src/index.ts @@ -0,0 +1,285 @@ +/** + * Worker Mailer Plugin for EmDash CMS + * + * Provides an `email:deliver` transport implementation using + * `@ribassu/worker-mailer` for Cloudflare Workers. + * + * Cloudflare Workers only supports SMTP connections that start secure + * (implicit TLS / SMTPS). STARTTLS upgrades from plaintext are not supported. + */ + +import type { EmailOptions, WorkerMailerOptions } from "@ribassu/worker-mailer"; +import { definePlugin } from "emdash"; +import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash"; + +const PLUGIN_ID = "worker-mailer"; +const VERSION = "0.1.0"; +const DEFAULT_PORT = 465; +const DEFAULT_AUTH_TYPE = "plain"; +const IMPLICIT_TLS_REQUIRED_MESSAGE = + "Cloudflare Workers only supports SMTP connections that start secure (implicit TLS / SMTPS). Use a TLS-enabled SMTP port such as 465."; + +const VALID_AUTH_TYPES = new Set(["plain", "login", "cram-md5"] as const); + +type AuthType = "plain" | "login" | "cram-md5"; + +export interface WorkerMailerPluginOptions { + /** SMTP host (e.g. smtp.example.com) */ + host?: string; + /** SMTP port for an implicit TLS endpoint (e.g. 465) */ + port?: number; + /** Only `true` is supported on Cloudflare Workers */ + secure?: boolean; + /** SMTP auth type */ + authType?: AuthType; + /** SMTP username */ + username?: string; + /** SMTP password */ + password?: string; + /** Optional sender email override (defaults to username) */ + fromEmail?: string; + /** Optional sender display name */ + fromName?: string; +} + +interface WorkerMailerConfig { + host: string; + port: number; + authType: AuthType; + username: string; + password: string; + fromEmail: string; + fromName: string | undefined; +} + +/** + * Descriptor for use in astro.config.mjs / live.config.ts. + */ +export function workerMailerPlugin( + options: WorkerMailerPluginOptions = {}, +): PluginDescriptor { + return { + id: PLUGIN_ID, + version: VERSION, + entrypoint: "@emdash-cms/plugin-worker-mailer", + options, + capabilities: ["email:provide"], + adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], + }; +} + +function coerceAuthType(value: unknown, fallback: AuthType): AuthType { + if (typeof value !== "string") return fallback; + if (VALID_AUTH_TYPES.has(value as AuthType)) return value as AuthType; + return 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; +} + +async function readConfig( + ctx: PluginContext, + options: WorkerMailerPluginOptions, +): Promise { + const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); + const port = coerceNumber( + await ctx.kv.get("settings:port"), + options.port ?? DEFAULT_PORT, + ); + const secure = (await ctx.kv.get("settings:secure")) ?? options.secure ?? true; + const authType = coerceAuthType( + await ctx.kv.get("settings:authType"), + options.authType ?? DEFAULT_AUTH_TYPE, + ); + const username = + toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); + const password = + toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); + + const explicitFrom = + toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); + const fromEmail = explicitFrom ?? username; + const fromName = + toNonEmpty(await ctx.kv.get("settings:fromName")) ?? toNonEmpty(options.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(", ")}.`, + ); + } + + if (!secure) { + throw new Error( + `Worker Mailer cannot use plaintext SMTP or STARTTLS. ${IMPLICIT_TLS_REQUIRED_MESSAGE}`, + ); + } + + 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); +} + +async function loadWorkerMailer(): Promise { + try { + return await import("@ribassu/worker-mailer"); + } catch (error) { + throw new Error( + `Failed to load @ribassu/worker-mailer. ` + + `Ensure this plugin runs on Cloudflare Workers with nodejs_compat enabled.`, + { cause: error }, + ); + } +} + +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, + 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`); +} + +export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { + return definePlugin({ + id: PLUGIN_ID, + version: VERSION, + capabilities: ["email:provide"], + + hooks: { + "plugin:install": { + handler: async (_event, ctx) => { + await setDefault(ctx, "settings:host", toNonEmpty(options.host)); + await setDefault(ctx, "settings:port", options.port ?? DEFAULT_PORT); + await ctx.kv.set("settings:secure", true); + await ctx.kv.delete("settings:startTls"); + await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); + await setDefault(ctx, "settings:username", toNonEmpty(options.username)); + await setDefault(ctx, "settings:password", toNonEmpty(options.password)); + await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); + await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); + }, + }, + + "email:deliver": { + exclusive: true, + handler: async (event, ctx) => { + const config = await readConfig(ctx, options); + await sendWithWorkerMailer(ctx, config, event.message); + }, + }, + }, + + admin: { + settingsSchema: { + host: { + type: "string", + label: "SMTP Host", + description: "SMTP server hostname for an implicit TLS endpoint (e.g. smtp.example.com)", + default: options.host ?? "", + }, + port: { + type: "number", + label: "SMTPS Port", + description: IMPLICIT_TLS_REQUIRED_MESSAGE, + default: options.port ?? DEFAULT_PORT, + min: 1, + max: 65535, + }, + authType: { + type: "select", + label: "Auth Type", + options: [ + { value: "plain", label: "PLAIN" }, + { value: "login", label: "LOGIN" }, + { value: "cram-md5", label: "CRAM-MD5" }, + ], + default: options.authType ?? DEFAULT_AUTH_TYPE, + }, + username: { + type: "string", + label: "SMTP Username", + default: options.username ?? "", + }, + password: { + type: "secret", + label: "SMTP Password", + description: "Stored encrypted at rest", + }, + fromEmail: { + type: "string", + label: "From Email", + description: "Defaults to SMTP username when empty", + default: options.fromEmail ?? "", + }, + fromName: { + type: "string", + label: "From Name", + description: "Optional display name for outgoing emails", + default: options.fromName ?? "", + }, + }, + pages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], + }, + }); +} + +export default createPlugin; 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/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc96580e..4352caf52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,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) @@ -614,7 +617,7 @@ importers: version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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) vitest-browser-react: specifier: ^2.0.5 version: 2.0.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18) @@ -660,7 +663,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/blocks: dependencies: @@ -715,7 +718,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/blocks/playground: dependencies: @@ -798,7 +801,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/core: dependencies: @@ -1027,7 +1030,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/marketplace: dependencies: @@ -1058,7 +1061,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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) wrangler: specifier: 'catalog:' version: 4.71.0(@cloudflare/workers-types@4.20260305.1) @@ -1086,7 +1089,7 @@ importers: version: 19.2.14 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/plugins/api-test: dependencies: @@ -1108,7 +1111,7 @@ importers: devDependencies: vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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/plugins/audit-log: dependencies: @@ -1197,6 +1200,15 @@ importers: specifier: workspace:* version: link:../../core + packages/plugins/worker-mailer: + dependencies: + '@ribassu/worker-mailer': + specifier: ^1.3.3 + version: 1.3.3 + emdash: + specifier: workspace:* + version: link:../../core + packages/x402: dependencies: '@x402/core': @@ -1223,7 +1235,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + 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) optionalDependencies: '@x402/svm': specifier: ^2.8.0 @@ -3305,6 +3317,9 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@ribassu/worker-mailer@1.3.3': + resolution: {integrity: sha512-3tHwWwkfWlGIxZ7kefWZlyZ0L36M+tyiYXoDs54PRy+qyKfCM6JuWMghfl6/ONI87NDXwN2I1vcweMo1WYl2CA==} + '@rolldown/binding-android-arm64@1.0.0-rc.3': resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10257,6 +10272,8 @@ snapshots: '@remirror/core-constants@3.0.0': {} + '@ribassu/worker-mailer@1.3.3': {} + '@rolldown/binding-android-arm64@1.0.0-rc.3': optional: true @@ -11665,7 +11682,7 @@ snapshots: '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 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) transitivePeerDependencies: - bufferutil - msw @@ -11699,7 +11716,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 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) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -15958,7 +15975,7 @@ snapshots: dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 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) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -16003,6 +16020,45 @@ snapshots: - tsx - yaml + vitest@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): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + volar-service-css@0.0.68(@volar/language-service@2.4.27): dependencies: vscode-css-languageservice: 6.3.9 From 22576513614e1b1ee103585a0ebdca005eb5a32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Thu, 2 Apr 2026 13:38:00 -0300 Subject: [PATCH 03/12] docs(email): add worker-mailer examples --- demos/cloudflare/README.md | 24 ++++++++++++ demos/cloudflare/astro.config.mjs | 12 +++++- packages/plugins/worker-mailer/README.md | 47 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/worker-mailer/README.md diff --git a/demos/cloudflare/README.md b/demos/cloudflare/README.md index 34e22fbfa..5478ba2dd 100644 --- a/demos/cloudflare/README.md +++ b/demos/cloudflare/README.md @@ -51,6 +51,30 @@ 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`. + +By default, `workerMailerPlugin()` just enables the SMTP settings page in EmDash so +you can configure the connection in the admin UI. + +If you want to seed defaults in code, use: + +```js +workerMailerPlugin({ + host: "smtp.example.com", + port: 465, + authType: "plain", + username: "smtp-user", + password: "smtp-password", + fromEmail: "no-reply@example.com", + fromName: "EmDash Demo", +}); +``` + +Cloudflare Workers only supports SMTP connections that start already secure +(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. + ## 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 e0c8d2851..e97cfbb7c 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -73,8 +73,18 @@ export default defineConfig({ // Trusted plugins (run in host worker) plugins: [ // SMTP delivery from the host Worker. Configure credentials in the - // plugin settings page or seed defaults here. Workers requires an + // plugin settings page or seed defaults in code. Workers requires an // SMTP endpoint that starts already secure (implicit TLS / SMTPS). + // Example: + // workerMailerPlugin({ + // host: "smtp.example.com", + // port: 465, + // authType: "plain", + // username: "smtp-user", + // password: "smtp-password", + // fromEmail: "no-reply@example.com", + // fromName: "EmDash Demo", + // }), workerMailerPlugin(), // Test plugin that exercises all v2 APIs formsPlugin(), diff --git a/packages/plugins/worker-mailer/README.md b/packages/plugins/worker-mailer/README.md new file mode 100644 index 000000000..46d4a265a --- /dev/null +++ b/packages/plugins/worker-mailer/README.md @@ -0,0 +1,47 @@ +# @emdash-cms/plugin-worker-mailer + +SMTP provider plugin for EmDash on Cloudflare Workers using +`@ribassu/worker-mailer`. + +Cloudflare Workers only supports SMTP connections that start already secure +(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. + +## Usage + +Register the plugin in `astro.config.mjs`: + +```js +import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; + +export default defineConfig({ + integrations: [ + emdash({ + plugins: [workerMailerPlugin()], + }), + ], +}); +``` + +Configure the SMTP connection in the EmDash admin UI, or seed defaults in code: + +```js +workerMailerPlugin({ + host: "smtp.example.com", + port: 465, + authType: "plain", + username: "smtp-user", + password: "smtp-password", + fromEmail: "no-reply@example.com", + fromName: "EmDash Demo", +}); +``` + +## Settings + +- `host`: SMTP hostname for an implicit TLS endpoint +- `port`: SMTP port for implicit TLS, usually `465` +- `authType`: `plain`, `login`, or `cram-md5` +- `username`: SMTP username +- `password`: SMTP password +- `fromEmail`: sender email override, defaults to `username` +- `fromName`: optional sender display name From 97c00b573079b254772771959676258d8569360c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Sat, 4 Apr 2026 14:42:17 -0300 Subject: [PATCH 04/12] up --- worker-mailer-plugin/CHANGELOG.md | 9 + worker-mailer-plugin/README.md | 47 +++++ worker-mailer-plugin/package.json | 41 +++++ worker-mailer-plugin/src/index.ts | 285 +++++++++++++++++++++++++++++ worker-mailer-plugin/tsconfig.json | 16 ++ 5 files changed, 398 insertions(+) create mode 100644 worker-mailer-plugin/CHANGELOG.md create mode 100644 worker-mailer-plugin/README.md create mode 100644 worker-mailer-plugin/package.json create mode 100644 worker-mailer-plugin/src/index.ts create mode 100644 worker-mailer-plugin/tsconfig.json diff --git a/worker-mailer-plugin/CHANGELOG.md b/worker-mailer-plugin/CHANGELOG.md new file mode 100644 index 000000000..9e7ab6423 --- /dev/null +++ b/worker-mailer-plugin/CHANGELOG.md @@ -0,0 +1,9 @@ +# @emdash-cms/plugin-worker-mailer + +## 0.1.0 + +### Minor Changes + +- Initial release of the Worker Mailer email provider plugin for EmDash. +- Document and enforce the Cloudflare Workers requirement for SMTP connections that start already secure + (implicit TLS / SMTPS). STARTTLS is not exposed by the plugin. diff --git a/worker-mailer-plugin/README.md b/worker-mailer-plugin/README.md new file mode 100644 index 000000000..46d4a265a --- /dev/null +++ b/worker-mailer-plugin/README.md @@ -0,0 +1,47 @@ +# @emdash-cms/plugin-worker-mailer + +SMTP provider plugin for EmDash on Cloudflare Workers using +`@ribassu/worker-mailer`. + +Cloudflare Workers only supports SMTP connections that start already secure +(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. + +## Usage + +Register the plugin in `astro.config.mjs`: + +```js +import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; + +export default defineConfig({ + integrations: [ + emdash({ + plugins: [workerMailerPlugin()], + }), + ], +}); +``` + +Configure the SMTP connection in the EmDash admin UI, or seed defaults in code: + +```js +workerMailerPlugin({ + host: "smtp.example.com", + port: 465, + authType: "plain", + username: "smtp-user", + password: "smtp-password", + fromEmail: "no-reply@example.com", + fromName: "EmDash Demo", +}); +``` + +## Settings + +- `host`: SMTP hostname for an implicit TLS endpoint +- `port`: SMTP port for implicit TLS, usually `465` +- `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/worker-mailer-plugin/package.json b/worker-mailer-plugin/package.json new file mode 100644 index 000000000..93754b5f5 --- /dev/null +++ b/worker-mailer-plugin/package.json @@ -0,0 +1,41 @@ +{ + "name": "@emdash-cms/plugin-worker-mailer", + "version": "0.1.0", + "description": "Implicit TLS SMTP provider plugin for EmDash CMS on Cloudflare Workers", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "keywords": [ + "emdash", + "cms", + "plugin", + "email", + "smtp", + "cloudflare", + "worker-mailer" + ], + "author": "Andre Ribas (@RibasSu)", + "license": "MIT", + "peerDependencies": { + "emdash": "^0.1.0" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^6.0.0-beta" + }, + "dependencies": { + "@ribassu/worker-mailer": "^1.3.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/plugins/worker-mailer" + } +} diff --git a/worker-mailer-plugin/src/index.ts b/worker-mailer-plugin/src/index.ts new file mode 100644 index 000000000..29b2e986f --- /dev/null +++ b/worker-mailer-plugin/src/index.ts @@ -0,0 +1,285 @@ +/** + * Worker Mailer Plugin for EmDash CMS + * + * Provides an `email:deliver` transport implementation using + * `@ribassu/worker-mailer` for Cloudflare Workers. + * + * Cloudflare Workers only supports SMTP connections that start secure + * (implicit TLS / SMTPS). STARTTLS upgrades from plaintext are not supported. + */ + +import type { EmailOptions, WorkerMailerOptions } from "@ribassu/worker-mailer"; +import { definePlugin } from "emdash"; +import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash"; + +const PLUGIN_ID = "worker-mailer"; +const VERSION = "0.1.0"; +const DEFAULT_PORT = 465; +const DEFAULT_AUTH_TYPE = "plain"; +const IMPLICIT_TLS_REQUIRED_MESSAGE = + "Cloudflare Workers only supports SMTP connections that start secure (implicit TLS / SMTPS). Use a TLS-enabled SMTP port such as 465."; + +const VALID_AUTH_TYPES = new Set(["plain", "login", "cram-md5"] as const); + +type AuthType = "plain" | "login" | "cram-md5"; + +export interface WorkerMailerPluginOptions { + /** SMTP host (e.g. smtp.example.com) */ + host?: string; + /** SMTP port for an implicit TLS endpoint (e.g. 465) */ + port?: number; + /** Only `true` is supported on Cloudflare Workers */ + secure?: boolean; + /** SMTP auth type */ + authType?: AuthType; + /** SMTP username */ + username?: string; + /** SMTP password */ + password?: string; + /** Optional sender email override (defaults to username) */ + fromEmail?: string; + /** Optional sender display name */ + fromName?: string; +} + +interface WorkerMailerConfig { + host: string; + port: number; + authType: AuthType; + username: string; + password: string; + fromEmail: string; + fromName: string | undefined; +} + +/** + * Descriptor for use in astro.config.mjs / live.config.ts. + */ +export function workerMailerPlugin( + options: WorkerMailerPluginOptions = {}, +): PluginDescriptor { + return { + id: PLUGIN_ID, + version: VERSION, + entrypoint: "@emdash-cms/plugin-worker-mailer", + options, + capabilities: ["email:provide"], + adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], + }; +} + +function coerceAuthType(value: unknown, fallback: AuthType): AuthType { + if (typeof value !== "string") return fallback; + if (VALID_AUTH_TYPES.has(value as AuthType)) return value as AuthType; + return 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; +} + +async function readConfig( + ctx: PluginContext, + options: WorkerMailerPluginOptions, +): Promise { + const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); + const port = coerceNumber( + await ctx.kv.get("settings:port"), + options.port ?? DEFAULT_PORT, + ); + const secure = (await ctx.kv.get("settings:secure")) ?? options.secure ?? true; + const authType = coerceAuthType( + await ctx.kv.get("settings:authType"), + options.authType ?? DEFAULT_AUTH_TYPE, + ); + const username = + toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); + const password = + toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); + + const explicitFrom = + toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); + const fromEmail = explicitFrom ?? username; + const fromName = + toNonEmpty(await ctx.kv.get("settings:fromName")) ?? toNonEmpty(options.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(", ")}.`, + ); + } + + if (!secure) { + throw new Error( + `Worker Mailer cannot use plaintext SMTP or STARTTLS. ${IMPLICIT_TLS_REQUIRED_MESSAGE}`, + ); + } + + 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); +} + +async function loadWorkerMailer(): Promise { + try { + return await import("@ribassu/worker-mailer"); + } catch (error) { + throw new Error( + `Failed to load @ribassu/worker-mailer. ` + + `Ensure this plugin runs on Cloudflare Workers with nodejs_compat enabled.`, + { cause: error }, + ); + } +} + +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, + 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`); +} + +export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { + return definePlugin({ + id: PLUGIN_ID, + version: VERSION, + capabilities: ["email:provide"], + + hooks: { + "plugin:install": { + handler: async (_event, ctx) => { + await setDefault(ctx, "settings:host", toNonEmpty(options.host)); + await setDefault(ctx, "settings:port", options.port ?? DEFAULT_PORT); + await ctx.kv.set("settings:secure", true); + await ctx.kv.delete("settings:startTls"); + await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); + await setDefault(ctx, "settings:username", toNonEmpty(options.username)); + await setDefault(ctx, "settings:password", toNonEmpty(options.password)); + await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); + await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); + }, + }, + + "email:deliver": { + exclusive: true, + handler: async (event, ctx) => { + const config = await readConfig(ctx, options); + await sendWithWorkerMailer(ctx, config, event.message); + }, + }, + }, + + admin: { + settingsSchema: { + host: { + type: "string", + label: "SMTP Host", + description: "SMTP server hostname for an implicit TLS endpoint (e.g. smtp.example.com)", + default: options.host ?? "", + }, + port: { + type: "number", + label: "SMTPS Port", + description: IMPLICIT_TLS_REQUIRED_MESSAGE, + default: options.port ?? DEFAULT_PORT, + min: 1, + max: 65535, + }, + authType: { + type: "select", + label: "Auth Type", + options: [ + { value: "plain", label: "PLAIN" }, + { value: "login", label: "LOGIN" }, + { value: "cram-md5", label: "CRAM-MD5" }, + ], + default: options.authType ?? DEFAULT_AUTH_TYPE, + }, + username: { + type: "string", + label: "SMTP Username", + default: options.username ?? "", + }, + password: { + type: "secret", + label: "SMTP Password", + description: "Stored encrypted at rest", + }, + fromEmail: { + type: "string", + label: "From Email", + description: "Defaults to SMTP username when empty", + default: options.fromEmail ?? "", + }, + fromName: { + type: "string", + label: "From Name", + description: "Optional display name for outgoing emails", + default: options.fromName ?? "", + }, + }, + pages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], + }, + }); +} + +export default createPlugin; diff --git a/worker-mailer-plugin/tsconfig.json b/worker-mailer-plugin/tsconfig.json new file mode 100644 index 000000000..2615e9ae2 --- /dev/null +++ b/worker-mailer-plugin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 3c5b9ecf732a322772cb581bb9be10e7a4e8f5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Mon, 6 Apr 2026 15:14:06 -0300 Subject: [PATCH 05/12] Remove pt-br i18n examples from main --- demos/cloudflare/astro.config.mjs | 3 +-- demos/simple/astro.config.mjs | 7 ------- docs/src/content/docs/guides/internationalization.mdx | 4 ++-- packages/core/tests/integration/fixture/astro.config.mjs | 4 ++-- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 91b6423ab..de86808ce 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -22,11 +22,10 @@ export default defineConfig({ }), i18n: { defaultLocale: "en", - locales: ["en", "fr", "es", "pt-br"], + locales: ["en", "fr", "es"], fallback: { fr: "en", es: "en", - "pt-br": "en", }, }, image: { diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index c393ff60e..d77553265 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -10,13 +10,6 @@ export default defineConfig({ adapter: node({ mode: "standalone", }), - i18n: { - defaultLocale: "en", - locales: ["en", "pt-br"], - fallback: { - "pt-br": "en", - }, - }, // Example: allowed domains for reverse proxy // security: { // allowedDomains: [ diff --git a/docs/src/content/docs/guides/internationalization.mdx b/docs/src/content/docs/guides/internationalization.mdx index 8d4442e26..34b3b09ef 100644 --- a/docs/src/content/docs/guides/internationalization.mdx +++ b/docs/src/content/docs/guides/internationalization.mdx @@ -21,8 +21,8 @@ import { sqlite } from "emdash/db"; export default defineConfig({ i18n: { defaultLocale: "en", - locales: ["en", "fr", "es", "pt-br"], - fallback: { fr: "en", es: "en", "pt-br": "en" }, + locales: ["en", "fr", "es"], + fallback: { fr: "en", es: "en" }, }, integrations: [ emdash({ diff --git a/packages/core/tests/integration/fixture/astro.config.mjs b/packages/core/tests/integration/fixture/astro.config.mjs index 4e50f0113..c361e7048 100644 --- a/packages/core/tests/integration/fixture/astro.config.mjs +++ b/packages/core/tests/integration/fixture/astro.config.mjs @@ -25,8 +25,8 @@ export default defineConfig({ ], i18n: { defaultLocale: "en", - locales: ["en", "fr", "es", "pt-br"], - fallback: { fr: "en", es: "en", "pt-br": "en" }, + locales: ["en", "fr", "es"], + fallback: { fr: "en", es: "en" }, }, devToolbar: { enabled: false }, vite: { From b2a9c335d80b9224564387656f3557a79c9368f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Thu, 9 Apr 2026 10:54:02 -0300 Subject: [PATCH 06/12] feat(plugin-worker-mailer): integrate @workermailer/smtp --- .changeset/friendly-tips-grow.md | 5 + demos/cloudflare/README.md | 7 +- demos/cloudflare/astro.config.mjs | 7 +- packages/plugins/worker-mailer/CHANGELOG.md | 3 +- packages/plugins/worker-mailer/README.md | 19 +- packages/plugins/worker-mailer/package.json | 25 +- packages/plugins/worker-mailer/src/index.ts | 99 ++++-- .../worker-mailer/tests/plugin.test.ts | 58 ++++ .../plugins/worker-mailer/vitest.config.ts | 9 + pnpm-lock.yaml | 26 +- worker-mailer-plugin/CHANGELOG.md | 9 - worker-mailer-plugin/README.md | 47 --- worker-mailer-plugin/package.json | 41 --- worker-mailer-plugin/src/index.ts | 286 ------------------ worker-mailer-plugin/tsconfig.json | 16 - 15 files changed, 196 insertions(+), 461 deletions(-) create mode 100644 .changeset/friendly-tips-grow.md create mode 100644 packages/plugins/worker-mailer/tests/plugin.test.ts create mode 100644 packages/plugins/worker-mailer/vitest.config.ts delete mode 100644 worker-mailer-plugin/CHANGELOG.md delete mode 100644 worker-mailer-plugin/README.md delete mode 100644 worker-mailer-plugin/package.json delete mode 100644 worker-mailer-plugin/src/index.ts delete mode 100644 worker-mailer-plugin/tsconfig.json 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 5478ba2dd..d299b5876 100644 --- a/demos/cloudflare/README.md +++ b/demos/cloudflare/README.md @@ -63,7 +63,8 @@ If you want to seed defaults in code, use: ```js workerMailerPlugin({ host: "smtp.example.com", - port: 465, + port: 587, + transportSecurity: "starttls", authType: "plain", username: "smtp-user", password: "smtp-password", @@ -72,8 +73,8 @@ workerMailerPlugin({ }); ``` -Cloudflare Workers only supports SMTP connections that start already secure -(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. +Cloudflare Workers TCP sockets support both STARTTLS and implicit TLS, and this +plugin exposes both modes. Plaintext SMTP is intentionally not supported. ## Notes diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index fd0f0c435..496b5cde1 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -72,12 +72,13 @@ export default defineConfig({ // Trusted plugins (run in host worker) plugins: [ // SMTP delivery from the host Worker. Configure credentials in the - // plugin settings page or seed defaults in code. Workers requires an - // SMTP endpoint that starts already secure (implicit TLS / SMTPS). + // plugin settings page or seed defaults in code. This plugin supports + // STARTTLS (usually port 587) and implicit TLS / SMTPS (usually 465). // Example: // workerMailerPlugin({ // host: "smtp.example.com", - // port: 465, + // port: 587, + // transportSecurity: "starttls", // authType: "plain", // username: "smtp-user", // password: "smtp-password", diff --git a/packages/plugins/worker-mailer/CHANGELOG.md b/packages/plugins/worker-mailer/CHANGELOG.md index 9e7ab6423..e5709d690 100644 --- a/packages/plugins/worker-mailer/CHANGELOG.md +++ b/packages/plugins/worker-mailer/CHANGELOG.md @@ -5,5 +5,4 @@ ### Minor Changes - Initial release of the Worker Mailer email provider plugin for EmDash. -- Document and enforce the Cloudflare Workers requirement for SMTP connections that start already secure - (implicit TLS / SMTPS). STARTTLS is not exposed by the plugin. +- Support secure SMTP via STARTTLS and implicit TLS on Cloudflare Workers. diff --git a/packages/plugins/worker-mailer/README.md b/packages/plugins/worker-mailer/README.md index 46d4a265a..b9c14d95f 100644 --- a/packages/plugins/worker-mailer/README.md +++ b/packages/plugins/worker-mailer/README.md @@ -1,10 +1,13 @@ # @emdash-cms/plugin-worker-mailer -SMTP provider plugin for EmDash on Cloudflare Workers using -`@ribassu/worker-mailer`. +SMTP provider plugin for EmDash on Cloudflare Workers using `@workermailer/smtp`. -Cloudflare Workers only supports SMTP connections that start already secure -(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. +The plugin supports secure SMTP in both modes exposed by Cloudflare TCP sockets: + +- `starttls` on port `587` +- `implicit_tls` on port `465` + +Plaintext SMTP is intentionally not exposed. ## Usage @@ -27,7 +30,8 @@ Configure the SMTP connection in the EmDash admin UI, or seed defaults in code: ```js workerMailerPlugin({ host: "smtp.example.com", - port: 465, + port: 587, + transportSecurity: "starttls", authType: "plain", username: "smtp-user", password: "smtp-password", @@ -38,8 +42,9 @@ workerMailerPlugin({ ## Settings -- `host`: SMTP hostname for an implicit TLS endpoint -- `port`: SMTP port for implicit TLS, usually `465` +- `host`: SMTP hostname +- `transportSecurity`: `starttls` or `implicit_tls` +- `port`: SMTP port, usually `587` for STARTTLS or `465` for implicit TLS - `authType`: `plain`, `login`, or `cram-md5` - `username`: SMTP username - `password`: SMTP password diff --git a/packages/plugins/worker-mailer/package.json b/packages/plugins/worker-mailer/package.json index 06676b6a0..0483c2b5d 100644 --- a/packages/plugins/worker-mailer/package.json +++ b/packages/plugins/worker-mailer/package.json @@ -1,14 +1,17 @@ { "name": "@emdash-cms/plugin-worker-mailer", "version": "0.1.0", - "description": "Implicit TLS SMTP provider plugin for EmDash CMS on Cloudflare Workers", + "description": "SMTP provider plugin for EmDash CMS on Cloudflare Workers using @workermailer/smtp", "type": "module", - "main": "src/index.ts", + "main": "dist/index.mjs", "exports": { - ".": "./src/index.ts" + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + } }, "files": [ - "src" + "dist" ], "keywords": [ "emdash", @@ -21,14 +24,20 @@ ], "author": "Andre Ribas (@RibasSu)", "license": "MIT", - "peerDependencies": { - "emdash": "workspace:*" - }, "scripts": { + "build": "tsdown src/index.ts --format esm --dts --clean", + "dev": "tsdown src/index.ts --format esm --dts --watch", + "test": "vitest run", "typecheck": "tsgo --noEmit" }, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, "dependencies": { - "@ribassu/worker-mailer": "^1.3.3" + "@workermailer/smtp": "^0.1.0", + "emdash": "workspace:*" }, "repository": { "type": "git", diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts index d7804ecc4..5f95973ec 100644 --- a/packages/plugins/worker-mailer/src/index.ts +++ b/packages/plugins/worker-mailer/src/index.ts @@ -2,36 +2,50 @@ * Worker Mailer Plugin for EmDash CMS * * Provides an `email:deliver` transport implementation using - * `@ribassu/worker-mailer` for Cloudflare Workers. + * `@workermailer/smtp` for Cloudflare Workers. * - * Cloudflare Workers only supports SMTP connections that start secure - * (implicit TLS / SMTPS). STARTTLS upgrades from plaintext are not supported. + * The plugin supports the two secure transport modes available via + * Cloudflare TCP sockets: + * - STARTTLS (typically port 587) + * - Implicit TLS / SMTPS (typically port 465) + * + * Plaintext SMTP is intentionally not exposed. */ -import type { EmailOptions, WorkerMailerOptions } from "@ribassu/worker-mailer"; +import type { AuthType, EmailOptions, WorkerMailerOptions } from "@workermailer/smtp"; import { definePlugin } from "emdash"; import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash"; const PLUGIN_ID = "worker-mailer"; const VERSION = "0.1.0"; -const DEFAULT_PORT = 465; +const DEFAULT_TRANSPORT_SECURITY = "starttls"; const DEFAULT_AUTH_TYPE = "plain"; -const IMPLICIT_TLS_REQUIRED_MESSAGE = - "Cloudflare Workers only supports SMTP connections that start secure (implicit TLS / SMTPS). Use a TLS-enabled SMTP port such as 465."; +const IMPLICIT_TLS_PORT = 465; +const STARTTLS_PORT = 587; +const TLS_REQUIRED_MESSAGE = + "Choose STARTTLS on port 587 or implicit TLS on port 465. Plaintext SMTP is not supported."; -type AuthType = "plain" | "login" | "cram-md5"; +type TransportSecurity = "starttls" | "implicit_tls"; function isAuthType(value: string): value is AuthType { return value === "plain" || value === "login" || value === "cram-md5"; } +function isTransportSecurity(value: string): value is TransportSecurity { + return value === "starttls" || value === "implicit_tls"; +} + +function defaultPortForTransportSecurity(transportSecurity: TransportSecurity): number { + return transportSecurity === "implicit_tls" ? IMPLICIT_TLS_PORT : STARTTLS_PORT; +} + export interface WorkerMailerPluginOptions { /** SMTP host (e.g. smtp.example.com) */ host?: string; - /** SMTP port for an implicit TLS endpoint (e.g. 465) */ + /** SMTP port (usually 587 for STARTTLS or 465 for implicit TLS) */ port?: number; - /** Only `true` is supported on Cloudflare Workers */ - secure?: boolean; + /** SMTP transport security mode */ + transportSecurity?: TransportSecurity; /** SMTP auth type */ authType?: AuthType; /** SMTP username */ @@ -47,6 +61,7 @@ export interface WorkerMailerPluginOptions { interface WorkerMailerConfig { host: string; port: number; + transportSecurity: TransportSecurity; authType: AuthType; username: string; password: string; @@ -70,6 +85,11 @@ export function workerMailerPlugin( }; } +function coerceTransportSecurity(value: unknown, fallback: TransportSecurity): TransportSecurity { + if (typeof value !== "string") return fallback; + return isTransportSecurity(value) ? value : fallback; +} + function coerceAuthType(value: unknown, fallback: AuthType): AuthType { if (typeof value !== "string") return fallback; return isAuthType(value) ? value : fallback; @@ -94,12 +114,15 @@ async function readConfig( ctx: PluginContext, options: WorkerMailerPluginOptions, ): Promise { + const transportSecurity = coerceTransportSecurity( + await ctx.kv.get("settings:transportSecurity"), + options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY, + ); const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); const port = coerceNumber( await ctx.kv.get("settings:port"), - options.port ?? DEFAULT_PORT, + options.port ?? defaultPortForTransportSecurity(transportSecurity), ); - const secure = (await ctx.kv.get("settings:secure")) ?? options.secure ?? true; const authType = coerceAuthType( await ctx.kv.get("settings:authType"), options.authType ?? DEFAULT_AUTH_TYPE, @@ -128,15 +151,10 @@ async function readConfig( ); } - if (!secure) { - throw new Error( - `Worker Mailer cannot use plaintext SMTP or STARTTLS. ${IMPLICIT_TLS_REQUIRED_MESSAGE}`, - ); - } - return { host: host!, port, + transportSecurity, authType, username: username!, password: password!, @@ -156,13 +174,13 @@ async function setDefault( await ctx.kv.set(key, value); } -async function loadWorkerMailer(): Promise { +async function loadWorkerMailer(): Promise { try { - return await import("@ribassu/worker-mailer"); + return await import("@workermailer/smtp"); } catch (error) { throw new Error( - `Failed to load @ribassu/worker-mailer. ` + - `Ensure this plugin runs on Cloudflare Workers with nodejs_compat enabled.`, + `Failed to load @workermailer/smtp. ` + + `Ensure this plugin runs on Cloudflare Workers with TCP sockets available.`, { cause: error }, ); } @@ -178,7 +196,8 @@ async function sendWithWorkerMailer( const mailerOptions: WorkerMailerOptions = { host: config.host, port: config.port, - secure: true, + secure: config.transportSecurity === "implicit_tls", + startTls: config.transportSecurity === "starttls", authType: config.authType, credentials: { username: config.username, @@ -196,10 +215,12 @@ async function sendWithWorkerMailer( await WorkerMailer.send(mailerOptions, emailOptions); - ctx.log.info(`Delivered email to ${message.to} via Worker Mailer`); + ctx.log.info(`Delivered email to ${message.to} via Worker Mailer (${config.transportSecurity})`); } export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { + const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; + return definePlugin({ id: PLUGIN_ID, version: VERSION, @@ -209,9 +230,15 @@ export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedP "plugin:install": { handler: async (_event, ctx) => { await setDefault(ctx, "settings:host", toNonEmpty(options.host)); - await setDefault(ctx, "settings:port", options.port ?? DEFAULT_PORT); - await ctx.kv.set("settings:secure", true); + await setDefault(ctx, "settings:transportSecurity", transportSecurity); + await setDefault( + ctx, + "settings:port", + options.port ?? defaultPortForTransportSecurity(transportSecurity), + ); + await ctx.kv.delete("settings:transportSecurityMode"); await ctx.kv.delete("settings:startTls"); + await ctx.kv.delete("settings:secure"); await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); await setDefault(ctx, "settings:username", toNonEmpty(options.username)); await setDefault(ctx, "settings:password", toNonEmpty(options.password)); @@ -234,14 +261,24 @@ export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedP host: { type: "string", label: "SMTP Host", - description: "SMTP server hostname for an implicit TLS endpoint (e.g. smtp.example.com)", + description: "SMTP server hostname (for example: smtp.example.com)", default: options.host ?? "", }, + transportSecurity: { + type: "select", + label: "Transport Security", + options: [ + { value: "starttls", label: "STARTTLS" }, + { value: "implicit_tls", label: "Implicit TLS / SMTPS" }, + ], + description: TLS_REQUIRED_MESSAGE, + default: transportSecurity, + }, port: { type: "number", - label: "SMTPS Port", - description: IMPLICIT_TLS_REQUIRED_MESSAGE, - default: options.port ?? DEFAULT_PORT, + label: "SMTP Port", + description: "Use 587 for STARTTLS or 465 for implicit TLS.", + default: options.port ?? defaultPortForTransportSecurity(transportSecurity), min: 1, max: 65535, }, 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..503b495bf --- /dev/null +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { createPlugin, workerMailerPlugin } from "../src/index.js"; + +describe("workerMailerPlugin descriptor", () => { + it("returns a valid plugin descriptor", () => { + const descriptor = workerMailerPlugin(); + + expect(descriptor.id).toBe("worker-mailer"); + expect(descriptor.version).toBe("0.1.0"); + expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-worker-mailer"); + expect(descriptor.adminPages).toHaveLength(1); + }); + + it("passes plugin options through", () => { + const descriptor = workerMailerPlugin({ + host: "smtp.example.com", + transportSecurity: "implicit_tls", + }); + + expect(descriptor.options).toEqual({ + host: "smtp.example.com", + transportSecurity: "implicit_tls", + }); + }); +}); + +describe("createPlugin", () => { + it("declares the email provider capability and delivery hook", () => { + const plugin = createPlugin(); + + expect(plugin.capabilities).toContain("email:provide"); + expect(plugin.hooks).toHaveProperty("email:deliver"); + expect(plugin.hooks).toHaveProperty("plugin:install"); + }); + + it("defaults to STARTTLS configuration", () => { + const plugin = createPlugin(); + const schema = plugin.admin!.settingsSchema!; + + expect(schema.transportSecurity).toMatchObject({ + type: "select", + default: "starttls", + }); + expect(schema.port).toMatchObject({ + type: "number", + default: 587, + }); + }); + + it("switches the default port for implicit TLS", () => { + const plugin = createPlugin({ transportSecurity: "implicit_tls" }); + const schema = plugin.admin!.settingsSchema!; + + expect(schema.transportSecurity!.default).toBe("implicit_tls"); + expect(schema.port!.default).toBe(465); + }); +}); 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 3073a5b26..a047c27d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1258,12 +1258,22 @@ importers: packages/plugins/worker-mailer: dependencies: - '@ribassu/worker-mailer': - specifier: ^1.3.3 - version: 1.3.3 + '@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: @@ -3494,9 +3504,6 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@ribassu/worker-mailer@1.3.3': - resolution: {integrity: sha512-3tHwWwkfWlGIxZ7kefWZlyZ0L36M+tyiYXoDs54PRy+qyKfCM6JuWMghfl6/ONI87NDXwN2I1vcweMo1WYl2CA==} - '@rolldown/binding-android-arm64@1.0.0-rc.3': resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4994,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==} @@ -10817,8 +10827,6 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@ribassu/worker-mailer@1.3.3': {} - '@rolldown/binding-android-arm64@1.0.0-rc.3': optional: true @@ -12442,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 diff --git a/worker-mailer-plugin/CHANGELOG.md b/worker-mailer-plugin/CHANGELOG.md deleted file mode 100644 index 9e7ab6423..000000000 --- a/worker-mailer-plugin/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @emdash-cms/plugin-worker-mailer - -## 0.1.0 - -### Minor Changes - -- Initial release of the Worker Mailer email provider plugin for EmDash. -- Document and enforce the Cloudflare Workers requirement for SMTP connections that start already secure - (implicit TLS / SMTPS). STARTTLS is not exposed by the plugin. diff --git a/worker-mailer-plugin/README.md b/worker-mailer-plugin/README.md deleted file mode 100644 index 46d4a265a..000000000 --- a/worker-mailer-plugin/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# @emdash-cms/plugin-worker-mailer - -SMTP provider plugin for EmDash on Cloudflare Workers using -`@ribassu/worker-mailer`. - -Cloudflare Workers only supports SMTP connections that start already secure -(implicit TLS / SMTPS). STARTTLS is not supported by this plugin. - -## Usage - -Register the plugin in `astro.config.mjs`: - -```js -import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; - -export default defineConfig({ - integrations: [ - emdash({ - plugins: [workerMailerPlugin()], - }), - ], -}); -``` - -Configure the SMTP connection in the EmDash admin UI, or seed defaults in code: - -```js -workerMailerPlugin({ - host: "smtp.example.com", - port: 465, - authType: "plain", - username: "smtp-user", - password: "smtp-password", - fromEmail: "no-reply@example.com", - fromName: "EmDash Demo", -}); -``` - -## Settings - -- `host`: SMTP hostname for an implicit TLS endpoint -- `port`: SMTP port for implicit TLS, usually `465` -- `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/worker-mailer-plugin/package.json b/worker-mailer-plugin/package.json deleted file mode 100644 index 93754b5f5..000000000 --- a/worker-mailer-plugin/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@emdash-cms/plugin-worker-mailer", - "version": "0.1.0", - "description": "Implicit TLS SMTP provider plugin for EmDash CMS on Cloudflare Workers", - "type": "module", - "main": "src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "src" - ], - "keywords": [ - "emdash", - "cms", - "plugin", - "email", - "smtp", - "cloudflare", - "worker-mailer" - ], - "author": "Andre Ribas (@RibasSu)", - "license": "MIT", - "peerDependencies": { - "emdash": "^0.1.0" - }, - "scripts": { - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^6.0.0-beta" - }, - "dependencies": { - "@ribassu/worker-mailer": "^1.3.3" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/emdash-cms/emdash.git", - "directory": "packages/plugins/worker-mailer" - } -} diff --git a/worker-mailer-plugin/src/index.ts b/worker-mailer-plugin/src/index.ts deleted file mode 100644 index d7804ecc4..000000000 --- a/worker-mailer-plugin/src/index.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Worker Mailer Plugin for EmDash CMS - * - * Provides an `email:deliver` transport implementation using - * `@ribassu/worker-mailer` for Cloudflare Workers. - * - * Cloudflare Workers only supports SMTP connections that start secure - * (implicit TLS / SMTPS). STARTTLS upgrades from plaintext are not supported. - */ - -import type { EmailOptions, WorkerMailerOptions } from "@ribassu/worker-mailer"; -import { definePlugin } from "emdash"; -import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash"; - -const PLUGIN_ID = "worker-mailer"; -const VERSION = "0.1.0"; -const DEFAULT_PORT = 465; -const DEFAULT_AUTH_TYPE = "plain"; -const IMPLICIT_TLS_REQUIRED_MESSAGE = - "Cloudflare Workers only supports SMTP connections that start secure (implicit TLS / SMTPS). Use a TLS-enabled SMTP port such as 465."; - -type AuthType = "plain" | "login" | "cram-md5"; - -function isAuthType(value: string): value is AuthType { - return value === "plain" || value === "login" || value === "cram-md5"; -} - -export interface WorkerMailerPluginOptions { - /** SMTP host (e.g. smtp.example.com) */ - host?: string; - /** SMTP port for an implicit TLS endpoint (e.g. 465) */ - port?: number; - /** Only `true` is supported on Cloudflare Workers */ - secure?: boolean; - /** SMTP auth type */ - authType?: AuthType; - /** SMTP username */ - username?: string; - /** SMTP password */ - password?: string; - /** Optional sender email override (defaults to username) */ - fromEmail?: string; - /** Optional sender display name */ - fromName?: string; -} - -interface WorkerMailerConfig { - host: string; - port: number; - authType: AuthType; - username: string; - password: string; - fromEmail: string; - fromName: string | undefined; -} - -/** - * Descriptor for use in astro.config.mjs / live.config.ts. - */ -export function workerMailerPlugin( - options: WorkerMailerPluginOptions = {}, -): PluginDescriptor { - return { - id: PLUGIN_ID, - version: VERSION, - entrypoint: "@emdash-cms/plugin-worker-mailer", - options, - capabilities: ["email:provide"], - adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], - }; -} - -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; -} - -async function readConfig( - ctx: PluginContext, - options: WorkerMailerPluginOptions, -): Promise { - const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); - const port = coerceNumber( - await ctx.kv.get("settings:port"), - options.port ?? DEFAULT_PORT, - ); - const secure = (await ctx.kv.get("settings:secure")) ?? options.secure ?? true; - const authType = coerceAuthType( - await ctx.kv.get("settings:authType"), - options.authType ?? DEFAULT_AUTH_TYPE, - ); - const username = - toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); - const password = - toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); - - const explicitFrom = - toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); - const fromEmail = explicitFrom ?? username; - const fromName = - toNonEmpty(await ctx.kv.get("settings:fromName")) ?? toNonEmpty(options.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(", ")}.`, - ); - } - - if (!secure) { - throw new Error( - `Worker Mailer cannot use plaintext SMTP or STARTTLS. ${IMPLICIT_TLS_REQUIRED_MESSAGE}`, - ); - } - - 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); -} - -async function loadWorkerMailer(): Promise { - try { - return await import("@ribassu/worker-mailer"); - } catch (error) { - throw new Error( - `Failed to load @ribassu/worker-mailer. ` + - `Ensure this plugin runs on Cloudflare Workers with nodejs_compat enabled.`, - { cause: error }, - ); - } -} - -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, - 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`); -} - -export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { - return definePlugin({ - id: PLUGIN_ID, - version: VERSION, - capabilities: ["email:provide"], - - hooks: { - "plugin:install": { - handler: async (_event, ctx) => { - await setDefault(ctx, "settings:host", toNonEmpty(options.host)); - await setDefault(ctx, "settings:port", options.port ?? DEFAULT_PORT); - await ctx.kv.set("settings:secure", true); - await ctx.kv.delete("settings:startTls"); - await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); - await setDefault(ctx, "settings:username", toNonEmpty(options.username)); - await setDefault(ctx, "settings:password", toNonEmpty(options.password)); - await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); - await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); - }, - }, - - "email:deliver": { - exclusive: true, - handler: async (event, ctx) => { - const config = await readConfig(ctx, options); - await sendWithWorkerMailer(ctx, config, event.message); - }, - }, - }, - - admin: { - settingsSchema: { - host: { - type: "string", - label: "SMTP Host", - description: "SMTP server hostname for an implicit TLS endpoint (e.g. smtp.example.com)", - default: options.host ?? "", - }, - port: { - type: "number", - label: "SMTPS Port", - description: IMPLICIT_TLS_REQUIRED_MESSAGE, - default: options.port ?? DEFAULT_PORT, - min: 1, - max: 65535, - }, - authType: { - type: "select", - label: "Auth Type", - options: [ - { value: "plain", label: "PLAIN" }, - { value: "login", label: "LOGIN" }, - { value: "cram-md5", label: "CRAM-MD5" }, - ], - default: options.authType ?? DEFAULT_AUTH_TYPE, - }, - username: { - type: "string", - label: "SMTP Username", - default: options.username ?? "", - }, - password: { - type: "secret", - label: "SMTP Password", - description: "Stored encrypted at rest", - }, - fromEmail: { - type: "string", - label: "From Email", - description: "Defaults to SMTP username when empty", - default: options.fromEmail ?? "", - }, - fromName: { - type: "string", - label: "From Name", - description: "Optional display name for outgoing emails", - default: options.fromName ?? "", - }, - }, - pages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], - }, - }); -} - -export default createPlugin; diff --git a/worker-mailer-plugin/tsconfig.json b/worker-mailer-plugin/tsconfig.json deleted file mode 100644 index 2615e9ae2..000000000 --- a/worker-mailer-plugin/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "preserve", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From 5c62662f7fc5862ac190f5dfbc0ca1983af34b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas?= Date: Thu, 9 Apr 2026 11:45:15 -0300 Subject: [PATCH 07/12] test(plugin-worker-mailer): cover install and delivery flows --- .../worker-mailer/tests/plugin.test.ts | 287 +++++++++++++++++- 1 file changed, 286 insertions(+), 1 deletion(-) diff --git a/packages/plugins/worker-mailer/tests/plugin.test.ts b/packages/plugins/worker-mailer/tests/plugin.test.ts index 503b495bf..c7015d09f 100644 --- a/packages/plugins/worker-mailer/tests/plugin.test.ts +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -1,7 +1,64 @@ -import { describe, expect, it } from "vitest"; +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 { createPlugin, workerMailerPlugin } from "../src/index.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( + plugin: ReturnType, + name: "plugin:install" | "email:deliver", +) { + const hook = plugin.hooks[name]; + if (!hook) { + throw new Error(`Expected hook ${name} to be defined`); + } + return hook as { + exclusive?: boolean; + handler: (event: unknown, ctx: PluginContext) => Promise; + }; +} + describe("workerMailerPlugin descriptor", () => { it("returns a valid plugin descriptor", () => { const descriptor = workerMailerPlugin(); @@ -26,12 +83,19 @@ describe("workerMailerPlugin descriptor", () => { }); describe("createPlugin", () => { + beforeEach(() => { + sendMock.mockReset(); + sendMock.mockResolvedValue(undefined); + }); + it("declares the email provider capability and delivery hook", () => { const plugin = createPlugin(); + const deliverHook = getHook(plugin, "email:deliver"); expect(plugin.capabilities).toContain("email:provide"); expect(plugin.hooks).toHaveProperty("email:deliver"); expect(plugin.hooks).toHaveProperty("plugin:install"); + expect(deliverHook.exclusive).toBe(true); }); it("defaults to STARTTLS configuration", () => { @@ -55,4 +119,225 @@ describe("createPlugin", () => { expect(schema.transportSecurity!.default).toBe("implicit_tls"); expect(schema.port!.default).toBe(465); }); + + it("seeds install defaults and removes legacy transport settings", async () => { + const plugin = createPlugin({ + host: "smtp.example.com", + port: 2465, + transportSecurity: "implicit_tls", + authType: "login", + username: "mailer", + password: "secret", + fromEmail: "from@example.com", + fromName: "Site Mailer", + }); + const { ctx, kv, store } = createMockContext({ + "settings:transportSecurityMode": "legacy", + "settings:startTls": true, + "settings:secure": true, + }); + + await getHook(plugin, "plugin:install").handler({}, ctx); + + expect(store.get("settings:host")).toBe("smtp.example.com"); + expect(store.get("settings:port")).toBe(2465); + expect(store.get("settings:transportSecurity")).toBe("implicit_tls"); + expect(store.get("settings:authType")).toBe("login"); + expect(store.get("settings:username")).toBe("mailer"); + expect(store.get("settings:password")).toBe("secret"); + expect(store.get("settings:fromEmail")).toBe("from@example.com"); + expect(store.get("settings:fromName")).toBe("Site Mailer"); + 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:transportSecurityMode"); + expect(kv.delete).toHaveBeenCalledWith("settings:startTls"); + expect(kv.delete).toHaveBeenCalledWith("settings:secure"); + }); + + it("does not overwrite existing settings during install", async () => { + const plugin = createPlugin({ + host: "smtp.default.example.com", + port: 587, + transportSecurity: "starttls", + authType: "plain", + username: "default-user", + password: "default-pass", + fromEmail: "default@example.com", + fromName: "Default Name", + }); + const { ctx, store } = createMockContext({ + "settings:host": "smtp.saved.example.com", + "settings:port": 2525, + "settings:transportSecurity": "implicit_tls", + "settings:authType": "cram-md5", + "settings:username": "saved-user", + "settings:password": "saved-pass", + "settings:fromEmail": "saved@example.com", + "settings:fromName": "Saved Name", + }); + + await getHook(plugin, "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:transportSecurity")).toBe("implicit_tls"); + 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 STARTTLS email and falls back fromEmail to the username", async () => { + const plugin = createPlugin({ + host: "smtp.example.com", + username: "mailer@example.com", + password: "secret", + }); + const { ctx, log } = createMockContext(); + + await getHook(plugin, "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: 587, + secure: false, + startTls: true, + 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 (starttls)", + ); + }); + + it("delivers implicit TLS email with stored settings overriding defaults", async () => { + const plugin = createPlugin({ + host: "smtp.default.example.com", + port: 587, + transportSecurity: "starttls", + authType: "plain", + username: "default-user", + password: "default-pass", + fromEmail: "default@example.com", + }); + const { ctx } = createMockContext({ + "settings:host": "smtp.saved.example.com", + "settings:port": "465", + "settings:transportSecurity": "implicit_tls", + "settings:authType": "login", + "settings:username": "saved-user", + "settings:password": "saved-pass", + "settings:fromEmail": "sender@example.com", + "settings:fromName": "Support Team", + }); + + await getHook(plugin, "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 plugin = createPlugin({ + host: "smtp.example.com", + }); + const { ctx } = createMockContext(); + + await expect( + getHook(plugin, "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 plugin = createPlugin({ + host: "smtp.example.com", + username: "mailer@example.com", + password: "secret", + }); + const { ctx } = createMockContext({ + "settings:port": 70000, + }); + + await expect( + getHook(plugin, "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(); + }); }); From bc9081b40350e727836ca781d467b3ca8c6ba444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas=20-=20DP?= Date: Thu, 9 Apr 2026 11:56:21 -0300 Subject: [PATCH 08/12] refactor(plugin-worker-mailer): extract shared smtp helpers --- packages/plugins/worker-mailer/src/index.ts | 236 ++----------------- packages/plugins/worker-mailer/src/shared.ts | 211 +++++++++++++++++ 2 files changed, 227 insertions(+), 220 deletions(-) create mode 100644 packages/plugins/worker-mailer/src/shared.ts diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts index 5f95973ec..61e7e2ee9 100644 --- a/packages/plugins/worker-mailer/src/index.ts +++ b/packages/plugins/worker-mailer/src/index.ts @@ -1,80 +1,23 @@ -/** - * Worker Mailer Plugin for EmDash CMS - * - * Provides an `email:deliver` transport implementation using - * `@workermailer/smtp` for Cloudflare Workers. - * - * The plugin supports the two secure transport modes available via - * Cloudflare TCP sockets: - * - STARTTLS (typically port 587) - * - Implicit TLS / SMTPS (typically port 465) - * - * Plaintext SMTP is intentionally not exposed. - */ - -import type { AuthType, EmailOptions, WorkerMailerOptions } from "@workermailer/smtp"; import { definePlugin } from "emdash"; -import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash"; - -const PLUGIN_ID = "worker-mailer"; -const VERSION = "0.1.0"; -const DEFAULT_TRANSPORT_SECURITY = "starttls"; -const DEFAULT_AUTH_TYPE = "plain"; -const IMPLICIT_TLS_PORT = 465; -const STARTTLS_PORT = 587; -const TLS_REQUIRED_MESSAGE = - "Choose STARTTLS on port 587 or implicit TLS on port 465. Plaintext SMTP is not supported."; - -type TransportSecurity = "starttls" | "implicit_tls"; - -function isAuthType(value: string): value is AuthType { - return value === "plain" || value === "login" || value === "cram-md5"; -} - -function isTransportSecurity(value: string): value is TransportSecurity { - return value === "starttls" || value === "implicit_tls"; -} - -function defaultPortForTransportSecurity(transportSecurity: TransportSecurity): number { - return transportSecurity === "implicit_tls" ? IMPLICIT_TLS_PORT : STARTTLS_PORT; -} - -export interface WorkerMailerPluginOptions { - /** SMTP host (e.g. smtp.example.com) */ - host?: string; - /** SMTP port (usually 587 for STARTTLS or 465 for implicit TLS) */ - port?: number; - /** SMTP transport security mode */ - transportSecurity?: TransportSecurity; - /** SMTP auth type */ - authType?: AuthType; - /** SMTP username */ - username?: string; - /** SMTP password */ - password?: string; - /** Optional sender email override (defaults to username) */ - fromEmail?: string; - /** Optional sender display name */ - fromName?: string; -} - -interface WorkerMailerConfig { - host: string; - port: number; - transportSecurity: TransportSecurity; - authType: AuthType; - username: string; - password: string; - fromEmail: string; - fromName: string | undefined; -} +import type { PluginDescriptor, ResolvedPlugin } from "emdash"; + +import { + DEFAULT_AUTH_TYPE, + DEFAULT_TRANSPORT_SECURITY, + PLUGIN_ID, + TLS_REQUIRED_MESSAGE, + VERSION, + defaultPortForTransportSecurity, + installDefaults, + readConfig, + sendWithWorkerMailer, + type WorkerMailerPluginOptions, +} from "./shared.js"; /** * Descriptor for use in astro.config.mjs / live.config.ts. */ -export function workerMailerPlugin( - options: WorkerMailerPluginOptions = {}, -): PluginDescriptor { +export function workerMailerPlugin(options: WorkerMailerPluginOptions = {}): PluginDescriptor { return { id: PLUGIN_ID, version: VERSION, @@ -85,139 +28,6 @@ export function workerMailerPlugin( }; } -function coerceTransportSecurity(value: unknown, fallback: TransportSecurity): TransportSecurity { - if (typeof value !== "string") return fallback; - return isTransportSecurity(value) ? value : fallback; -} - -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; -} - -async function readConfig( - ctx: PluginContext, - options: WorkerMailerPluginOptions, -): Promise { - const transportSecurity = coerceTransportSecurity( - await ctx.kv.get("settings:transportSecurity"), - options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY, - ); - const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); - const port = coerceNumber( - await ctx.kv.get("settings:port"), - options.port ?? defaultPortForTransportSecurity(transportSecurity), - ); - const authType = coerceAuthType( - await ctx.kv.get("settings:authType"), - options.authType ?? DEFAULT_AUTH_TYPE, - ); - const username = - toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); - const password = - toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); - - const explicitFrom = - toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); - const fromEmail = explicitFrom ?? username; - const fromName = - toNonEmpty(await ctx.kv.get("settings:fromName")) ?? toNonEmpty(options.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, - transportSecurity, - 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); -} - -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 }, - ); - } -} - -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: config.transportSecurity === "implicit_tls", - startTls: config.transportSecurity === "starttls", - 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 (${config.transportSecurity})`); -} - export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; @@ -229,21 +39,7 @@ export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedP hooks: { "plugin:install": { handler: async (_event, ctx) => { - await setDefault(ctx, "settings:host", toNonEmpty(options.host)); - await setDefault(ctx, "settings:transportSecurity", transportSecurity); - await setDefault( - ctx, - "settings:port", - options.port ?? defaultPortForTransportSecurity(transportSecurity), - ); - await ctx.kv.delete("settings:transportSecurityMode"); - await ctx.kv.delete("settings:startTls"); - await ctx.kv.delete("settings:secure"); - await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); - await setDefault(ctx, "settings:username", toNonEmpty(options.username)); - await setDefault(ctx, "settings:password", toNonEmpty(options.password)); - await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); - await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); + await installDefaults(ctx, options); }, }, diff --git a/packages/plugins/worker-mailer/src/shared.ts b/packages/plugins/worker-mailer/src/shared.ts new file mode 100644 index 000000000..9057f6250 --- /dev/null +++ b/packages/plugins/worker-mailer/src/shared.ts @@ -0,0 +1,211 @@ +import type { AuthType, EmailOptions, WorkerMailerOptions } from "@workermailer/smtp"; +import type { PluginContext } from "emdash"; + +export const PLUGIN_ID = "worker-mailer"; +export const VERSION = "0.1.0"; +export const DEFAULT_TRANSPORT_SECURITY = "starttls"; +export const DEFAULT_AUTH_TYPE = "plain"; +export const IMPLICIT_TLS_PORT = 465; +export const STARTTLS_PORT = 587; +export const TLS_REQUIRED_MESSAGE = + "Choose STARTTLS on port 587 or implicit TLS on port 465. Plaintext SMTP is not supported."; + +export type TransportSecurity = "starttls" | "implicit_tls"; + +export interface WorkerMailerPluginOptions extends Record { + /** SMTP host (e.g. smtp.example.com) */ + host?: string; + /** SMTP port (usually 587 for STARTTLS or 465 for implicit TLS) */ + port?: number; + /** SMTP transport security mode */ + transportSecurity?: TransportSecurity; + /** SMTP auth type */ + authType?: AuthType; + /** SMTP username */ + username?: string; + /** SMTP password */ + password?: string; + /** Optional sender email override (defaults to username) */ + fromEmail?: string; + /** Optional sender display name */ + fromName?: string; +} + +export interface WorkerMailerConfig { + host: string; + port: number; + transportSecurity: TransportSecurity; + 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 isTransportSecurity(value: string): value is TransportSecurity { + return value === "starttls" || value === "implicit_tls"; +} + +export function defaultPortForTransportSecurity(transportSecurity: TransportSecurity): number { + return transportSecurity === "implicit_tls" ? IMPLICIT_TLS_PORT : STARTTLS_PORT; +} + +function coerceTransportSecurity(value: unknown, fallback: TransportSecurity): TransportSecurity { + if (typeof value !== "string") return fallback; + return isTransportSecurity(value) ? value : fallback; +} + +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, + options: WorkerMailerPluginOptions, +): Promise { + const transportSecurity = coerceTransportSecurity( + await ctx.kv.get("settings:transportSecurity"), + options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY, + ); + const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); + const port = coerceNumber( + await ctx.kv.get("settings:port"), + options.port ?? defaultPortForTransportSecurity(transportSecurity), + ); + const authType = coerceAuthType( + await ctx.kv.get("settings:authType"), + options.authType ?? DEFAULT_AUTH_TYPE, + ); + const username = + toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); + const password = + toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); + + const explicitFrom = + toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); + const fromEmail = explicitFrom ?? username; + const fromName = + toNonEmpty(await ctx.kv.get("settings:fromName")) ?? toNonEmpty(options.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, + transportSecurity, + 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, + options: WorkerMailerPluginOptions, +): Promise { + const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; + + await setDefault(ctx, "settings:host", toNonEmpty(options.host)); + await setDefault(ctx, "settings:transportSecurity", transportSecurity); + await setDefault( + ctx, + "settings:port", + options.port ?? defaultPortForTransportSecurity(transportSecurity), + ); + await ctx.kv.delete("settings:transportSecurityMode"); + await ctx.kv.delete("settings:startTls"); + await ctx.kv.delete("settings:secure"); + await setDefault(ctx, "settings:authType", options.authType ?? DEFAULT_AUTH_TYPE); + await setDefault(ctx, "settings:username", toNonEmpty(options.username)); + await setDefault(ctx, "settings:password", toNonEmpty(options.password)); + await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); + await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); +} + +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: config.transportSecurity === "implicit_tls", + startTls: config.transportSecurity === "starttls", + 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 (${config.transportSecurity})`); +} From 8ca82013a192007df04b6311504e17a2d9b04143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas=20-=20DP?= Date: Thu, 9 Apr 2026 12:00:12 -0300 Subject: [PATCH 09/12] feat(plugin-worker-mailer): add sandbox runtime entry --- packages/plugins/worker-mailer/package.json | 7 ++++--- packages/plugins/worker-mailer/src/index.ts | 21 ++----------------- .../worker-mailer/src/sandbox-entry.ts | 15 +++++++++++++ packages/plugins/worker-mailer/src/shared.ts | 20 ++++++++++++++++++ 4 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 packages/plugins/worker-mailer/src/sandbox-entry.ts diff --git a/packages/plugins/worker-mailer/package.json b/packages/plugins/worker-mailer/package.json index 0483c2b5d..d9f3e9ef4 100644 --- a/packages/plugins/worker-mailer/package.json +++ b/packages/plugins/worker-mailer/package.json @@ -8,7 +8,8 @@ ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.mts" - } + }, + "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ "dist" @@ -25,8 +26,8 @@ "author": "Andre Ribas (@RibasSu)", "license": "MIT", "scripts": { - "build": "tsdown src/index.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts --format esm --dts --watch", + "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" }, diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts index 61e7e2ee9..dc2ecaa07 100644 --- a/packages/plugins/worker-mailer/src/index.ts +++ b/packages/plugins/worker-mailer/src/index.ts @@ -7,10 +7,8 @@ import { PLUGIN_ID, TLS_REQUIRED_MESSAGE, VERSION, + createWorkerMailerHooks, defaultPortForTransportSecurity, - installDefaults, - readConfig, - sendWithWorkerMailer, type WorkerMailerPluginOptions, } from "./shared.js"; @@ -35,22 +33,7 @@ export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedP id: PLUGIN_ID, version: VERSION, capabilities: ["email:provide"], - - hooks: { - "plugin:install": { - handler: async (_event, ctx) => { - await installDefaults(ctx, options); - }, - }, - - "email:deliver": { - exclusive: true, - handler: async (event, ctx) => { - const config = await readConfig(ctx, options); - await sendWithWorkerMailer(ctx, config, event.message); - }, - }, - }, + hooks: createWorkerMailerHooks(options), admin: { settingsSchema: { 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..ce81af2ea --- /dev/null +++ b/packages/plugins/worker-mailer/src/sandbox-entry.ts @@ -0,0 +1,15 @@ +/** + * Sandbox Entry Point -- Worker Mailer SMTP + * + * Standard-format runtime entry for future sandboxed/marketplace use. + * Configuration comes from plugin KV settings and Block Kit admin pages, + * not constructor options. + */ + +import { definePlugin } from "emdash"; + +import { createWorkerMailerHooks } from "./shared.js"; + +export default definePlugin({ + hooks: createWorkerMailerHooks(), +}); diff --git a/packages/plugins/worker-mailer/src/shared.ts b/packages/plugins/worker-mailer/src/shared.ts index 9057f6250..2f1081b79 100644 --- a/packages/plugins/worker-mailer/src/shared.ts +++ b/packages/plugins/worker-mailer/src/shared.ts @@ -1,5 +1,6 @@ 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"; @@ -209,3 +210,22 @@ export async function sendWithWorkerMailer( ctx.log.info(`Delivered email to ${message.to} via Worker Mailer (${config.transportSecurity})`); } + +export function createWorkerMailerHooks( + options: WorkerMailerPluginOptions = {}, +): Pick { + return { + "plugin:install": { + handler: async (_event, ctx) => { + await installDefaults(ctx, options); + }, + }, + "email:deliver": { + exclusive: true, + handler: async (event, ctx) => { + const config = await readConfig(ctx, options); + await sendWithWorkerMailer(ctx, config, event.message); + }, + }, + }; +} From f691f1b1d591f13d14c8618ac01a8c6fa85de534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas=20-=20DP?= Date: Thu, 9 Apr 2026 12:07:48 -0300 Subject: [PATCH 10/12] feat(plugin-worker-mailer): add block kit sandbox config --- packages/plugins/worker-mailer/src/index.ts | 17 +- .../worker-mailer/src/sandbox-entry.ts | 211 +++++++++++++++++- .../worker-mailer/tests/plugin.test.ts | 120 +++++++++- 3 files changed, 345 insertions(+), 3 deletions(-) diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts index dc2ecaa07..8758ed607 100644 --- a/packages/plugins/worker-mailer/src/index.ts +++ b/packages/plugins/worker-mailer/src/index.ts @@ -13,7 +13,7 @@ import { } from "./shared.js"; /** - * Descriptor for use in astro.config.mjs / live.config.ts. + * Native descriptor for use in plugins: [] with constructor options. */ export function workerMailerPlugin(options: WorkerMailerPluginOptions = {}): PluginDescriptor { return { @@ -26,6 +26,21 @@ export function workerMailerPlugin(options: WorkerMailerPluginOptions = {}): Plu }; } +/** + * Standard descriptor for use in sandboxed: [] or marketplace-style installs. + * Configuration is stored in plugin KV and edited through Block Kit pages. + */ +export function workerMailerSandboxedPlugin(): 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" }], + }; +} + export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; diff --git a/packages/plugins/worker-mailer/src/sandbox-entry.ts b/packages/plugins/worker-mailer/src/sandbox-entry.ts index ce81af2ea..4bfde3f7c 100644 --- a/packages/plugins/worker-mailer/src/sandbox-entry.ts +++ b/packages/plugins/worker-mailer/src/sandbox-entry.ts @@ -7,9 +7,218 @@ */ import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; -import { createWorkerMailerHooks } from "./shared.js"; +import { + DEFAULT_AUTH_TYPE, + DEFAULT_TRANSPORT_SECURITY, + TLS_REQUIRED_MESSAGE, + createWorkerMailerHooks, + defaultPortForTransportSecurity, +} 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 transportSecurity = + (await ctx.kv.get("settings:transportSecurity")) ?? DEFAULT_TRANSPORT_SECURITY; + 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"), + defaultPortForTransportSecurity( + transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls", + ), + ); + const hasPassword = !!(await ctx.kv.get("settings:password")); + + return { + blocks: [ + { type: "header", text: "SMTP Settings" }, + { + type: "context", + text: "Configure Worker Mailer for SMTP delivery in trusted or sandboxed mode.", + }, + { + type: "fields", + fields: [ + { label: "Security", value: transportSecurity === "implicit_tls" ? "Implicit TLS" : "STARTTLS" }, + { 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: "select", + action_id: "transportSecurity", + label: "Transport Security", + options: [ + { label: "STARTTLS", value: "starttls" }, + { label: "Implicit TLS / SMTPS", value: "implicit_tls" }, + ], + initial_value: transportSecurity, + }, + { + 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: + `${TLS_REQUIRED_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 transportSecurity = values.transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls"; + const port = toPortNumber(values.port, defaultPortForTransportSecurity(transportSecurity)); + + 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.set("settings:transportSecurity", transportSecurity); + 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/tests/plugin.test.ts b/packages/plugins/worker-mailer/tests/plugin.test.ts index c7015d09f..503c72f06 100644 --- a/packages/plugins/worker-mailer/tests/plugin.test.ts +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -11,7 +11,12 @@ vi.mock("@workermailer/smtp", () => ({ }, })); -import { createPlugin, workerMailerPlugin } from "../src/index.js"; +import { + createPlugin, + workerMailerPlugin, + workerMailerSandboxedPlugin, +} from "../src/index.js"; +import sandboxEntry from "../src/sandbox-entry.js"; function createMockContext(initial: Record = {}) { const store = new Map(Object.entries(initial)); @@ -59,6 +64,20 @@ function getHook( }; } +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 plugin descriptor", () => { const descriptor = workerMailerPlugin(); @@ -80,6 +99,19 @@ describe("workerMailerPlugin descriptor", () => { transportSecurity: "implicit_tls", }); }); + + it("exposes a standard sandboxed descriptor", () => { + const descriptor = workerMailerSandboxedPlugin(); + + 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("createPlugin", () => { @@ -341,3 +373,89 @@ describe("createPlugin", () => { expect(sendMock).not.toHaveBeenCalled(); }); }); + +describe("sandbox entry", () => { + it("renders the SMTP settings page via Block Kit", async () => { + const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", + "settings:transportSecurity": "implicit_tls", + "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: "Security", value: "Implicit TLS" }, + { label: "Password", value: "Stored" }, + ]), + }), + ); + }); + + it("saves SMTP settings from a Block Kit form submission", async () => { + const { ctx, store } = createMockContext({ + "settings:password": "existing-secret", + }); + + const result = await getAdminRoute()( + { + input: { + type: "form_submit", + action_id: "save_settings", + values: { + host: "smtp.example.com", + transportSecurity: "implicit_tls", + port: "465", + authType: "login", + username: "mailer@example.com", + password: "", + fromEmail: "", + fromName: "Support", + }, + }, + }, + ctx, + ); + + expect(store.get("settings:host")).toBe("smtp.example.com"); + expect(store.get("settings:transportSecurity")).toBe("implicit_tls"); + 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", + }); + }); +}); From 4804cdb68d01c75c47eb37df6ca0164fedf16c7f Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Thu, 9 Apr 2026 15:55:50 +0000 Subject: [PATCH 11/12] style: format --- packages/plugins/worker-mailer/src/sandbox-entry.ts | 8 ++++++-- packages/plugins/worker-mailer/tests/plugin.test.ts | 6 +----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/plugins/worker-mailer/src/sandbox-entry.ts b/packages/plugins/worker-mailer/src/sandbox-entry.ts index 4bfde3f7c..cbf39c4b5 100644 --- a/packages/plugins/worker-mailer/src/sandbox-entry.ts +++ b/packages/plugins/worker-mailer/src/sandbox-entry.ts @@ -86,7 +86,10 @@ async function buildSettingsPage(ctx: PluginContext) { { type: "fields", fields: [ - { label: "Security", value: transportSecurity === "implicit_tls" ? "Implicit TLS" : "STARTTLS" }, + { + label: "Security", + value: transportSecurity === "implicit_tls" ? "Implicit TLS" : "STARTTLS", + }, { label: "Port", value: String(port) }, { label: "Host", value: host || "Not configured" }, { label: "Password", value: hasPassword ? "Stored" : "Not set" }, @@ -170,7 +173,8 @@ async function buildSettingsPage(ctx: PluginContext) { } async function saveSettings(ctx: PluginContext, values: Record) { - const transportSecurity = values.transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls"; + const transportSecurity = + values.transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls"; const port = toPortNumber(values.port, defaultPortForTransportSecurity(transportSecurity)); if (!Number.isFinite(port) || port < 1 || port > 65535) { diff --git a/packages/plugins/worker-mailer/tests/plugin.test.ts b/packages/plugins/worker-mailer/tests/plugin.test.ts index 503c72f06..e83cc4fb8 100644 --- a/packages/plugins/worker-mailer/tests/plugin.test.ts +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -11,11 +11,7 @@ vi.mock("@workermailer/smtp", () => ({ }, })); -import { - createPlugin, - workerMailerPlugin, - workerMailerSandboxedPlugin, -} from "../src/index.js"; +import { createPlugin, workerMailerPlugin, workerMailerSandboxedPlugin } from "../src/index.js"; import sandboxEntry from "../src/sandbox-entry.js"; function createMockContext(initial: Record = {}) { From 813f27a0dfcdfaf4a991808f15573a08d6ed8893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ribas=20-=20DP?= Date: Thu, 9 Apr 2026 14:06:35 -0300 Subject: [PATCH 12/12] refactor(plugin-worker-mailer): standardize isolated secure smtp While testing I first implemented this as a native, options-driven plugin in plugins: []. After the guidance in Discussions #245, this now follows the standard isolated Block Kit path in sandboxed: []. This also removes the older shape from the plugin docs/comments and requires SMTP connections to start secure over implicit TLS instead of upgrading from plaintext. --- demos/cloudflare/README.md | 23 +-- demos/cloudflare/astro.config.mjs | 24 +-- packages/plugins/worker-mailer/CHANGELOG.md | 2 +- packages/plugins/worker-mailer/README.md | 30 +-- packages/plugins/worker-mailer/src/index.ts | 108 +--------- .../worker-mailer/src/sandbox-entry.ts | 44 ++--- packages/plugins/worker-mailer/src/shared.ts | 115 +++-------- .../worker-mailer/tests/plugin.test.ts | 184 ++++++------------ 8 files changed, 117 insertions(+), 413 deletions(-) diff --git a/demos/cloudflare/README.md b/demos/cloudflare/README.md index d299b5876..fc38dd51c 100644 --- a/demos/cloudflare/README.md +++ b/demos/cloudflare/README.md @@ -55,26 +55,11 @@ This builds and deploys to Cloudflare Workers. EmDash handles migrations automat This demo includes `@emdash-cms/plugin-worker-mailer` in `astro.config.mjs`. -By default, `workerMailerPlugin()` just enables the SMTP settings page in EmDash so -you can configure the connection in the admin UI. - -If you want to seed defaults in code, use: - -```js -workerMailerPlugin({ - host: "smtp.example.com", - port: 587, - transportSecurity: "starttls", - authType: "plain", - username: "smtp-user", - password: "smtp-password", - fromEmail: "no-reply@example.com", - fromName: "EmDash Demo", -}); -``` +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 TCP sockets support both STARTTLS and implicit TLS, and this -plugin exposes both modes. Plaintext SMTP is intentionally not supported. +Cloudflare Workers SMTP connections must start secure, so this plugin uses an +implicit TLS / SMTPS endpoint instead of upgrading a plaintext connection. ## Notes diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 496b5cde1..a3e375c51 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -71,26 +71,18 @@ export default defineConfig({ ], // Trusted plugins (run in host worker) plugins: [ - // SMTP delivery from the host Worker. Configure credentials in the - // plugin settings page or seed defaults in code. This plugin supports - // STARTTLS (usually port 587) and implicit TLS / SMTPS (usually 465). - // Example: - // workerMailerPlugin({ - // host: "smtp.example.com", - // port: 587, - // transportSecurity: "starttls", - // authType: "plain", - // username: "smtp-user", - // password: "smtp-password", - // fromEmail: "no-reply@example.com", - // fromName: "EmDash Demo", - // }), - workerMailerPlugin(), // Test plugin that exercises all v2 APIs 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/packages/plugins/worker-mailer/CHANGELOG.md b/packages/plugins/worker-mailer/CHANGELOG.md index e5709d690..6c899c416 100644 --- a/packages/plugins/worker-mailer/CHANGELOG.md +++ b/packages/plugins/worker-mailer/CHANGELOG.md @@ -5,4 +5,4 @@ ### Minor Changes - Initial release of the Worker Mailer email provider plugin for EmDash. -- Support secure SMTP via STARTTLS and implicit TLS on Cloudflare Workers. +- 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 index b9c14d95f..a8afd55fc 100644 --- a/packages/plugins/worker-mailer/README.md +++ b/packages/plugins/worker-mailer/README.md @@ -2,12 +2,8 @@ SMTP provider plugin for EmDash on Cloudflare Workers using `@workermailer/smtp`. -The plugin supports secure SMTP in both modes exposed by Cloudflare TCP sockets: - -- `starttls` on port `587` -- `implicit_tls` on port `465` - -Plaintext SMTP is intentionally not exposed. +Cloudflare Workers SMTP connections must start secure, so this plugin uses +implicit TLS / SMTPS and does not expose plaintext or STARTTLS upgrade flows. ## Usage @@ -19,32 +15,22 @@ import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer"; export default defineConfig({ integrations: [ emdash({ - plugins: [workerMailerPlugin()], + sandboxed: [workerMailerPlugin()], }), ], }); ``` -Configure the SMTP connection in the EmDash admin UI, or seed defaults in code: +Configure the SMTP connection in the EmDash admin UI at the plugin's settings page. +On install, the plugin seeds secure defaults for: -```js -workerMailerPlugin({ - host: "smtp.example.com", - port: 587, - transportSecurity: "starttls", - authType: "plain", - username: "smtp-user", - password: "smtp-password", - fromEmail: "no-reply@example.com", - fromName: "EmDash Demo", -}); -``` +- `port = 465` +- `authType = "plain"` ## Settings - `host`: SMTP hostname -- `transportSecurity`: `starttls` or `implicit_tls` -- `port`: SMTP port, usually `587` for STARTTLS or `465` for implicit TLS +- `port`: SMTP port, usually `465` for implicit TLS / SMTPS - `authType`: `plain`, `login`, or `cram-md5` - `username`: SMTP username - `password`: SMTP password diff --git a/packages/plugins/worker-mailer/src/index.ts b/packages/plugins/worker-mailer/src/index.ts index 8758ed607..98aa837fc 100644 --- a/packages/plugins/worker-mailer/src/index.ts +++ b/packages/plugins/worker-mailer/src/index.ts @@ -1,36 +1,11 @@ -import { definePlugin } from "emdash"; -import type { PluginDescriptor, ResolvedPlugin } from "emdash"; +import type { PluginDescriptor } from "emdash"; -import { - DEFAULT_AUTH_TYPE, - DEFAULT_TRANSPORT_SECURITY, - PLUGIN_ID, - TLS_REQUIRED_MESSAGE, - VERSION, - createWorkerMailerHooks, - defaultPortForTransportSecurity, - type WorkerMailerPluginOptions, -} from "./shared.js"; +import { PLUGIN_ID, VERSION } from "./shared.js"; /** - * Native descriptor for use in plugins: [] with constructor options. + * Standard descriptor for isolated Block Kit configuration and runtime delivery. */ -export function workerMailerPlugin(options: WorkerMailerPluginOptions = {}): PluginDescriptor { - return { - id: PLUGIN_ID, - version: VERSION, - entrypoint: "@emdash-cms/plugin-worker-mailer", - options, - capabilities: ["email:provide"], - adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], - }; -} - -/** - * Standard descriptor for use in sandboxed: [] or marketplace-style installs. - * Configuration is stored in plugin KV and edited through Block Kit pages. - */ -export function workerMailerSandboxedPlugin(): PluginDescriptor { +export function workerMailerPlugin(): PluginDescriptor { return { id: PLUGIN_ID, version: VERSION, @@ -40,78 +15,3 @@ export function workerMailerSandboxedPlugin(): PluginDescriptor { adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], }; } - -export function createPlugin(options: WorkerMailerPluginOptions = {}): ResolvedPlugin { - const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; - - return definePlugin({ - id: PLUGIN_ID, - version: VERSION, - capabilities: ["email:provide"], - hooks: createWorkerMailerHooks(options), - - admin: { - settingsSchema: { - host: { - type: "string", - label: "SMTP Host", - description: "SMTP server hostname (for example: smtp.example.com)", - default: options.host ?? "", - }, - transportSecurity: { - type: "select", - label: "Transport Security", - options: [ - { value: "starttls", label: "STARTTLS" }, - { value: "implicit_tls", label: "Implicit TLS / SMTPS" }, - ], - description: TLS_REQUIRED_MESSAGE, - default: transportSecurity, - }, - port: { - type: "number", - label: "SMTP Port", - description: "Use 587 for STARTTLS or 465 for implicit TLS.", - default: options.port ?? defaultPortForTransportSecurity(transportSecurity), - min: 1, - max: 65535, - }, - authType: { - type: "select", - label: "Auth Type", - options: [ - { value: "plain", label: "PLAIN" }, - { value: "login", label: "LOGIN" }, - { value: "cram-md5", label: "CRAM-MD5" }, - ], - default: options.authType ?? DEFAULT_AUTH_TYPE, - }, - username: { - type: "string", - label: "SMTP Username", - default: options.username ?? "", - }, - password: { - type: "secret", - label: "SMTP Password", - description: "Stored encrypted at rest", - }, - fromEmail: { - type: "string", - label: "From Email", - description: "Defaults to SMTP username when empty", - default: options.fromEmail ?? "", - }, - fromName: { - type: "string", - label: "From Name", - description: "Optional display name for outgoing emails", - default: options.fromName ?? "", - }, - }, - pages: [{ path: "/settings", label: "SMTP", icon: "envelope" }], - }, - }); -} - -export default createPlugin; diff --git a/packages/plugins/worker-mailer/src/sandbox-entry.ts b/packages/plugins/worker-mailer/src/sandbox-entry.ts index cbf39c4b5..b9ddd1398 100644 --- a/packages/plugins/worker-mailer/src/sandbox-entry.ts +++ b/packages/plugins/worker-mailer/src/sandbox-entry.ts @@ -1,9 +1,8 @@ /** * Sandbox Entry Point -- Worker Mailer SMTP * - * Standard-format runtime entry for future sandboxed/marketplace use. - * Configuration comes from plugin KV settings and Block Kit admin pages, - * not constructor options. + * Standard-format runtime entry for isolated / marketplace-style use. + * Configuration comes from plugin KV settings and Block Kit admin pages. */ import { definePlugin } from "emdash"; @@ -11,10 +10,9 @@ import type { PluginContext } from "emdash"; import { DEFAULT_AUTH_TYPE, - DEFAULT_TRANSPORT_SECURITY, - TLS_REQUIRED_MESSAGE, + DEFAULT_SECURE_PORT, + SECURE_CONNECTION_MESSAGE, createWorkerMailerHooks, - defaultPortForTransportSecurity, } from "./shared.js"; interface AdminInteraction { @@ -61,8 +59,6 @@ function toPortNumber(value: unknown, fallback: number): number { } async function buildSettingsPage(ctx: PluginContext) { - const transportSecurity = - (await ctx.kv.get("settings:transportSecurity")) ?? DEFAULT_TRANSPORT_SECURITY; const host = (await ctx.kv.get("settings:host")) ?? ""; const username = (await ctx.kv.get("settings:username")) ?? ""; const fromEmail = (await ctx.kv.get("settings:fromEmail")) ?? ""; @@ -70,9 +66,7 @@ async function buildSettingsPage(ctx: PluginContext) { const authType = (await ctx.kv.get("settings:authType")) ?? DEFAULT_AUTH_TYPE; const port = toPortNumber( await ctx.kv.get("settings:port"), - defaultPortForTransportSecurity( - transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls", - ), + DEFAULT_SECURE_PORT, ); const hasPassword = !!(await ctx.kv.get("settings:password")); @@ -81,15 +75,12 @@ async function buildSettingsPage(ctx: PluginContext) { { type: "header", text: "SMTP Settings" }, { type: "context", - text: "Configure Worker Mailer for SMTP delivery in trusted or sandboxed mode.", + text: "Configure Worker Mailer for isolated SMTP delivery with Block Kit settings.", }, { type: "fields", fields: [ - { - label: "Security", - value: transportSecurity === "implicit_tls" ? "Implicit TLS" : "STARTTLS", - }, + { label: "Connection", value: "Implicit TLS / SMTPS" }, { label: "Port", value: String(port) }, { label: "Host", value: host || "Not configured" }, { label: "Password", value: hasPassword ? "Stored" : "Not set" }, @@ -106,16 +97,6 @@ async function buildSettingsPage(ctx: PluginContext) { label: "SMTP Host", initial_value: host, }, - { - type: "select", - action_id: "transportSecurity", - label: "Transport Security", - options: [ - { label: "STARTTLS", value: "starttls" }, - { label: "Implicit TLS / SMTPS", value: "implicit_tls" }, - ], - initial_value: transportSecurity, - }, { type: "number_input", action_id: "port", @@ -164,7 +145,7 @@ async function buildSettingsPage(ctx: PluginContext) { { type: "context", text: - `${TLS_REQUIRED_MESSAGE} ` + + `${SECURE_CONNECTION_MESSAGE} ` + "Leave From Email blank to fall back to the SMTP username. " + "Leave Password blank to keep the stored secret.", }, @@ -173,9 +154,7 @@ async function buildSettingsPage(ctx: PluginContext) { } async function saveSettings(ctx: PluginContext, values: Record) { - const transportSecurity = - values.transportSecurity === "implicit_tls" ? "implicit_tls" : "starttls"; - const port = toPortNumber(values.port, defaultPortForTransportSecurity(transportSecurity)); + const port = toPortNumber(values.port, DEFAULT_SECURE_PORT); if (!Number.isFinite(port) || port < 1 || port > 65535) { return { @@ -184,7 +163,10 @@ async function saveSettings(ctx: PluginContext, values: Record) }; } - await ctx.kv.set("settings:transportSecurity", transportSecurity); + 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); diff --git a/packages/plugins/worker-mailer/src/shared.ts b/packages/plugins/worker-mailer/src/shared.ts index 2f1081b79..ecedced5e 100644 --- a/packages/plugins/worker-mailer/src/shared.ts +++ b/packages/plugins/worker-mailer/src/shared.ts @@ -4,38 +4,14 @@ import type { PluginHooks } from "emdash"; export const PLUGIN_ID = "worker-mailer"; export const VERSION = "0.1.0"; -export const DEFAULT_TRANSPORT_SECURITY = "starttls"; export const DEFAULT_AUTH_TYPE = "plain"; -export const IMPLICIT_TLS_PORT = 465; -export const STARTTLS_PORT = 587; -export const TLS_REQUIRED_MESSAGE = - "Choose STARTTLS on port 587 or implicit TLS on port 465. Plaintext SMTP is not supported."; - -export type TransportSecurity = "starttls" | "implicit_tls"; - -export interface WorkerMailerPluginOptions extends Record { - /** SMTP host (e.g. smtp.example.com) */ - host?: string; - /** SMTP port (usually 587 for STARTTLS or 465 for implicit TLS) */ - port?: number; - /** SMTP transport security mode */ - transportSecurity?: TransportSecurity; - /** SMTP auth type */ - authType?: AuthType; - /** SMTP username */ - username?: string; - /** SMTP password */ - password?: string; - /** Optional sender email override (defaults to username) */ - fromEmail?: string; - /** Optional sender display name */ - fromName?: string; -} +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; - transportSecurity: TransportSecurity; authType: AuthType; username: string; password: string; @@ -47,19 +23,6 @@ function isAuthType(value: string): value is AuthType { return value === "plain" || value === "login" || value === "cram-md5"; } -function isTransportSecurity(value: string): value is TransportSecurity { - return value === "starttls" || value === "implicit_tls"; -} - -export function defaultPortForTransportSecurity(transportSecurity: TransportSecurity): number { - return transportSecurity === "implicit_tls" ? IMPLICIT_TLS_PORT : STARTTLS_PORT; -} - -function coerceTransportSecurity(value: unknown, fallback: TransportSecurity): TransportSecurity { - if (typeof value !== "string") return fallback; - return isTransportSecurity(value) ? value : fallback; -} - function coerceAuthType(value: unknown, fallback: AuthType): AuthType { if (typeof value !== "string") return fallback; return isAuthType(value) ? value : fallback; @@ -80,33 +43,16 @@ function toNonEmpty(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } -export async function readConfig( - ctx: PluginContext, - options: WorkerMailerPluginOptions, -): Promise { - const transportSecurity = coerceTransportSecurity( - await ctx.kv.get("settings:transportSecurity"), - options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY, - ); - const host = toNonEmpty(await ctx.kv.get("settings:host")) ?? toNonEmpty(options.host); - const port = coerceNumber( - await ctx.kv.get("settings:port"), - options.port ?? defaultPortForTransportSecurity(transportSecurity), - ); - const authType = coerceAuthType( - await ctx.kv.get("settings:authType"), - options.authType ?? DEFAULT_AUTH_TYPE, - ); - const username = - toNonEmpty(await ctx.kv.get("settings:username")) ?? toNonEmpty(options.username); - const password = - toNonEmpty(await ctx.kv.get("settings:password")) ?? toNonEmpty(options.password); - - const explicitFrom = - toNonEmpty(await ctx.kv.get("settings:fromEmail")) ?? toNonEmpty(options.fromEmail); +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")) ?? toNonEmpty(options.fromName); + const fromName = toNonEmpty(await ctx.kv.get("settings:fromName")); const missing: string[] = []; if (!host) missing.push("host"); @@ -124,7 +70,6 @@ export async function readConfig( return { host: host!, port, - transportSecurity, authType, username: username!, password: password!, @@ -144,27 +89,13 @@ async function setDefault( await ctx.kv.set(key, value); } -export async function installDefaults( - ctx: PluginContext, - options: WorkerMailerPluginOptions, -): Promise { - const transportSecurity = options.transportSecurity ?? DEFAULT_TRANSPORT_SECURITY; - - await setDefault(ctx, "settings:host", toNonEmpty(options.host)); - await setDefault(ctx, "settings:transportSecurity", transportSecurity); - await setDefault( - ctx, - "settings:port", - options.port ?? defaultPortForTransportSecurity(transportSecurity), - ); +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", options.authType ?? DEFAULT_AUTH_TYPE); - await setDefault(ctx, "settings:username", toNonEmpty(options.username)); - await setDefault(ctx, "settings:password", toNonEmpty(options.password)); - await setDefault(ctx, "settings:fromEmail", toNonEmpty(options.fromEmail)); - await setDefault(ctx, "settings:fromName", toNonEmpty(options.fromName)); + await setDefault(ctx, "settings:authType", DEFAULT_AUTH_TYPE); } async function loadWorkerMailer(): Promise { @@ -189,8 +120,8 @@ export async function sendWithWorkerMailer( const mailerOptions: WorkerMailerOptions = { host: config.host, port: config.port, - secure: config.transportSecurity === "implicit_tls", - startTls: config.transportSecurity === "starttls", + secure: true, + startTls: false, authType: config.authType, credentials: { username: config.username, @@ -208,22 +139,20 @@ export async function sendWithWorkerMailer( await WorkerMailer.send(mailerOptions, emailOptions); - ctx.log.info(`Delivered email to ${message.to} via Worker Mailer (${config.transportSecurity})`); + ctx.log.info(`Delivered email to ${message.to} via Worker Mailer (implicit TLS)`); } -export function createWorkerMailerHooks( - options: WorkerMailerPluginOptions = {}, -): Pick { +export function createWorkerMailerHooks(): Pick { return { "plugin:install": { handler: async (_event, ctx) => { - await installDefaults(ctx, options); + await installDefaults(ctx); }, }, "email:deliver": { exclusive: true, handler: async (event, ctx) => { - const config = await readConfig(ctx, options); + 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 index e83cc4fb8..0939a533f 100644 --- a/packages/plugins/worker-mailer/tests/plugin.test.ts +++ b/packages/plugins/worker-mailer/tests/plugin.test.ts @@ -11,7 +11,7 @@ vi.mock("@workermailer/smtp", () => ({ }, })); -import { createPlugin, workerMailerPlugin, workerMailerSandboxedPlugin } from "../src/index.js"; +import { workerMailerPlugin } from "../src/index.js"; import sandboxEntry from "../src/sandbox-entry.js"; function createMockContext(initial: Record = {}) { @@ -46,11 +46,8 @@ function createMockContext(initial: Record = {}) { }; } -function getHook( - plugin: ReturnType, - name: "plugin:install" | "email:deliver", -) { - const hook = plugin.hooks[name]; +function getHook(name: "plugin:install" | "email:deliver") { + const hook = sandboxEntry.hooks?.[name]; if (!hook) { throw new Error(`Expected hook ${name} to be defined`); } @@ -75,30 +72,9 @@ function getAdminRoute() { } describe("workerMailerPlugin descriptor", () => { - it("returns a valid plugin descriptor", () => { + it("returns a valid standard plugin descriptor", () => { const descriptor = workerMailerPlugin(); - expect(descriptor.id).toBe("worker-mailer"); - expect(descriptor.version).toBe("0.1.0"); - expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-worker-mailer"); - expect(descriptor.adminPages).toHaveLength(1); - }); - - it("passes plugin options through", () => { - const descriptor = workerMailerPlugin({ - host: "smtp.example.com", - transportSecurity: "implicit_tls", - }); - - expect(descriptor.options).toEqual({ - host: "smtp.example.com", - transportSecurity: "implicit_tls", - }); - }); - - it("exposes a standard sandboxed descriptor", () => { - const descriptor = workerMailerSandboxedPlugin(); - expect(descriptor).toMatchObject({ id: "worker-mailer", version: "0.1.0", @@ -110,94 +86,52 @@ describe("workerMailerPlugin descriptor", () => { }); }); -describe("createPlugin", () => { +describe("sandbox entry hooks", () => { beforeEach(() => { sendMock.mockReset(); sendMock.mockResolvedValue(undefined); }); - it("declares the email provider capability and delivery hook", () => { - const plugin = createPlugin(); - const deliverHook = getHook(plugin, "email:deliver"); + it("declares install and exclusive delivery hooks", () => { + const deliverHook = getHook("email:deliver"); - expect(plugin.capabilities).toContain("email:provide"); - expect(plugin.hooks).toHaveProperty("email:deliver"); - expect(plugin.hooks).toHaveProperty("plugin:install"); + expect(sandboxEntry.hooks).toHaveProperty("email:deliver"); + expect(sandboxEntry.hooks).toHaveProperty("plugin:install"); expect(deliverHook.exclusive).toBe(true); }); - it("defaults to STARTTLS configuration", () => { - const plugin = createPlugin(); - const schema = plugin.admin!.settingsSchema!; - - expect(schema.transportSecurity).toMatchObject({ - type: "select", - default: "starttls", - }); - expect(schema.port).toMatchObject({ - type: "number", - default: 587, - }); - }); - - it("switches the default port for implicit TLS", () => { - const plugin = createPlugin({ transportSecurity: "implicit_tls" }); - const schema = plugin.admin!.settingsSchema!; - - expect(schema.transportSecurity!.default).toBe("implicit_tls"); - expect(schema.port!.default).toBe(465); - }); - it("seeds install defaults and removes legacy transport settings", async () => { - const plugin = createPlugin({ - host: "smtp.example.com", - port: 2465, - transportSecurity: "implicit_tls", - authType: "login", - username: "mailer", - password: "secret", - fromEmail: "from@example.com", - fromName: "Site Mailer", - }); const { ctx, kv, store } = createMockContext({ + "settings:transportSecurity": "starttls", "settings:transportSecurityMode": "legacy", "settings:startTls": true, "settings:secure": true, }); - await getHook(plugin, "plugin:install").handler({}, ctx); + await getHook("plugin:install").handler({}, ctx); - expect(store.get("settings:host")).toBe("smtp.example.com"); - expect(store.get("settings:port")).toBe(2465); - expect(store.get("settings:transportSecurity")).toBe("implicit_tls"); - expect(store.get("settings:authType")).toBe("login"); - expect(store.get("settings:username")).toBe("mailer"); - expect(store.get("settings:password")).toBe("secret"); - expect(store.get("settings:fromEmail")).toBe("from@example.com"); - expect(store.get("settings:fromName")).toBe("Site Mailer"); + 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 plugin = createPlugin({ - host: "smtp.default.example.com", - port: 587, - transportSecurity: "starttls", - authType: "plain", - username: "default-user", - password: "default-pass", - fromEmail: "default@example.com", - fromName: "Default Name", - }); const { ctx, store } = createMockContext({ "settings:host": "smtp.saved.example.com", "settings:port": 2525, - "settings:transportSecurity": "implicit_tls", "settings:authType": "cram-md5", "settings:username": "saved-user", "settings:password": "saved-pass", @@ -205,11 +139,10 @@ describe("createPlugin", () => { "settings:fromName": "Saved Name", }); - await getHook(plugin, "plugin:install").handler({}, ctx); + 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:transportSecurity")).toBe("implicit_tls"); expect(store.get("settings:authType")).toBe("cram-md5"); expect(store.get("settings:username")).toBe("saved-user"); expect(store.get("settings:password")).toBe("saved-pass"); @@ -217,15 +150,14 @@ describe("createPlugin", () => { expect(store.get("settings:fromName")).toBe("Saved Name"); }); - it("delivers STARTTLS email and falls back fromEmail to the username", async () => { - const plugin = createPlugin({ - host: "smtp.example.com", - username: "mailer@example.com", - password: "secret", + 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", }); - const { ctx, log } = createMockContext(); - await getHook(plugin, "email:deliver").handler( + await getHook("email:deliver").handler( { message: { to: "hello@example.com", @@ -240,9 +172,9 @@ describe("createPlugin", () => { expect(sendMock).toHaveBeenCalledWith( { host: "smtp.example.com", - port: 587, - secure: false, - startTls: true, + port: 465, + secure: true, + startTls: false, authType: "plain", credentials: { username: "mailer@example.com", @@ -258,24 +190,14 @@ describe("createPlugin", () => { }, ); expect(log.info).toHaveBeenCalledWith( - "Delivered email to hello@example.com via Worker Mailer (starttls)", + "Delivered email to hello@example.com via Worker Mailer (implicit TLS)", ); }); - it("delivers implicit TLS email with stored settings overriding defaults", async () => { - const plugin = createPlugin({ - host: "smtp.default.example.com", - port: 587, - transportSecurity: "starttls", - authType: "plain", - username: "default-user", - password: "default-pass", - fromEmail: "default@example.com", - }); + it("delivers implicit TLS email with stored settings", async () => { const { ctx } = createMockContext({ "settings:host": "smtp.saved.example.com", "settings:port": "465", - "settings:transportSecurity": "implicit_tls", "settings:authType": "login", "settings:username": "saved-user", "settings:password": "saved-pass", @@ -283,7 +205,7 @@ describe("createPlugin", () => { "settings:fromName": "Support Team", }); - await getHook(plugin, "email:deliver").handler( + await getHook("email:deliver").handler( { message: { to: "hello@example.com", @@ -322,13 +244,12 @@ describe("createPlugin", () => { }); it("fails fast when required SMTP settings are missing", async () => { - const plugin = createPlugin({ - host: "smtp.example.com", + const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", }); - const { ctx } = createMockContext(); await expect( - getHook(plugin, "email:deliver").handler( + getHook("email:deliver").handler( { message: { to: "hello@example.com", @@ -345,17 +266,15 @@ describe("createPlugin", () => { }); it("fails fast when the configured port is invalid", async () => { - const plugin = createPlugin({ - host: "smtp.example.com", - username: "mailer@example.com", - password: "secret", - }); const { ctx } = createMockContext({ + "settings:host": "smtp.example.com", + "settings:username": "mailer@example.com", + "settings:password": "secret", "settings:port": 70000, }); await expect( - getHook(plugin, "email:deliver").handler( + getHook("email:deliver").handler( { message: { to: "hello@example.com", @@ -370,11 +289,10 @@ describe("createPlugin", () => { }); }); -describe("sandbox entry", () => { +describe("sandbox entry admin route", () => { it("renders the SMTP settings page via Block Kit", async () => { const { ctx } = createMockContext({ "settings:host": "smtp.example.com", - "settings:transportSecurity": "implicit_tls", "settings:port": 465, "settings:authType": "login", "settings:username": "mailer@example.com", @@ -409,15 +327,25 @@ describe("sandbox entry", () => { expect.objectContaining({ type: "fields", fields: expect.arrayContaining([ - { label: "Security", value: "Implicit TLS" }, + { 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", }); @@ -428,7 +356,6 @@ describe("sandbox entry", () => { action_id: "save_settings", values: { host: "smtp.example.com", - transportSecurity: "implicit_tls", port: "465", authType: "login", username: "mailer@example.com", @@ -442,7 +369,10 @@ describe("sandbox entry", () => { ); expect(store.get("settings:host")).toBe("smtp.example.com"); - expect(store.get("settings:transportSecurity")).toBe("implicit_tls"); + 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");