Skip to content

feat(core): cc and replyTo on plugin email messages - #3333

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:feat/plugin-email-cc-reply-to
Sep 24, 2026
Merged

ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:feat/plugin-email-cc-reply-to

Conversation

@swissky

@swissky swissky commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Closes #2611

Adds optional cc and replyTo fields to the plugin EmailMessage, so a plugin can send one message to several visible recipients and direct replies to someone other than the sender. The common case is a form or notification plugin that mails a team and wants a reply to go straight to the visitor who submitted the form. Today a plugin has to send one message per recipient and cannot set a reply address at all; only the Cloudflare provider's site-wide replyTo option exists.

  • ctx.email.send() validates the new fields (array of strings / string) alongside the existing to/subject/text checks.
  • Both fields travel unchanged through email:beforeSend, email:deliver, and email:afterSend, and through the Cloudflare and workerd sandbox bridges.
  • The Cloudflare email provider passes cc to the send_email binding. A message's replyTo overrides the provider's configured replyTo.
  • The development console provider prints Cc: and Reply-To: lines.
  • hooks.mdx documents the fields on the event shape and tells provider authors to deliver them. The plugin skill's Resend example now forwards them.

Both fields are optional. Existing plugins and providers keep working unchanged; a third-party provider that ignores cc simply doesn't deliver to those recipients until it is updated.

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 and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
  • I have included screenshots below if this PR changes the UI

No admin UI strings or UI changes. No Discussion: small additive extension of the existing email API, opened directly as a maintainer-team member; happy to move it to a Discussion if preferred.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Opus 5.5

Screenshots / test output

Not applicable.

Targeted tests: packages/core email pipeline (40 passed; new case checks delivery of cc/replyTo and rejection of malformed values), packages/cloudflare email provider (12 passed; new case checks cc passthrough and replyTo precedence), packages/workerd bridge handler (76 passed; new case checks the sandbox bridge forwards both fields).

@changeset-bot

changeset-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 42b49a5

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

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Minor
@emdash-cms/plugin-test Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 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

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 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.

This is a small, additive extension of the existing email hook/capability model and looks like the right change: plugins can now set cc and replyTo on an email, the fields flow through EmailMessage (and therefore email:beforeSend, email:deliver, and email:afterSend), and both built-in providers pass them on. The changeset package list and bump types are correct, docs are updated, and the public type surface is consistent across core, cloudflare, and workerd.

I reviewed the diff statically, traced the call sites for the email pipeline and both providers, and checked the workerd bridge validation, Cloudflare binding shape, and updated docs. Two things stand out:

  1. The workerd bridge's isEmailMessage type guard was not updated to match the now-expanded EmailMessage type, so malformed cc/replyTo values can cross the RPC boundary before EmailPipeline rejects them.
  2. The Cloudflare provider uses || to pick the message-level replyTo, which means an empty-string replyTo falls back to the provider's configured value rather than overriding it as documented.

Neither is a security or data-loss issue, but the first is a real validation gap at a boundary and should be fixed before merge.


Findings

  • [needs fixing] packages/workerd/src/sandbox/bridge-handler.ts:999-1005

    The local EmailMessage type now includes cc and replyTo, but isEmailMessage only checks to, subject, text, and html. A malformed value such as cc: ["a", 1] or replyTo: 42 will pass this guard, be typed as a valid EmailMessage, and reach emailSend before EmailPipeline eventually throws. The bridge already has isStringArray, so the guard should use it to keep the runtime shape and the narrowed type in sync.

    function isEmailMessage(value: unknown): value is EmailMessage {
    	if (!isRecord(value)) return false;
    	if (typeof value.to !== "string") return false;
    	if (typeof value.subject !== "string") return false;
    	if (typeof value.text !== "string") return false;
    	if (value.html !== undefined && typeof value.html !== "string") return false;
    	if (value.cc !== undefined && !isStringArray(value.cc)) return false;
    	if (value.replyTo !== undefined && typeof value.replyTo !== "string") return false;
    	return true;
    }
    
  • [suggestion] packages/cloudflare/src/plugins/cloudflare-email.ts:132

    Using || to resolve the message-level replyTo means an empty-string replyTo falls back to config.replyTo, which contradicts the doc update that says a plugin-set replyTo overrides the provider option. Prefer nullish coalescing so any value set on the message (including an empty string, which is then omitted below) takes precedence.

    		const replyTo = message.replyTo ?? config.replyTo;
    

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed bot:review Trigger an emdashbot code review on this PR labels Sep 23, 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.

This is a sensible, backwards-compatible extension of the plugin email API. Adding optional cc and replyTo to EmailMessage, validating them at the EmailPipeline boundary, threading them through email:beforeSend / email:deliver / email:afterSend, and updating both the Cloudflare and workerd sandbox bridges fits EmDash's architecture. The docs, skill examples, changeset, and provider-specific behavior are all aligned.

I checked the diff, the core email pipeline, the Cloudflare email provider, the workerd and Cloudflare sandbox bridges, the public docs, the changeset, and the new tests. Everything looks correct except one real inconsistency in the workerd sandbox bridge: the local EmailMessage type was expanded, but its runtime narrowing predicate (isEmailMessage) was not, so malformed cc/replyTo values can cross the RPC boundary before the pipeline rejects them.

Other minor things are fine: no new queries on logged-out routes, no SQL or auth changes, no UI/i18n impact, and the tests cover the key behaviors. The PR is close to clean once the bridge guard is tightened.


Findings

  • [needs fixing] packages/workerd/src/sandbox/bridge-handler.ts:999-1005

    The EmailMessage type now carries optional cc and replyTo, but isEmailMessage only checks to, subject, text, and html. It will return true for a value like { to: "a@b.com", subject: "s", text: "t", cc: "not-an-array" }, falsely claiming it is an EmailMessage. That lets invalid sandbox RPC payloads leak past the bridge's input contract and get rejected later by EmailPipeline.send with a less specific error.

    Validate the new optional fields in the predicate so it matches the type:

    function isEmailMessage(value: unknown): value is EmailMessage {
    	if (!isRecord(value)) return false;
    	if (typeof value.to !== "string") return false;
    	if (typeof value.subject !== "string") return false;
    	if (typeof value.text !== "string") return false;
    	if (value.html !== undefined && typeof value.html !== "string") return false;
    	if (value.cc !== undefined && !isStringArray(value.cc)) return false;
    	if (value.replyTo !== undefined && typeof value.replyTo !== "string") return false;
    	return true;
    }
    

    isStringArray is already defined at line 969. Consider also adding a bridge-level test in packages/workerd/test/bridge-handler.test.ts that asserts email/send rejects malformed cc or replyTo at the bridge boundary, mirroring the existing Missing capability test case.

@pkg-pr-new

pkg-pr-new Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3333

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3333

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3333

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 42b49a5

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR touches 24 files. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.
This PR spans 4 different areas (area/core, area/docs, area/templates, area/cloudflare). Consider breaking it into smaller, focused PRs.

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.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 23, 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.

This is a sensible, backwards-compatible extension of the plugin email API. The approach fits EmDash: new optional fields on EmailMessage, validation at the pipeline boundary, threading through the email hooks, and delivery/bridge updates for both Cloudflare and workerd.

The earlier workerd bridge-predicate issue is now fixed: isEmailMessage validates cc and replyTo, and packages/workerd/test/bridge-handler.test.ts adds a bridge-level test that rejects malformed values. I also re-checked the core email pipeline, Cloudflare provider, dev console provider, both sandbox bridges, the docs, and the tests — they all look correct.

The one remaining problem is a missing changeset entry for the published workerd package. packages/workerd/src/sandbox/bridge-handler.ts is part of @emdash-cms/sandbox-workerd, which has its own version and CHANGELOG and is not in the changeset fixed package list that rides along with emdash/@emdash-cms/cloudflare. AGENTS.md requires a changeset for any change to a published package's behavior or API, so that package should be listed too.

No logged-out route changes, no SQL/auth/i18n impact, and no new comments or drive-by refactors to flag.

