Skip to content

Feature request: built-in SMTP email transport in core #1541

Description

@marcusbellamyshaw-cell

Feature request: built-in SMTP email transport in Emdash core

Summary

Provide a first-class SMTP email transport in Emdash core so operators can
send mail through any standard SMTP server (host / port / username / password)
instead of only through HTTP transactional APIs. A plugin cannot do this, by
design — only the host runtime can open the TCP socket SMTP requires.

Motivation

Today, sending email from Emdash on Cloudflare Workers means using a provider
with an HTTP API (e.g. the emdash-plugin-brevo plugin, which calls
POST https://api.brevo.com/v3/smtp/email). That works well, but a large class
of operators only have generic SMTP credentials:

  • Brevo's own SMTP relay (smtp-relay.brevo.com:587, authenticated with an
    xsmtpsib- SMTP key) — distinct from its HTTP API key (xkeysib-).
  • Office365 / Google Workspace / Fastmail / Amazon SES SMTP endpoints.
  • Self-hosted Postfix and corporate mail relays.

None of these can be reached from a plugin.

Why this can't be a plugin

Emdash sandboxed plugins are given exactly one network primitive —
ctx.http.fetch() — which speaks HTTP/HTTPS only and is gated by the
plugin's allowedHosts allowlist. There is no TCP/socket capability in the
plugin capability vocabulary. SMTP is a raw TCP protocol (EHLO / STARTTLS /
AUTH / MAIL FROM / RCPT TO / DATA), so a plugin physically cannot open an SMTP
connection.

Cloudflare Workers can open TCP sockets via connect() from
cloudflare:sockets, but only host code can — that module is not exposed to
the sandbox. Therefore an SMTP transport has to live in core (or in a
host-trusted/native extension point), not in a sandboxed plugin.

Proposal

Add an optional core email transport, smtp, that registers as the email
delivery provider so existing ctx.email.send() callers and the
email:deliver hook contract keep working unchanged.

Configuration

Via env/wrangler vars (secrets) and/or the admin Email settings page:

Key Example Notes
EMAIL_SMTP_HOST smtp-relay.brevo.com
EMAIL_SMTP_PORT 587 587 (STARTTLS) or 465 (implicit TLS)
EMAIL_SMTP_SECURE starttls | tls
EMAIL_SMTP_USER you@example.com
EMAIL_SMTP_PASS xsmtpsib-… stored as a secret
EMAIL_SMTP_FROM Site <noreply@example.com> default sender

Implementation sketch

  • Use connect(\${host}:${port}`, { secureTransport: port === 465 ? "on" : "starttls", allowHalfOpen: false })fromcloudflare:sockets`.
  • Minimal SMTP client: read greeting → EHLO → (STARTTLS + socket.startTls() when secure=starttls) → AUTH LOGIN/PLAINMAIL FROMRCPT TODATA → dot-stuffed MIME body → QUIT.
  • Build a compliant MIME message (headers, text/plain + text/html multipart/alternative, quoted-printable/base64, message-id, date).
  • Enforce a connect/overall timeout; surface SMTP reply codes as structured errors.

Caveats to call out in the implementation

  • Cloudflare blocks outbound port 25; this transport targets submission
    ports 587/465 only, and should validate/refuse 25 with a clear message.
  • TLS must be required (STARTTLS upgrade verified, or implicit TLS on 465);
    never fall back to plaintext auth on an unencrypted channel.
  • Some hosts (corporate relays) may not be reachable from Cloudflare egress IPs;
    document this alongside the existing dynamic-egress-IP guidance.

Alternatives considered

  1. HTTP→SMTP gateway service — works from a plugin (it's just HTTP), but adds
    a third party in the mail path and means handing SMTP credentials to that
    service. Rejected as a default.
  2. Per-plugin SMTP — impossible (no TCP capability in the sandbox), which is
    the reason for this request.
  3. Status quo (HTTP transactional APIs only) — fine for Brevo/SendGrid/etc.
    API users, but excludes everyone who only has SMTP credentials.

Backward compatibility

Additive. Existing HTTP-API providers (including the Brevo plugin) continue to
work; smtp is just another selectable transport. If both a core SMTP transport
and an exclusive email:deliver plugin are configured, document the precedence
(suggest: an explicitly-configured plugin transport wins, else core SMTP).

Acceptance criteria

  • ctx.email.send() delivers via SMTP when the smtp transport is configured.
  • STARTTLS (587) and implicit TLS (465) both supported; port 25 refused.
  • AUTH LOGIN and PLAIN supported; credentials stored as secrets, never logged.
  • Connect + overall timeouts; SMTP error codes surfaced to callers.
  • Docs: setup guide + the Cloudflare port-25 / egress-IP caveats.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions