From bc370d1a907210476a8936835f3419e6721d7e5c Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 20:26:09 +0530 Subject: [PATCH] Let a transactional send set its own sender name POST /emails and the send_email MCP tool take an optional fromName, the From name for that email instead of the delivery source's. Only the name changes: the address stays the delivery source's verified one. So the name can't break the From header or pass for an address, it is refused when it holds control characters, invisible formatting characters (such as right-to-left overrides and zero-width spaces), <, > or @. A name with RFC 5322 specials such as a comma is now quoted in the From header, which also fixes such names on teams. --- .changeset/transactional-from-name.md | 5 ++ apps/api/openapi.json | 4 ++ apps/api/src/mcp/tools/transactional.ts | 6 +++ apps/api/src/transactional/queries.test.ts | 46 +++++++++++++++++++ apps/api/src/transactional/queries.ts | 11 ++++- apps/api/src/transactional/routes.ts | 10 ++++ apps/api/src/utils/mail.test.ts | 12 +++++ apps/api/src/utils/mail.ts | 8 +++- .../content/docs/developers/transactional.mdx | 23 ++++++++++ .../api-contract/src/schemas/transactional.ts | 29 ++++++++++++ 10 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 .changeset/transactional-from-name.md diff --git a/.changeset/transactional-from-name.md b/.changeset/transactional-from-name.md new file mode 100644 index 00000000..4f2d8c48 --- /dev/null +++ b/.changeset/transactional-from-name.md @@ -0,0 +1,5 @@ +--- +"sendlit": minor +--- + +`emails.send` takes an optional `fromName`, the From name for that email instead of the delivery source's. The address stays the delivery source's verified one. diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 24eecaff..b1a36db4 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -15908,6 +15908,10 @@ "nullable": true } }, + "fromName": { + "description": "The From name for this email, instead of the delivery source's. The address stays the delivery source's.", + "type": "string" + }, "replyTo": { "type": "string", "format": "email" diff --git a/apps/api/src/mcp/tools/transactional.ts b/apps/api/src/mcp/tools/transactional.ts index a53f4fa6..d68863e1 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -1,5 +1,6 @@ import { emailHeadersSchema, + senderNameSchema, transactionalEmailDetailSchema, transactionalEmailSchema, transactionalEmailStatus, @@ -36,6 +37,10 @@ const SEND_ERRORS: Record[0], string]> = { "validation_failed", "Header names/values must not contain CR/LF; From, To, Subject and Content-Type are set by the send pipeline", ], + invalid_from_name: [ + "validation_failed", + "fromName must be 1-100 characters, without control or invisible formatting characters, <, > or @", + ], template_not_transactional: [ "validation_failed", "The template is a marketing template; send_email needs a transactional one.", @@ -90,6 +95,7 @@ export const transactionalTools = (): SendLitMcpTool[] => [ .describe( "Liquid merge variables; requires `templateId`. Provide every unguarded merge tag; use `{% if %}` or `default` in the template for optional values.", ), + fromName: senderNameSchema.optional(), replyTo: z.string().email().optional(), headers: emailHeadersSchema .optional() diff --git a/apps/api/src/transactional/queries.test.ts b/apps/api/src/transactional/queries.test.ts index 076832fc..227fb896 100644 --- a/apps/api/src/transactional/queries.test.ts +++ b/apps/api/src/transactional/queries.test.ts @@ -473,6 +473,52 @@ describe("createTransactionalEmail happy paths", () => { expect(row.html).toBe("

Hello {{ not_a_liquid_tag }}

"); }); + it("uses the caller's sender name with the pinned address", async () => { + const { team } = await seedTeamAndContact(tdb); + const pinned = await createTransactionalEmail({ + teamId: team.id, + to: "buyer@example.com", + subject: "Your receipt", + html: "

hi

", + }); + const address = pinned.fromEmail!.match(/<([^>]+)>$/)![1]; + + const row = await createTransactionalEmail({ + teamId: team.id, + to: "buyer@example.com", + subject: "Your receipt", + html: "

hi

", + fromName: " Yoga, with Nalini ", + }); + + expect(row.fromEmail).toBe(`"Yoga, with Nalini" <${address}>`); + }); + + it("rejects a sender name that could break or fake the From header", async () => { + const { team } = await seedTeamAndContact(tdb); + for (const fromName of [ + "", + " ", + "Evil\r\nBcc: x@example.com", + "PayPal ", + "support@paypal.com", + "Shop \u202Emoc.lapyap", + "Pay\u200BPal", + "x".repeat(101), + ]) { + await expect( + createTransactionalEmail({ + teamId: team.id, + to: "a@example.com", + subject: "Hi", + html: "

