Skip to content

feat(core): built-in SMTP email transport for generic SMTP credentials#2156

Open
swissky wants to merge 34 commits into
emdash-cms:mainfrom
swissky:feat/smtp-transport
Open

feat(core): built-in SMTP email transport for generic SMTP credentials#2156
swissky wants to merge 34 commits into
emdash-cms:mainfrom
swissky:feat/smtp-transport

Conversation

@swissky

@swissky swissky commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a built-in email:deliver transport that works with any standard SMTP server (Brevo relay, Office365, Google Workspace, Fastmail, Amazon SES, self-hosted Postfix) via raw TCP — the one network primitive sandboxed plugins cannot use. This unblocks operators who only have generic SMTP credentials and cannot use HTTP transactional APIs (Brevo HTTP API, SendGrid, etc.).

How it works:

  • Registered as a built-in plugin (same pattern as the dev console email provider), participating in exclusive hook resolution. Explicitly selected plugin transports still take precedence.
  • Configuration via env vars (EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, EMAIL_SMTP_USER, EMAIL_SMTP_PASS, optional EMAIL_SMTP_FROM) or via the new Settings → Email admin UI (password stored encrypted).
  • Supports STARTTLS (port 587) and implicit TLS (port 465). Port 25 is refused with a clear error because Cloudflare blocks it. TLS is always required; plaintext auth is never attempted.
  • Runs on Cloudflare Workers via cloudflare:sockets and on Node via node:net/node:tls, with automatic fallback.

Cloudflare Workers specifics fixed:

  • WritableStream lock: the plaintext socket's writer must be closed before startTls() upgrades the socket, otherwise the first write to the TLS socket throws "WritableStream is currently locked".
  • sock.opened must be awaited before writing — unlike Node, where the connect callback signals readiness.
  • secureTransport: "starttls" is required during the initial connection to later allow sock.startTls().
  • SMTP timeout (25s) must be shorter than the hook timeout (30s) and raced as a rejecting promise — a throw inside setTimeout never reaches the awaiting promise and becomes an unhandled exception.

Admin UI:

  • Provider selection (None / SMTP / Cloudflare Email) with an explicit __none__ sentinel for exclusive hooks.
  • SMTP form with host, port, security, username, password (encrypted), from name/email, reply-to.
  • Test email button that surfaces the underlying SMTP error (e.g. "535 Authentication failed") instead of a bare 500.
  • Cloudflare Email "Test Binding" button that checks the send_email binding.
  • Port change auto-suggests the matching security mode (WP Mail SMTP's autotls pattern).

Closes #1541

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Built-in SMTP email transport for generic SMTP credentials #2157

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Kimi K3 (Moonshot AI)

Screenshots / test output

Unit tests: packages/core/tests/unit/plugins/email-smtp.test.ts — 21 tests covering env config parsing, port-25 refusal, full STARTTLS session flow, AUTH failure, dot-stuffing, the plugin handler contract, and Cloudflare-specific regressions (timeout race, STARTTLS upgrade flow).

Test Files  1 passed (1)
     Tests  21 passed (21)

Live verification on Cloudflare Workers (Brevo SMTP, port 465 implicit TLS): test email delivered successfully.

@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2826cc7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 842 lines across 5 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2156

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2156

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2156

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2156

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2156

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2156

emdash

npm i https://pkg.pr.new/emdash@2156

create-emdash

npm i https://pkg.pr.new/create-emdash@2156

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2156

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2156

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2156

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2156

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2156

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2156

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2156

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2156

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2156

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2156

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2156

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2156

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2156

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2156

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2156

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2156

commit: 2826cc7

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@swissky
swissky force-pushed the feat/smtp-transport branch from 7059d92 to b21e3c5 Compare July 20, 2026 21:34
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 emdash-cms#1541
@swissky
swissky force-pushed the feat/smtp-transport branch from b21e3c5 to e142700 Compare July 20, 2026 21:35
@swissky
swissky marked this pull request as draft July 21, 2026 05:06
Extends the built-in SMTP email provider to be configurable via
Settings → Email in the admin UI, not just environment variables.

- DB-backed SMTP config (encrypted with EMDASH_ENCRYPTION_KEY)
- Provider selection dropdown (None / SMTP / Cloudflare Email)
- PUT /_emdash/api/settings/email endpoint for saving config
- SMTP form with host, port, security, user, password, sender
- Cloudflare Email option (requires astro.config.mjs + wrangler binding)
- Test email button for verifying the full pipeline
- Unit tests for DB config save/load/clear/encrypt

The password is AES-GCM encrypted with the same key used for plugin
secrets. Environment variables remain as a fallback when no DB config
is stored.
@github-actions github-actions Bot added area/admin and removed review/needs-review No maintainer or bot review yet labels Jul 21, 2026
swissky and others added 9 commits July 22, 2026 00:26
- Admin Email Settings: provider selection (none/SMTP/Cloudflare),
  structured sender fields (fromName/fromEmail/replyTo) for both
- Built-in Cloudflare Email provider via send_email binding, always
  registered so it appears in Settings; config from DB or env vars
- Explicit "none" selection persists (__none__ sentinel) so exclusive-hook
  resolution never auto-selects a provider again after restart
- Test Binding button checks the send_email binding in the Worker runtime
- Fix: strip emdash_enc_v1_ prefix before encrypt()/decrypt() for SMTP
  password storage (auth helpers expect raw base64url key material)
…ry (emdash-cms#2102)

* i18n(fr): translate 138 missing keys + consistency around media library

* consistency around media library

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* reword download backup heading

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…-cms#1518) (emdash-cms#1957)

Pages with computed titles/descriptions had to drop down to
getContentSeo and merge panel-vs-default by hand, because getSeoMeta's
only fallbacks were data.title/data.excerpt. The new options rank
between the SEO panel value and the data fallback, so editor-set panel
values still win.
…mdash-cms#1954)

* feat(mcp): add media_upload tool for programmatic media management

Adds a media_upload MCP tool that accepts base64-encoded file data or a
public URL, runs the same pipeline as the REST upload route (global MIME
allowlist, size limit, content-hash dedupe, storage upload, image
metadata enrichment), and returns the media item ready to reference from
content fields.

URL fetches go through ssrfSafeFetch so redirects and private hosts are
rejected. Closes emdash-cms#620, closes emdash-cms#1825.

* fix(mcp): validate MIME string before allowlist and storage upload

The prefix-based allowlist check let a crafted contentType like
'image/png\r\nX-Evil: 1' through to the storage backend's ContentType
header (echoed by the media file route). Validate against the existing
CONTENT_TYPE_RE first, and tighten the MCP input schema (regex on
contentType, .url() on url) so malformed input is rejected before any
bytes are buffered or fetched.

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
…mdash-cms#1079)

handleContentList only special-cased a missing table, so a backing
ec_{collection} table without the deleted_at column threw "no such
column" and fell through to a generic CONTENT_LIST_ERROR, hiding
published content from editors with no diagnostic.

Detect the missing deleted_at column and return
COLLECTION_SCHEMA_MISMATCH naming the collection. isMissingColumnError
mirrors isMissingTableError and takes an optional column scope so an
unrelated unknown-column error still returns the generic error.

Closes emdash-cms#709

Co-authored-by: Matt Kane <mkane@cloudflare.com>
…tries (emdash-cms#2153)

On revision-supporting collections, staging a draft (Save/Autosave) on a
published entry previously stamped updated_at on the live content row even
though no public-facing column changed. Public SEO surfaces that read
updated_at as "content last modified" — sitemap <lastmod>, JSON-LD
dateModified — therefore registered a phantom modification every time an
editor staged or discarded a draft.

Skip the updated_at stamp on column-no-op writes. version still increments
on every update so the _rev optimistic-concurrency token (version:updated_at)
continues to catch concurrent editors with a 409.

Fixes emdash-cms#2143
* Update admin icon vocabulary

* Refine admin icon overrides

* Optimize admin icon lookup
emdashbot Bot and others added 10 commits July 22, 2026 00:26
* feat(plugins): add explicit MCP tool declarations

* fix(plugins): harden MCP request and consent handling

* fix(admin): send marketplace capability consent flag
…ms#2122)

* Expose Astro's trailingSlash config to plugins via ctx.site

Plugins that build absolute URLs (sitemap, canonical, hreflang) need to match the
site's URL convention, but ctx.site only carried name/url/locale, so plugins had to
hardcode a trailing slash. That's wrong for a site configured with
trailingSlash: 'never' -- notably a headless front-end that serves bare URLs, where
the plugin's sitemap/canonical then advertise URLs the site doesn't serve.

Capture astroConfig.trailingSlash at astro:config:setup, carry it on
virtual:emdash/config, and surface it as ctx.site.trailingSlash -- the same path the
i18n config already rides. Optional on SiteInfo; createSiteInfo defaults it to
'ignore' (Astro's default) so existing construction is unaffected.

* Type trailingSlash on the sandbox site payload + add changeset

Review follow-up (emdash-cms#2122): createSiteInfo already flows the full SiteInfo — including
trailingSlash — to sandboxed plugins via the runner options, matching the documented
'same normalized site context used by trusted plugin hooks and routes' parity. Make
that explicit by adding trailingSlash to the sandbox site-shape types (core + the
workerd/cloudflare wrappers) so the wire format is typed, not silently widened. Add
the changeset for the published emdash change.

* changeset: declare @emdash-cms/cloudflare + @emdash-cms/sandbox-workerd

The sandbox runners/wrappers forward trailingSlash into generated worker
code, so both published packages change behavior and must be released
alongside emdash core.

* test: expect trailingSlash in normalized siteInfo

createSiteInfo now always populates trailingSlash (default "ignore"), so the strict
siteInfo expectations must include it: sandbox-runner-options (toEqual x2),
create.test.ts (objectContaining on the sandbox-runner siteInfo), and the
plugin-route-site-info integration test (end-to-end). Verified by running the full
core suite locally (4753 passing).

* style: format

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* test: add initial visual regression baselines

* docs: update visual test gating comment
…h-cms#2158)

* fix(admin): improve editor toolbar visual feedback

* feat(admin): add editor toolbar tooltips

* fix(admin): reflect selected text alignment

* refactor(admin): clarify toolbar tooltip triggers

* test(admin): harden bubble menu style assertions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#2171)

* ci: accept visual baselines via /accept-baselines comment

Replace the reaction-poll apply job with an issue_comment trigger. A
maintainer comments /accept-baselines; the job checks write access via
author association, finds the measure run for the PR head, and commits
the candidate Linux baselines. Drops the schedule poller, the all-PR
scan, the reactions API, and the collaborator-permission lookup, and
posts explicit result comments. Update the sticky comment and docs copy.

* ci: address review on /accept-baselines apply

- Require an exact `/accept-baselines` comment (matches format-command)
  so quoted or suffixed text can't trigger a privileged write
- Grant issues: write for the reaction API, and make the 👀 ack
  best-effort so it can't block the apply
- Select the newest completed measure run and tell the maintainer to
  wait when it's still in progress, instead of a misleading "expired"
- Broaden the failure comment to any post-gate failure
… admin

- deliverSmtp threw inside a setTimeout callback, which never reaches the
  awaiting promise — the delivery kept hanging until the 30s hook timeout
  killed it with no SMTP trace. Race a rejecting timeout promise instead.
- Return smtp.user in the settings API and repopulate the username field
  on reload; add user to SmtpConfigStatus type.
- Surface the underlying delivery error in the test-email endpoint instead
  of a bare 500, and auto-suggest the security mode from the port.
- Add regression tests for the timeout race and the STARTTLS upgrade flow.
@github-actions

Copy link
Copy Markdown
Contributor

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/ar/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ca/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en-GB/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
packages/admin/src/locales/es-419/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/es-ES/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/eu/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fa/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fr/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/hu/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/id/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ja/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ko/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/nb/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/nl/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pl/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pseudo/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pt-BR/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/sr-Latn/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/sv/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/th/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/tr/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/uk/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-TW/messages.po Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot mentioned this pull request Jul 22, 2026
@swissky
swissky marked this pull request as ready for review July 22, 2026 04:30
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 22, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach is sound: a built-in SMTP transport is the right way to give operators generic SMTP credentials on Cloudflare Workers, and it fits the existing exclusive email:deliver hook model alongside the dev console and Cloudflare Email providers. The PR references the approved discussion and adds a proper changeset.

Static review found several real issues, though:

  • Admin UI labels are not localized. Hard-coded English provider names and security-mode labels in EmailSettings.tsx violate the Lingui/i18n convention.
  • SMTP configured via the admin UI does not become active until the runtime restarts. The SMTP built-in is registered only at cold-start when a complete config is already present. The settings route saves the DB record and sets the exclusive selection, but the current pipeline has no SMTP handler, so isAvailable() returns true while send() throws EmailNotConfiguredError.
  • The admin API allows saving port 25. The PR claims port 25 is refused with a clear error, but that refusal only lives in loadSmtpConfigFromEnv(). The Zod schema used by the settings route accepts any port 1–65535.
  • SMTP MIME is not safe for non-ASCII content. Headers (Subject, From, Reply-To) are emitted as raw UTF-8 and the body is declared Content-Transfer-Encoding: 7bit, which will mojibake or be rejected for localized/RTL content.
  • Stale/narrative comments. The SMTP plugin header still says "env-only … no admin UI yet", and tests reference issue numbers in comments.

I did not run the test suite, linter, or builds; the findings below are based on reading the diff, tracing call sites, and checking the repo conventions in AGENTS.md.

Comment on lines +32 to +35
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: string }[] = [
{ value: "none", label: "None" },
{ value: "smtp", label: "SMTP" },
{ value: "cloudflare", label: "Cloudflare Email" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] These select labels are user-facing English strings rendered in the DOM, but they bypass Lingui. Per AGENTS.md every admin UI string must go through @lingui/react/macro / @lingui/core/macro.

Use msg descriptors at module scope and resolve them with t() when rendering the Select items:

Suggested change
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: string }[] = [
{ value: "none", label: "None" },
{ value: "smtp", label: "SMTP" },
{ value: "cloudflare", label: "Cloudflare Email" },
import { msg } from "@lingui/core/macro";
import type { MessageDescriptor } from "@lingui/core";
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: MessageDescriptor }[] = [
{ value: "none", label: msg`None` },
{ value: "smtp", label: msg`SMTP` },
{ value: "cloudflare", label: msg`Cloudflare Email` },
];

Then map with label: t(opt.label) in the <Select items={...}> call on line 263.

Comment on lines +304 to +305
{ value: "starttls", label: "STARTTLS (port 587)" },
{ value: "tls", label: "Implicit TLS (port 465)" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The STARTTLS / implicit TLS option labels are hard-coded English. They are user-visible strings in the security <Select> and must be localized.

Suggested change
{ value: "starttls", label: "STARTTLS (port 587)" },
{ value: "tls", label: "Implicit TLS (port 465)" },
items={[
{ value: "starttls", label: t`STARTTLS (port 587)` },
{ value: "tls", label: t`Implicit TLS (port 465)` },
]}

// Persist and activate SMTP as the selected provider
await optionsRepo.set(optionKey, SMTP_EMAIL_PLUGIN_ID);
emdash.hooks.setExclusiveSelection(EMAIL_DELIVER_HOOK, SMTP_EMAIL_PLUGIN_ID);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This saves the SMTP config to the DB and immediately sets it as the selected exclusive provider, but nothing rebuilds the hook pipeline. EmDashRuntime only registers the built-in SMTP plugin at cold-start when loadSmtpConfig() already returns a complete config (emdash-runtime.ts:1359). If the worker started without SMTP env vars or an existing DB config, the SMTP handler is not in the current pipeline, so emdash.email.isAvailable() will report true while send() later throws EmailNotConfiguredError.

Fix by making the SMTP provider available immediately after admin configuration. The cleanest path is to register the built-in SMTP plugin unconditionally (like Cloudflare Email) and have its handler reject an incomplete config with a clear message, or expose a hook-pipeline rebuild that this route can invoke after persisting a new provider.

const smtpConfigSchema = z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
secure: z.enum(["starttls", "tls"]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The PR description says port 25 is refused with a clear error because Cloudflare blocks it, but this Zod schema accepts any port 1–65535. A user can save port 25 through the admin UI, after which delivery will hang/fail at runtime.

Add a refine so the API rejects port 25 at the same place it rejects other invalid values:

Suggested change
secure: z.enum(["starttls", "tls"]),
const smtpConfigSchema = z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
secure: z.enum(["starttls", "tls"]),
user: z.string().min(1),
pass: z.string().min(1).optional(), // undefined = keep existing password
fromName: z.string().optional(),
fromEmail: z.string().email().optional(),
replyTo: z.string().email().optional(),
}).refine((v) => v.port !== 25, {
message: "Port 25 is not supported on Cloudflare Workers; use 587 (STARTTLS) or 465 (implicit TLS).",
});

Comment thread packages/core/src/plugins/email-smtp.ts Outdated
].join("\r\n");
} else {
headers.push(`Content-Type: text/plain; charset=utf-8`);
headers.push(`Content-Transfer-Encoding: 7bit`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The MIME body is declared Content-Transfer-Encoding: 7bit and Subject / From / Reply-To headers are emitted as raw UTF-8. That violates RFC 5322/2047 and will mojibake or be rejected for any non-ASCII content, including localized site names, RTL text, or sender names.

At minimum, encode non-ASCII headers per RFC 2047 and switch the body transfer encoding to quoted-printable or base64 (or negotiate BODY=8BITMIME and use 8bit). As written, a user-visible <strong>${siteName}</strong> from the test email or any localized subject will be corrupted.

Comment thread packages/core/src/plugins/email-smtp.ts Outdated
Comment on lines +10 to +11
* uses `node:net` / `node:tls`. Configuration is env-only for the first
* iteration — no admin UI yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This header comment is stale: it says the SMTP config is "env-only" and that "no admin UI yet" exists, but this PR adds exactly that admin UI. Update or remove the outdated line so future readers don't believe the feature is CLI/env-only.

Suggested change
* uses `node:net` / `node:tls`. Configuration is env-only for the first
* iteration no admin UI yet.
* via raw TCP the one network primitive sandboxed plugins cannot use.
*
* Registered as a built-in `email:deliver` provider when SMTP is configured,
* either via env vars or via Settings Email in the admin UI.

});
});

