Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/transactional-from-name.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/api/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions apps/api/src/mcp/tools/transactional.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
emailHeadersSchema,
senderNameSchema,
transactionalEmailDetailSchema,
transactionalEmailSchema,
transactionalEmailStatus,
Expand Down Expand Up @@ -36,6 +37,10 @@ const SEND_ERRORS: Record<string, [Parameters<typeof toolError>[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.",
Expand Down Expand Up @@ -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()
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/transactional/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,52 @@ describe("createTransactionalEmail happy paths", () => {
expect(row.html).toBe("<p>Hello {{ not_a_liquid_tag }}</p>");
});

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: "<p>hi</p>",
});
const address = pinned.fromEmail!.match(/<([^>]+)>$/)![1];

const row = await createTransactionalEmail({
teamId: team.id,
to: "buyer@example.com",
subject: "Your receipt",
html: "<p>hi</p>",
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 <a@b.c>",
"support@paypal.com",
"Shop \u202Emoc.lapyap",
"Pay\u200BPal",
"x".repeat(101),
]) {
await expect(
createTransactionalEmail({
teamId: team.id,
to: "a@example.com",
subject: "Hi",
html: "<p>hi</p>",
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 },
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/transactional/queries.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -199,6 +200,7 @@ export async function createTransactionalEmail({
html,
text,
variables = {},
fromName,
replyTo,
headers,
idempotencyKey,
Expand All @@ -214,6 +216,8 @@ export async function createTransactionalEmail({
/** Plain-text part, sent verbatim; generated from the HTML when omitted. */
text?: string;
variables?: Record<string, unknown>;
/** The From name for this send only; the address stays the pinned one. */
fromName?: string;
replyTo?: string;
headers?: Record<string, string>;
idempotencyKey?: string;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -321,7 +328,7 @@ export async function createTransactionalEmail({
}

const from = getEmailFrom({
name: pin.fromName,
name: fromName?.trim() ?? pin.fromName,
email: pin.fromEmail,
});

Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/transactional/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/utils/mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" <a@example.com>',
);
expect(getEmailFrom({ name: 'The "Best" School', email: "a@example.com" })).toBe(
'"The \\"Best\\" School" <a@example.com>',
);
expect(getEmailFrom({ name: "Yoga & Co", email: "a@example.com" })).toBe(
"Yoga & Co <a@example.com>",
);
});

it("builds site and unsubscribe URLs from env", () => {
process.env.PROTOCOL = "https";
process.env.DOMAIN = "app.sendlit.test";
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/utils/mail.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions apps/docs/content/docs/developers/transactional.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>Your code is 345987</p>",
"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
Expand Down
29 changes: 29 additions & 0 deletions packages/api-contract/src/schemas/transactional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
Loading