hi

", + fromName, + }), + ).rejects.toThrow("invalid_from_name"); + } + expect(queue.addTransactionalMailJob).not.toHaveBeenCalled(); + }); + it("accepts transactional sends without a workspace mailing address", async () => { const { team } = await seedTeamAndContact(tdb, { settings: { mailingAddress: null }, diff --git a/apps/api/src/transactional/queries.ts b/apps/api/src/transactional/queries.ts index adfd6f18..e7f0b48b 100644 --- a/apps/api/src/transactional/queries.ts +++ b/apps/api/src/transactional/queries.ts @@ -1,3 +1,4 @@ +import { isValidSenderName } from "@sendlit/api-contract"; import type { Email as EmailContent } from "@sendlit/email-editor"; import { and, count, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; import { reserveSend } from "../billing/entitlements"; @@ -184,7 +185,7 @@ export async function countTransactionalEmails( * never goes through that schema). * * Throws a plain `Error` whose `message` is one of: `invalid_content`, - * `invalid_headers`, `marketing_variables_not_allowed`, + * `invalid_headers`, `invalid_from_name`, `marketing_variables_not_allowed`, * `template_not_found`, `render_failed`, * `esp_not_configured`, `esp_not_found`, `recipient_suppressed` — callers * map these to the PRD's `400`/`422` responses (see @@ -199,6 +200,7 @@ export async function createTransactionalEmail({ html, text, variables = {}, + fromName, replyTo, headers, idempotencyKey, @@ -214,6 +216,8 @@ export async function createTransactionalEmail({ /** Plain-text part, sent verbatim; generated from the HTML when omitted. */ text?: string; variables?: Record; + /** The From name for this send only; the address stays the pinned one. */ + fromName?: string; replyTo?: string; headers?: Record; idempotencyKey?: string; @@ -245,6 +249,9 @@ export async function createTransactionalEmail({ // names are owned by the pipeline (sender identity is resolved // server-side — see the PRD's validation notes). assertValidHeaders(headers); + if (fromName !== undefined && !isValidSenderName(fromName)) { + throw new Error("invalid_from_name"); + } if (idempotencyKey) { const existing = await findTransactionalEmailByIdempotencyKey( @@ -321,7 +328,7 @@ export async function createTransactionalEmail({ } const from = getEmailFrom({ - name: pin.fromName, + name: fromName?.trim() ?? pin.fromName, email: pin.fromEmail, }); diff --git a/apps/api/src/transactional/routes.ts b/apps/api/src/transactional/routes.ts index e99f0e33..f3eecb1f 100644 --- a/apps/api/src/transactional/routes.ts +++ b/apps/api/src/transactional/routes.ts @@ -75,6 +75,7 @@ const impl = s.router(contract.transactional, { html: body.html, text: body.text, variables: body.variables, + fromName: body.fromName, replyTo: body.replyTo, headers: body.headers, idempotencyKey: body.idempotencyKey, @@ -122,6 +123,15 @@ const impl = s.router(contract.transactional, { "by the send pipeline", }, }; + case "invalid_from_name": + return { + status: 400, + body: { + error: + "fromName must be 1-100 characters, without control or " + + "invisible formatting characters, <, > or @", + }, + }; case "template_not_found": return { status: 400, diff --git a/apps/api/src/utils/mail.test.ts b/apps/api/src/utils/mail.test.ts index 36a6d57b..5e47416b 100644 --- a/apps/api/src/utils/mail.test.ts +++ b/apps/api/src/utils/mail.test.ts @@ -18,6 +18,18 @@ describe("mail utils", () => { ); }); + it("quotes a display name that holds RFC 5322 specials", () => { + expect(getEmailFrom({ name: "Smith, J. & Co", email: "a@example.com" })).toBe( + '"Smith, J. & Co" ', + ); + expect(getEmailFrom({ name: 'The "Best" School', email: "a@example.com" })).toBe( + '"The \\"Best\\" School" ', + ); + expect(getEmailFrom({ name: "Yoga & Co", email: "a@example.com" })).toBe( + "Yoga & Co ", + ); + }); + it("builds site and unsubscribe URLs from env", () => { process.env.PROTOCOL = "https"; process.env.DOMAIN = "app.sendlit.test"; diff --git a/apps/api/src/utils/mail.ts b/apps/api/src/utils/mail.ts index 8a4c7e66..6df6c201 100644 --- a/apps/api/src/utils/mail.ts +++ b/apps/api/src/utils/mail.ts @@ -1,5 +1,11 @@ +/** RFC 5322 specials: a display name holding any of them must be quoted. */ +const DISPLAY_NAME_SPECIALS = /[()<>[\]:;@\\,."]/; + export function getEmailFrom({ name, email }: { name: string; email: string }) { - return `${name} <${email}>`; + const display = DISPLAY_NAME_SPECIALS.test(name) + ? `"${name.replace(/[\\"]/g, "\\$&")}"` + : name; + return `${display} <${email}>`; } export function getSiteUrl(): string { diff --git a/apps/docs/content/docs/developers/transactional.mdx b/apps/docs/content/docs/developers/transactional.mdx index 224faa8e..35bf42ab 100644 --- a/apps/docs/content/docs/developers/transactional.mdx +++ b/apps/docs/content/docs/developers/transactional.mdx @@ -101,6 +101,29 @@ SendLit does not run Liquid over it, and `variables` cannot be supplied with it. This prevents legitimate `{{` or `{%` content in caller-rendered HTML from being modified. +## Sender name + +An email goes out from the sender of its delivery source. To use another name +for one email, pass `fromName`, for example the name of the shop or school the +email is about: + +```json +{ + "to": "reader@example.com", + "subject": "Your sign-in code", + "html": "

Your code is 345987

", + "fromName": "Yoga with Nalini" +} +``` + +Only the name changes; the address stays the delivery source's verified one. +`fromName` can be up to 100 characters. It can't contain line breaks or other +control characters, invisible formatting characters such as right-to-left +overrides and zero-width spaces, `<`, `>` or `@`, so a name can't pass for an +address or read as something it isn't. Otherwise the send is refused with a +`400`. A name with a comma, quote or other punctuation is quoted +in the From header for you. + ## Plain-text part Every email goes out as `multipart/alternative` with an HTML and a plain-text diff --git a/packages/api-contract/src/schemas/transactional.ts b/packages/api-contract/src/schemas/transactional.ts index eac13a20..25a554fb 100644 --- a/packages/api-contract/src/schemas/transactional.ts +++ b/packages/api-contract/src/schemas/transactional.ts @@ -41,6 +41,34 @@ export const transactionalEmailDetailSchema = transactionalEmailSchema.extend({ text: z.string().nullable().optional(), }); +/** A sender name a caller may set for one send. Only the display name: the + * address is always the delivery source's verified one. Refused, so the name + * can't break the From header or pass for an address: + * - control characters (`Cc`), which include line breaks; + * - invisible formatting characters (`Cf`), such as right-to-left overrides + * and zero-width spaces, which can make a name read as something else; + * - `<`, `>` and `@`. + * Anything else is quoted when the header is built. */ +export const MAX_SENDER_NAME_LENGTH = 100; + +export function isValidSenderName(name: string): boolean { + const trimmed = name.trim(); + return ( + trimmed.length > 0 && + trimmed.length <= MAX_SENDER_NAME_LENGTH && + !/[\p{Cc}\p{Cf}<>@]/u.test(trimmed) + ); +} + +export const senderNameSchema = z + .string() + .refine(isValidSenderName, { + message: `1-${MAX_SENDER_NAME_LENGTH} characters, without control or invisible formatting characters, <, > or @`, + }) + .describe( + "The From name for this email, instead of the delivery source's. The address stays the delivery source's.", + ); + /** Headers the send pipeline owns — caller-supplied values would spoof the * resolved sender identity or corrupt the MIME envelope. */ const reservedHeaders = ["from", "to", "subject", "content-type"] as const; @@ -75,6 +103,7 @@ export const sendEmailBodySchema = z "Plain-text part, sent verbatim. When omitted it is generated from the HTML.", ), variables: z.record(z.any()).optional(), + fromName: senderNameSchema.optional(), replyTo: z.string().email().optional(), headers: emailHeadersSchema.optional(), idempotencyKey: z.string().min(1).max(256).optional(),