describe("Cloudflare socket edge cases (regression #1541)", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] AGENTS.md says comments should never reference issues/PRs; that context belongs in commit messages. The describe block name and the inline // Regression #1541 comment both violate this. Rename the suite to something descriptive (e.g., Cloudflare socket edge cases) and delete the issue-number comment on line 462.

}
});

it("uses 25s timeout so hook timeout (30s) does not swallow the error", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The test name claims to verify that the default 25s timeout is shorter than the 30s hook timeout, but it actually passes timeoutMs: 50 and only asserts the configurable timeout is used. It cannot fail on the real regression it claims to guard against (someone changing the default 25s value). Either drop the test or mock timers and assert the actual default.

Resolve conflicts: revert messages.po to upstream (workflow regenerates
on merge), take upstream AGENTS.md and SKILL.md (both upstream-only
changes).
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed needs-rebase review/needs-review No maintainer or bot review yet labels Jul 22, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 22, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review summary

The approach is still sound: a built-in SMTP transport is the right way to unblock operators with generic SMTP credentials on Cloudflare Workers, and adding Settings → Email UI support fits EmDash’s exclusive-hook email model. However, this revision does not resolve the main blockers from the first review. I checked the diff, the full changed files, cross-references to EmailPipeline/HookPipeline, and the repo conventions in AGENTS.md.

What changed since first review

  • The admin settings route now persists SMTP config to the DB and sets the exclusive provider selection.
  • Cloudflare Email also gained a DB-backed config path.
  • New tests cover DB save/load and Cloudflare Email.

What is still broken / unaddressed

  • Admin UI labels are still hard-coded English (PROVIDER_OPTIONS, security-mode labels, placeholders) — a direct AGENTS.md/Localize everything user-facing violation.
  • Success toasts display raw server English messages ("SMTP configured and activated", etc.) instead of localized text.
  • SMTP configured via the admin UI does not actually deliver email until the runtime restarts. The plugin is only registered at cold-start when a complete config already exists; the settings route can set the exclusive selection, but the hook pipeline has no email:deliver handler for emdash-smtp.
  • Port 25 is still accepted by the admin API. The only refusal is in loadSmtpConfigFromEnv(); the Zod schema and loadSmtpConfigFromDb allow port 25, defeating the documented guarantee.
  • SMTP MIME is not safe for non-ASCII / RTL content. Headers are emitted raw and the body is declared Content-Transfer-Encoding: 7bit.
  • Stale/narrative comments remain. The SMTP module header still says “env-only … no admin UI yet”, and tests reference issue numbers.

I did not run the test suite, linter, or build; the findings below are based on static reading and tracing call sites.

Comment on lines +32 to +35
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: string }[] = [
{ value: "none", label: "None" },
{ value: "smtp", label: "SMTP" },
{ value: "cloudflare", label: "Cloudflare Email" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] Provider and security-mode labels are still hard-coded English: "None", "SMTP", "Cloudflare Email", "STARTTLS (port 587)", "Implicit TLS (port 465)", and all the SMTP form placeholders. AGENTS.md requires every admin UI string to go through Lingui.

Suggested change
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: string }[] = [
{ value: "none", label: "None" },
{ value: "smtp", label: "SMTP" },
{ value: "cloudflare", label: "Cloudflare Email" },
import { msg } from "@lingui/core/macro";
import type { MessageDescriptor } from "@lingui/core";
const PROVIDER_OPTIONS: { value: EmailProviderChoice; label: MessageDescriptor }[] = [
{ value: "none", label: msg`None` },
{ value: "smtp", label: msg`SMTP` },
{ value: "cloudflare", label: msg`Cloudflare Email` },
];
// inside the component, render with t(opt.label)

}, [settings]);