Comment on lines +2 to +3
"emdash": minor
"@emdash-cms/cloudflare": minor

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 changes packages/workerd/src/sandbox/bridge-handler.ts to accept, validate, and forward cc/replyTo across the workerd sandbox RPC boundary. That is a behavior change to the published @emdash-cms/sandbox-workerd package, which has its own version and CHANGELOG and is not in the changeset fixed package list. AGENTS.md requires a changeset for any change to a published package's behavior or API.

Suggested change
"emdash": minor
"@emdash-cms/cloudflare": minor
---
"emdash": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/sandbox-workerd": minor
---

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed bot:review Trigger an emdashbot code review on this PR review/needs-rereview New commits since the last review labels Sep 23, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 23, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 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.

This re-review confirms the one remaining issue from the prior review is fixed: the changeset at .changeset/plugin-email-cc-reply-to.md now lists @emdash-cms/sandbox-workerd alongside emdash and @emdash-cms/cloudflare, capturing the behavior change to the workerd sandbox bridge.

The approach remains sound: adding optional cc and replyTo to EmailMessage, validating them at the pipeline entry, threading them through the email hooks, and forwarding them through both the Cloudflare and workerd sandbox bridges plus the Cloudflare email provider and dev console provider. It is backwards-compatible because both fields are optional.

I re-checked:

  • Email pipeline validation in packages/core/src/plugins/email.ts — rejects non-array cc and non-string replyTo.
  • Type definitions in packages/core/src/plugins/types.ts, packages/core/src/plugins/sandbox/types.ts, packages/cloudflare/src/sandbox/types.ts, and packages/workerd/src/sandbox/bridge-handler.ts — all consistent.
  • Cloudflare email provider delivery and replyTo precedence logic — message-level replyTo overrides the provider config.
  • workerd bridge predicate validation — isEmailMessage correctly validates cc and replyTo.
  • Dev console output, hooks docs, deployment docs, skill reference docs, and template copies — all updated and consistent.
  • Tests added/updated in core, cloudflare, and workerd packages — they exercise the new fields and malformed-value rejections.

No remaining logic bugs, regressions, AGENTS.md violations, or documentation inaccuracies were found.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@emdashbot emdashbot Bot added review/approved Approved; no new commits since and removed review/needs-rereview New commits since the last review labels Sep 23, 2026
@swissky swissky added this to the 1.0 milestone Sep 23, 2026
@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.

@ascorbic
ascorbic merged commit 26f2076 into emdash-cms:main Sep 24, 2026
52 checks passed
@emdashbot emdashbot Bot mentioned this pull request Sep 24, 2026
danielmlr pushed a commit that referenced this pull request Oct 21, 2026
…er (#4144)

* fix(plugin-forms): set Reply-To on notification emails to the submitter

Form notifications are sent from the site's sender address with no
Reply-To, so replying to one goes to that address (often a noreply@)
instead of the person who filled in the form. A help desk that builds
a conversation from the email (Chatwoot, for example) also files it
under the sender address, so every submission lands on one contact.

#3333 added replyTo to plugin email messages for exactly this case
(#2611 describes a contact-form notification); the forms plugin didn't
use it yet. Notifications now set replyTo to the address from the
form's email field, the one the autoresponder already sends to. The
value has passed the email field's validation (no whitespace), so it
can't inject headers. Without an email field, or with it left empty,
notifications are unchanged.

The submitter's address is now looked up once for both the
notifications and the autoresponder; the autoresponder's behavior is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-forms): only set Reply-To for a plain email address

The email field's own check accepts values like "IT-Support"<someone@example.com>
or jane@example.com,. As a Reply-To, they can show the team a made-up name or get
the notification rejected. Check the address with z.email() first, as the
notification addresses are; the autoresponder keeps the field's check.

The changeset now says the daily digest is unchanged, and that replies go to the
visitor instead of the Cloudflare email provider's configured replyTo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

EmailMessage has no replyTo field — plugins can't set a reply-to for visitor-submitted email

2 participants