Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
51f8554
feat(i18n): add pt-br locale to demos, fixture, and docs
RibasSu Apr 2, 2026
4df3309
feat(email): add worker-mailer plugin
RibasSu Apr 2, 2026
2257651
docs(email): add worker-mailer examples
RibasSu Apr 2, 2026
b348838
Merge branch 'main' into main
RibasSu Apr 2, 2026
da9b864
Merge branch 'main' into main
RibasSu Apr 2, 2026
97c00b5
up
RibasSu Apr 4, 2026
091fd0d
Merge branch 'emdash-cms:main' into main
RibasSu Apr 4, 2026
0f87db9
Merge branch 'emdash-cms:main' into feat/worker-mailer-plugin
RibasSu Apr 4, 2026
296e8c8
Merge local-original/main into main
RibasSu Apr 6, 2026
3c5b9ec
Remove pt-br i18n examples from main
RibasSu Apr 6, 2026
9d7140a
Merge branch 'main' into feat/worker-mailer-plugin
RibasSu Apr 6, 2026
945c26d
Merge branch 'emdash-cms:main' into feat/worker-mailer-plugin
RibasSu Apr 9, 2026
b2a9c33
feat(plugin-worker-mailer): integrate @workermailer/smtp
RibasSu Apr 9, 2026
dbf7e0e
Merge branch 'emdash-cms:main' into feat/worker-mailer-plugin
RibasSu Apr 9, 2026
5c62662
test(plugin-worker-mailer): cover install and delivery flows
RibasSu Apr 9, 2026
bc9081b
refactor(plugin-worker-mailer): extract shared smtp helpers
RibasSu Apr 9, 2026
8ca8201
feat(plugin-worker-mailer): add sandbox runtime entry
RibasSu Apr 9, 2026
f691f1b
feat(plugin-worker-mailer): add block kit sandbox config
RibasSu Apr 9, 2026
4804cdb
style: format
emdashbot[bot] Apr 9, 2026
813f27a
refactor(plugin-worker-mailer): standardize isolated secure smtp
RibasSu Apr 9, 2026
9f2bd47
Merge branch 'main' into feat/worker-mailer-plugin
RibasSu Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/friendly-tips-grow.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions demos/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ pnpm deploy

This builds and deploys to Cloudflare Workers. EmDash handles migrations automatically on startup.

## Email

This demo includes `@emdash-cms/plugin-worker-mailer` in `astro.config.mjs`.

In this demo, `workerMailerPlugin()` runs as an isolated plugin and exposes its
SMTP settings page in EmDash so you can configure the connection in the admin UI.

Cloudflare Workers SMTP connections must start secure, so this plugin uses an
implicit TLS / SMTPS endpoint instead of upgrading a plaintext connection.

## Notes

- `astro dev` now uses `workerd` (the real Workers runtime) - development matches production
Expand Down
10 changes: 9 additions & 1 deletion demos/cloudflare/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -74,7 +75,14 @@ export default defineConfig({
formsPlugin(),
],
// Sandboxed plugins (run in isolated workers)
sandboxed: [webhookNotifierPlugin()],
sandboxed: [
// SMTP delivery with Block Kit settings in an isolated worker.
// Configure credentials in the plugin settings page. Cloudflare
// Workers must start SMTP connections secure, so this uses an
// implicit TLS / SMTPS endpoint, usually on port 465.
workerMailerPlugin(),
webhookNotifierPlugin(),
],
// Sandbox runner for Cloudflare
sandboxRunner: sandbox(),
// Plugin marketplace
Expand Down
17 changes: 9 additions & 8 deletions demos/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/worker-mailer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @emdash-cms/plugin-worker-mailer

## 0.1.0

### Minor Changes

- Initial release of the Worker Mailer email provider plugin for EmDash.
- Support secure SMTP over implicit TLS / SMTPS on Cloudflare Workers.
38 changes: 38 additions & 0 deletions packages/plugins/worker-mailer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# @emdash-cms/plugin-worker-mailer

SMTP provider plugin for EmDash on Cloudflare Workers using `@workermailer/smtp`.

Cloudflare Workers SMTP connections must start secure, so this plugin uses
implicit TLS / SMTPS and does not expose plaintext or STARTTLS upgrade flows.

## Usage

Register the plugin in `astro.config.mjs`:

```js
import { workerMailerPlugin } from "@emdash-cms/plugin-worker-mailer";

export default defineConfig({
integrations: [
emdash({
sandboxed: [workerMailerPlugin()],
}),
],
});
```

Configure the SMTP connection in the EmDash admin UI at the plugin's settings page.
On install, the plugin seeds secure defaults for:

- `port = 465`
- `authType = "plain"`

## Settings

- `host`: SMTP hostname
- `port`: SMTP port, usually `465` for implicit TLS / SMTPS
- `authType`: `plain`, `login`, or `cram-md5`
- `username`: SMTP username
- `password`: SMTP password
- `fromEmail`: sender email override, defaults to `username`
- `fromName`: optional sender display name
48 changes: 48 additions & 0 deletions packages/plugins/worker-mailer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@emdash-cms/plugin-worker-mailer",
"version": "0.1.0",
"description": "SMTP provider plugin for EmDash CMS on Cloudflare Workers using @workermailer/smtp",
"type": "module",
"main": "dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.mts"
},
"./sandbox": "./dist/sandbox-entry.mjs"
},
"files": [
"dist"
],
"keywords": [
"emdash",
"cms",
"plugin",
"email",
"smtp",
"cloudflare",
"worker-mailer"
],
"author": "Andre Ribas (@RibasSu)",
"license": "MIT",
"scripts": {
"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean",
"dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch",
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"@workermailer/smtp": "^0.1.0",
"emdash": "workspace:*"
},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/worker-mailer"
}
}
17 changes: 17 additions & 0 deletions packages/plugins/worker-mailer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { PluginDescriptor } from "emdash";

import { PLUGIN_ID, VERSION } from "./shared.js";

/**
* Standard descriptor for isolated Block Kit configuration and runtime delivery.
*/
export function workerMailerPlugin(): PluginDescriptor {
return {
id: PLUGIN_ID,
version: VERSION,
format: "standard",
entrypoint: "@emdash-cms/plugin-worker-mailer/sandbox",
capabilities: ["email:provide"],
adminPages: [{ path: "/settings", label: "SMTP", icon: "envelope" }],
};
}
210 changes: 210 additions & 0 deletions packages/plugins/worker-mailer/src/sandbox-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Sandbox Entry Point -- Worker Mailer SMTP
*
* Standard-format runtime entry for isolated / marketplace-style use.
* Configuration comes from plugin KV settings and Block Kit admin pages.
*/

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

import {
DEFAULT_AUTH_TYPE,
DEFAULT_SECURE_PORT,
SECURE_CONNECTION_MESSAGE,
createWorkerMailerHooks,
} from "./shared.js";

interface AdminInteraction {
type: string;
page?: string;
action_id?: string;
values?: Record<string, unknown>;
}

export default definePlugin({
hooks: createWorkerMailerHooks(),
routes: {
admin: {
handler: async (routeCtx: { input: unknown }, ctx: PluginContext) => {
const interaction = (routeCtx.input ?? {}) as AdminInteraction;

if (interaction.type === "page_load" && interaction.page === "/settings") {
return buildSettingsPage(ctx);
}

if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
return saveSettings(ctx, interaction.values ?? {});
}

return { blocks: [] };
},
},
},
});

function toNonEmpty(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}

function toPortNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}

async function buildSettingsPage(ctx: PluginContext) {
const host = (await ctx.kv.get<string>("settings:host")) ?? "";
const username = (await ctx.kv.get<string>("settings:username")) ?? "";
const fromEmail = (await ctx.kv.get<string>("settings:fromEmail")) ?? "";
const fromName = (await ctx.kv.get<string>("settings:fromName")) ?? "";
const authType = (await ctx.kv.get<string>("settings:authType")) ?? DEFAULT_AUTH_TYPE;
const port = toPortNumber(
await ctx.kv.get<number | string>("settings:port"),
DEFAULT_SECURE_PORT,
);
const hasPassword = !!(await ctx.kv.get<string>("settings:password"));

return {
blocks: [
{ type: "header", text: "SMTP Settings" },
{
type: "context",
text: "Configure Worker Mailer for isolated SMTP delivery with Block Kit settings.",
},
{
type: "fields",
fields: [
{ label: "Connection", value: "Implicit TLS / SMTPS" },
{ label: "Port", value: String(port) },
{ label: "Host", value: host || "Not configured" },
{ label: "Password", value: hasPassword ? "Stored" : "Not set" },
],
},
{ type: "divider" },
{
type: "form",
block_id: "worker-mailer-settings",
fields: [
{
type: "text_input",
action_id: "host",
label: "SMTP Host",
initial_value: host,
},
{
type: "number_input",
action_id: "port",
label: "SMTP Port",
initial_value: port,
min: 1,
max: 65535,
},
{
type: "select",
action_id: "authType",
label: "Auth Type",
options: [
{ label: "PLAIN", value: "plain" },
{ label: "LOGIN", value: "login" },
{ label: "CRAM-MD5", value: "cram-md5" },
],
initial_value: authType,
},
{
type: "text_input",
action_id: "username",
label: "SMTP Username",
initial_value: username,
},
{
type: "secret_input",
action_id: "password",
label: "SMTP Password",
},
{
type: "text_input",
action_id: "fromEmail",
label: "From Email",
initial_value: fromEmail,
},
{
type: "text_input",
action_id: "fromName",
label: "From Name",
initial_value: fromName,
},
],
submit: { label: "Save Settings", action_id: "save_settings" },
},
{
type: "context",
text:
`${SECURE_CONNECTION_MESSAGE} ` +
"Leave From Email blank to fall back to the SMTP username. " +
"Leave Password blank to keep the stored secret.",
},
],
};
}

async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
const port = toPortNumber(values.port, DEFAULT_SECURE_PORT);

if (!Number.isFinite(port) || port < 1 || port > 65535) {
return {
...(await buildSettingsPage(ctx)),
toast: { message: "Port must be between 1 and 65535", type: "error" },
};
}

await ctx.kv.delete("settings:transportSecurity");
await ctx.kv.delete("settings:transportSecurityMode");
await ctx.kv.delete("settings:startTls");
await ctx.kv.delete("settings:secure");
await ctx.kv.set("settings:port", port);
await ctx.kv.set("settings:authType", toNonEmpty(values.authType) ?? DEFAULT_AUTH_TYPE);

const host = toNonEmpty(values.host);
if (host) {
await ctx.kv.set("settings:host", host);
} else {
await ctx.kv.delete("settings:host");
}

const username = toNonEmpty(values.username);
if (username) {
await ctx.kv.set("settings:username", username);
} else {
await ctx.kv.delete("settings:username");
}

const password = toNonEmpty(values.password);
if (password) {
await ctx.kv.set("settings:password", password);
}

const fromEmail = toNonEmpty(values.fromEmail);
if (fromEmail) {
await ctx.kv.set("settings:fromEmail", fromEmail);
} else {
await ctx.kv.delete("settings:fromEmail");
}

const fromName = toNonEmpty(values.fromName);
if (fromName) {
await ctx.kv.set("settings:fromName", fromName);
} else {
await ctx.kv.delete("settings:fromName");
}

return {
...(await buildSettingsPage(ctx)),
toast: { message: "Settings saved", type: "success" },
};
}
Loading
Loading