const saveMutation = useMutation({
mutationFn: saveEmailSettings,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The save-success toast shows result.message directly. The API returns English strings like "SMTP configured and activated", so non-English admins will see untranslated text. The same pattern is used for testMutation and bindingMutation success toasts.

Suggested change
mutationFn: saveEmailSettings,
onSuccess: () => {
toastManager.add({ title: t`Email settings saved`, variant: "success", timeout: 5000 });
setSmtpPass(""); // clear password field after save
void queryClient.invalidateQueries({ queryKey: ["email-settings"] });
},

If the message needs to vary by provider, return a stable code from the API and map it to a localized string on the client.

Comment thread packages/core/src/emdash-runtime.ts Outdated
// WP Mail SMTP's is_mailer_complete(): a half-saved config (host but no
// password) must not register a provider — the resulting 535 on send is
// more confusing than a clean "not configured" state.
if (isSmtpConfigComplete(smtpConfig)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The SMTP built-in is registered only when isSmtpConfigComplete(loadSmtpConfig(...)) is true at cold start. The settings route (packages/core/src/astro/routes/api/settings/email.ts:337) can save DB config and call emdash.hooks.setExclusiveSelection(EMAIL_DELIVER_HOOK, SMTP_EMAIL_PLUGIN_ID), but if the plugin was never registered (because no SMTP config existed at startup), the pipeline has no email:deliver handler for it. EmailPipeline.isAvailable() will return true while send() throws EmailNotConfiguredError.

The Cloudflare Email built-in is always registered (emdash-runtime.ts, just below this block); SMTP should be registered the same way, with its handler loading the latest DB config on demand and failing cleanly when the config is incomplete.


const smtpConfigSchema = z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The PR states that port 25 is refused, but the admin API schema accepts any port from 1–65535. The only port-25 refusal is in loadSmtpConfigFromEnv(); a config saved through the admin UI will bypass that check both at validation time and in loadSmtpConfigFromDb().

Suggested change
port: z.number().int().min(1).max(65535),
const smtpConfigSchema = z.object({
host: z.string().min(1),
port: z.number()
.int()
.min(1)
.max(65535)
.refine((p) => p !== 25, {
message: "Port 25 is not supported; use 587 (STARTTLS) or 465 (implicit TLS).",
}),
secure: z.enum(["starttls", "tls"]),
user: z.string().min(1),
pass: z.string().min(1).optional(),
fromName: z.string().optional(),
fromEmail: z.string().email().optional(),
replyTo: z.string().email().optional(),
});

The same refusal should also be added to loadSmtpConfigFromDb() and to the client-side handleSave port check.

Comment thread packages/core/src/plugins/email-smtp.ts Outdated
Comment on lines +229 to +245
`Subject: ${sanitizeHeader(subject)}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <${crypto.randomUUID()}@emdash>`,
`MIME-Version: 1.0`,
];
if (replyTo) {
headers.push(`Reply-To: ${sanitizeHeader(replyTo)}`);
}

let body: string;
if (html) {
const boundary = `----=_emdash_${crypto.randomUUID()}`;
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
body = [
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: 7bit`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] buildMime emits raw UTF-8 in Subject, From, and Reply-To headers and declares Content-Transfer-Encoding: 7bit for both plain-text and HTML parts. For localized or RTL content this will mojibake or be rejected by strict SMTP servers. Headers should be RFC 2047 encoded and the transfer encoding should be base64/quoted-printable when non-ASCII bytes are present.

Comment thread packages/core/src/plugins/email-smtp.ts Outdated
* Registered as a built-in `email:deliver` provider when SMTP env vars are
* present. On Cloudflare Workers it uses `cloudflare:sockets`; on Node it
* uses `node:net` / `node:tls`. Configuration is env-only for the first
* iteration — no admin UI yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The module header is stale: it says the config is "env-only … no admin UI yet", but this PR adds exactly that. There are also issue-number references in the new test comments (packages/core/tests/unit/plugins/email-smtp.test.ts:453, :462). AGENTS.md prohibits issue/PR references in comments and narrative/stale documentation.

Suggested change
* iteration no admin UI yet.
* Registered as a built-in `email:deliver` provider when SMTP is configured
* via environment variables or the Settings Email admin UI.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 22, 2026
- Localize provider names and security-mode labels in EmailSettings
- Register SMTP provider unconditionally; load config from DB on send
  so admin-saved settings take effect without restart
- Refuse port 25 in the admin API schema, matching the env loader
- Encode MIME headers per RFC 2047 and bodies as base64 for UTF-8 safety
- Apply dot-stuffing to raw text before base64 encoding
- Remove stale comments and issue references from tests
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 22, 2026
CI (Node 22) flags the inline /[^\x20-\x7E]/ regex in encodeHeader as a
performance issue — it recompiles on every call. Local lint (Node 24)
misses it because the type-aware analysis behaves differently across
Node versions.

Also add scripts/pre-push-check.sh that documents the Node 22 vs 24
limitation and runs the checks that work locally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: built-in SMTP email transport in core

10 participants