Skip to content

SMTP proxy sends raw RFC 5322 recipients, which the public API schema rejects (invalid_string on "to") #439

Description

@gabrieldasf

Every message relayed through usesend/smtp-proxy whose To header carries a display name is rejected by useSend own public API, so it never leaves the proxy.

Cause

apps/smtp-server/src/server.ts (current main, bd83535) forwards the address headers in their raw RFC 5322 form:

to: Array.isArray(parsed.to)
  ? parsed.to.map((addr) => addr.text).join(", ")
  : parsed.to?.text,

parsed.to.text is "Gabriel Ferreira" <gabriel@example.com>, but apps/web/src/server/public-api/schemas/email-schema.ts validates recipients as bare addresses:

const emailAddress = z.string().email().max(320);
const recipients = emailAddress.or(z.array(emailAddress).min(1).max(50));
// to: recipients

from is unaffected because its schema is z.string().min(3).max(512), free-form. That asymmetry is why the error names only to.

The .join(", ") branch is broken for the same reason: a comma-joined string is not a single valid address either, so two plain recipients also fail. Same applies to cc/bcc on main.

Reproduction

Point any SMTP client that sets a display name (Documenso, Plane CE, Penpot, most mail clients) at the proxy and send one message. Proxy log:

Sending email to useSend API at: http://usesend:3000/api/v1/emails
useSend API error response: error: "{\"success\":false,\"error\":{\"issues\":[{\"validation\":\"email\",\"code\":\"invalid_string\",\"message\":\"Invalid email\",\"path\":[\"to\"]}]}}"
email data: {"to":"\"Gabriel da Silva Ferreira\" <gabriel@quartzo.ai>","from":"\"Quartzo Docs\" <noreply@quartzo.ai>", ...}

The SMTP transaction then fails, so the sending app sees a delivery error and no Email row is ever created.

Suggested fix

Use mailparser .value (the parsed { name, address } list) instead of .text, and send an array — the schema already accepts z.array(emailAddress):

function toAddressList(field): string[] | undefined {
  if (!field) return undefined;
  const groups = Array.isArray(field) ? field : [field];
  const addresses = groups
    .flatMap((g) => g?.value ?? [])
    .map((e) => e?.address)
    .filter(Boolean);
  return addresses.length > 0 ? addresses : undefined;
}

Applied to to, cc, bcc and replyTo; from should keep .text so the display name survives.

Happy to send a PR if that shape looks right. We are running this patch in a fork today and mail flows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions