Skip to content

Commit e142700

Browse files
committed
feat(core): built-in SMTP email transport for generic SMTP credentials
Adds an email:deliver provider that works with any standard SMTP server (Brevo relay, Office365, Fastmail, Amazon SES, self-hosted Postfix) via raw TCP — the one network primitive sandboxed plugins cannot use. Configuration is env-only (EMAIL_SMTP_HOST/PORT/USER/PASS/FROM). Supports STARTTLS (587) and implicit TLS (465); port 25 is refused because Cloudflare blocks it. TLS is always required. Works on Cloudflare Workers (cloudflare:sockets) and Node (node:net/node:tls). Registered as a built-in plugin so it participates in exclusive hook resolution like any other provider — explicitly selected plugin transports still take precedence. Closes #1541
1 parent e8b7ddc commit e142700

5 files changed

Lines changed: 840 additions & 0 deletions

File tree

.changeset/smtp-email-transport.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
Adds a built-in SMTP email transport for generic SMTP credentials (Brevo relay, Office365, Fastmail, Amazon SES, self-hosted Postfix). Configure via `EMAIL_SMTP_HOST`, `EMAIL_SMTP_PORT`, `EMAIL_SMTP_USER`, `EMAIL_SMTP_PASS`, and optional `EMAIL_SMTP_FROM` env vars. Supports STARTTLS (port 587) and implicit TLS (port 465); port 25 is refused with a clear error because Cloudflare blocks it. Works on Cloudflare Workers (via `cloudflare:sockets`) and Node (via `node:net`/`node:tls`). Sandboxed plugins cannot open TCP sockets, so this lives in core — explicitly selected plugin transports still take precedence.

docs/src/content/docs/deployment/cloudflare.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,44 @@ the provider under **Settings → Email**.
274274
Email.
275275
</Aside>
276276

277+
### Built-in SMTP transport
278+
279+
If you don't want to use Cloudflare Email Sending (e.g. you already have SMTP
280+
credentials for Brevo relay, Office365, Fastmail, Amazon SES, or a self-hosted
281+
Postfix relay), EmDash includes a built-in SMTP transport that works with any
282+
standard SMTP server. Sandboxed plugins cannot open TCP sockets, so this lives
283+
in core and uses `cloudflare:sockets` on Workers or `node:net`/`node:tls` on
284+
Node.
285+
286+
Configure via environment variables:
287+
288+
```bash title=".env"
289+
EMAIL_SMTP_HOST=smtp-relay.brevo.com
290+
EMAIL_SMTP_PORT=587
291+
EMAIL_SMTP_USER=you@example.com
292+
EMAIL_SMTP_PASS=xsmtpsib-your-key-here
293+
EMAIL_SMTP_FROM="My Site <noreply@example.com>"
294+
```
295+
296+
| Variable | Example | Notes |
297+
| ----------------- | ------------------------------ | --------------------------------------------------------- |
298+
| `EMAIL_SMTP_HOST` | `smtp-relay.brevo.com` | Any SMTP server reachable from your deployment |
299+
| `EMAIL_SMTP_PORT` | `587` or `465` | 587 = STARTTLS, 465 = implicit TLS. Port 25 is refused. |
300+
| `EMAIL_SMTP_USER` | `you@example.com` | SMTP auth username |
301+
| `EMAIL_SMTP_PASS` | `xsmtpsib-…` | SMTP auth password (store as a secret) |
302+
| `EMAIL_SMTP_FROM` | `My Site <noreply@example.com>` | Optional default sender; falls back to `EMAIL_SMTP_USER` |
303+
304+
<Aside type="caution">
305+
Cloudflare blocks outbound port 25. This transport only supports submission
306+
ports 587 (STARTTLS) and 465 (implicit TLS), and refuses port 25 with a clear
307+
error. TLS is always required — plaintext auth is never attempted.
308+
</Aside>
309+
310+
When configured, the SMTP transport registers as a built-in `email:deliver`
311+
provider. Explicitly selected plugin transports (like `cloudflareEmail()`) still
312+
take precedence — the built-in SMTP provider only wins when it's the sole
313+
registered provider or when you explicitly select it under **Settings → Email**.
314+
277315
## Environment Variables
278316

279317
### Recommended: encryption key

packages/core/src/emdash-runtime.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ import { getDb } from "./loader.js";
172172
import { CronExecutor, type InvokeCronHookFn } from "./plugins/cron.js";
173173
import { definePlugin } from "./plugins/define-plugin.js";
174174
import { DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver } from "./plugins/email-console.js";
175+
import { createSmtpEmailDeliver, loadSmtpConfigFromEnv, SMTP_EMAIL_PLUGIN_ID } from "./plugins/email-smtp.js";
175176
import { EmailPipeline } from "./plugins/email.js";
176177
import {
177178
createHookPipeline,
@@ -1298,6 +1299,33 @@ export class EmDashRuntime {
12981299
}
12991300
}
13001301

1302+
// Register built-in SMTP email provider when EMAIL_SMTP_* env vars are
1303+
// present. This is the only transport that works with generic SMTP
1304+
// credentials (Brevo relay, Office365, Fastmail, self-hosted Postfix)
1305+
// because sandboxed plugins cannot open TCP sockets. Registered as a
1306+
// built-in so it participates in exclusive hook resolution like any
1307+
// other provider — explicitly-selected plugin transports still win.
1308+
const smtpConfig = loadSmtpConfigFromEnv();
1309+
if (smtpConfig) {
1310+
try {
1311+
const smtpPlugin = definePlugin({
1312+
id: SMTP_EMAIL_PLUGIN_ID,
1313+
version: "1.0.0",
1314+
capabilities: ["hooks.email-transport:register"],
1315+
hooks: {
1316+
"email:deliver": {
1317+
exclusive: true,
1318+
handler: createSmtpEmailDeliver(smtpConfig),
1319+
},
1320+
},
1321+
});
1322+
allPipelinePlugins.push(smtpPlugin);
1323+
enabledPlugins.add(smtpPlugin.id);
1324+
} catch (error) {
1325+
console.warn("[email] Failed to register SMTP email provider:", error);
1326+
}
1327+
}
1328+
13011329
// Register built-in default comment moderator.
13021330
// Always present — auto-selected as the sole comment:moderate provider
13031331
// unless a plugin (e.g. AI moderation) provides its own.

0 commit comments

Comments
 (0)