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(),