Skip to content

fix(smtp): preserve Reply-To when relaying to /v1/send - #454

Merged
driaug merged 1 commit into
useplunk:nextfrom
emanuelefaja:fix/smtp-relay-preserve-reply-to
Aug 10, 2026
Merged

fix(smtp): preserve Reply-To when relaying to /v1/send#454
driaug merged 1 commit into
useplunk:nextfrom
emanuelefaja:fix/smtp-relay-preserve-reply-to

Conversation

@emanuelefaja

Copy link
Copy Markdown
Contributor

Problem

The SMTP relay parses each incoming message and re-sends it through POST /v1/send. reply-to is listed in standardHeaders, so it is correctly excluded from customHeaders — but nothing puts it back. The request body has no reply field:

body: JSON.stringify({
  from: from,
  to: recipients,
  subject: parsed.subject,
  body: bodyContent,
  headers: ...,
  attachments: attachments,
})

The header is parsed and then silently dropped. Anyone relaying transactional mail through the SMTP server loses Reply-To, and replies go to the From address instead.

Everything downstream already supports it, so this is only a gap in the relay:

  • packages/shared — the send schema has reply: z.ZodOptional<z.ZodString>
  • apps/api/src/controllers/v1/Actions.ts — destructures reply and passes it as replyTo
  • apps/api/src/services/EmailService.ts — forwards reply: email.replyTo
  • apps/api/src/services/SESService.ts — writes Reply-To: ${reply || from.email} into the raw MIME

Evidence

On a self-hosted install relaying ~57k transactional emails a month through the SMTP server, not one has ever carried a Reply-To:

sourceType     |   total | with_replyto
TRANSACTIONAL  |  57,769 |            0     ← via the SMTP relay
CAMPAIGN       | 317,038 |      317,038     ← via the API directly

Campaigns, which call the API directly, set it every time. Only the relay path loses it.

Fix

Read parsed.replyTo the same way the surrounding code already reads parsed.from, and pass it through:

const replyTo = parsed.replyTo?.value[0]?.address;
...
  reply: replyTo,

reply is optional in the schema, so messages without a Reply-To header are unaffected — undefined is simply omitted from the JSON body.

Notes

  • Only the first address is taken. Reply-To permits a list, but the API's reply field is a single string, so a list would need a schema change. Taking the first address matches how from is handled a few lines above.
  • No behaviour change for any message that does not set Reply-To.

The relay parses the incoming message and re-sends it through the API.
'reply-to' is listed in standardHeaders, so it is excluded from
customHeaders, but nothing puts it back — the request body has no reply
field. The header is parsed and then silently dropped.

Everything downstream already supports it: /v1/send accepts reply,
EmailService passes it through as replyTo, and SESService writes
'Reply-To: ${reply || from.email}'. Only the relay never populates it.

Read parsed.replyTo the same way the existing code reads parsed.from,
and pass it as the API's reply field.
@driaug
driaug merged commit ce3bcf0 into useplunk:next Aug 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants