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
22 changes: 22 additions & 0 deletions .changeset/per-send-from-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@senderkit/sdk": minor
"@senderkit/cli": minor
---

Add per-send From overrides on email sends, identical on templated and raw sends.

- **`from`** — optional From **address** override (bare address). Previously
available on `sendRaw` only; now also accepted on `send` (templated).
- **`fromName`** — new on both `send` and `sendRaw`: optional From **display
name** override, rendered by the sender as `Name <address>`. Max 128
characters; no control characters or angle brackets.

Either field can be set on its own; both fall back to the provider connection's
configured values. On managed sending the `from` address is honored only on the
workspace's verified sending domain, while `fromName` always applies.

- **SDK:** `from` / `fromName` added to `SendRequest` and `fromName` to
`SendRawEmailRequest`; both forwarded to `/v1/send`.
- **MCP:** `senderkit_send` and `senderkit_send_raw` gain `from` / `fromName`
inputs.
- **CLI:** `senderkit send` and `senderkit send-raw` gain `--from` / `--from-name`.
5 changes: 3 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ Mode (live vs test) is derived from the key prefix — `sk_live_…` or `sk_test

```bash
senderkit send <template> <to> [--vars '{…}'] [--channel email|sms|push|web-push] \
[--version N] [--metadata '{…}'] [--idempotency-key K]
[--version N] [--metadata '{…}'] [--idempotency-key K] \
[--from …] [--from-name …]

senderkit send-raw <to> --channel email --subject "Hi" --html "<p>…</p>" [--text …] [--from …]
senderkit send-raw <to> --channel email --subject "Hi" --html "<p>…</p>" [--text …] [--from …] [--from-name …]
senderkit send-raw <to> --channel sms --body "Your code is 123456"
senderkit send-raw <to> --channel push --title "Shipped" --body "…" [--badge 1] [--push-data '{…}']
senderkit send-raw '<subscription-json>' --channel web-push --title "Back in stock" --body "…" \
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/commands/send-raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function buildRequest(input: z.infer<typeof schema>): SendRawRequest {
...base,
channel: "email",
from: input.from,
fromName: input.fromName,
content: {
subject: input.subject,
html: input.html,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/core/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const sendCommand = defineCommand<typeof schema.shape, SendResponse>({
bcc: input.bcc,
replyTo: input.replyTo,
attachments: input.attachments,
from: input.from,
fromName: input.fromName,
}),
format: (res) =>
`${success(`Queued message ${res.id}`)}\n${keyValues({
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ The response shape:

`status` is `"scheduled"` when `scheduledAt` is in the future, otherwise `"queued"`.

Override the From identity per send (email only) with `from` (bare address) and/or `fromName` (display name). Either can be set on its own; both fall back to the provider connection's configured values. The same two fields work on `sendRaw`.

```ts
await senderkit.send({
template: "welcome",
to: "user@example.com",
from: "hello@acme.com", // bare address
fromName: "Acme Support", // display name → "Acme Support <hello@acme.com>"
});
```

On managed sending, the `from` address is honored only when it's on the workspace's verified sending domain; `fromName` always applies. `fromName` is capped at 128 characters and may not contain control characters or angle brackets.

### `sendRaw`

Send inline content without registering a template — useful for one-off admin notifications, contact-form replies, AI-generated drafts, or any case where the body is known at call-time.
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class SenderKit {
if (request.bcc) body["bcc"] = request.bcc;
if (request.replyTo) body["replyTo"] = request.replyTo;
if (request.attachments) body["attachments"] = request.attachments;
if (request.from) body["from"] = request.from;
if (request.fromName) body["fromName"] = request.fromName;

return this.http.request<SendResponse>({
method: "POST",
Expand Down Expand Up @@ -111,6 +113,7 @@ export class SenderKit {
if (request.interpolate) body["interpolate"] = true;
if (request.scheduledAt) body["scheduledAt"] = toIsoString(request.scheduledAt);
if (request.channel === "email" && request.from) body["from"] = request.from;
if (request.channel === "email" && request.fromName) body["fromName"] = request.fromName;

return this.http.request<SendResponse>({
method: "POST",
Expand Down
27 changes: 26 additions & 1 deletion packages/sdk/src/mcp-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ const attachment = z.object({
contentId: z.string().optional(),
});

/**
* Per-send From overrides, identical on templated and raw email sends. `from`
* is a bare address; the display name goes in `fromName`, never inline. Both
* are email-only and fall back to the provider connection's configured values.
*/
const fromOverride = z
.string()
.optional()
.describe(
"Email-only. Optional From address override (bare address — put the display " +
"name in fromName). Defaults to the connection's From address.",
);

const fromName = z
.string()
.max(128)
.optional()
.describe(
"Email-only. Optional From display name, rendered as `Name <address>`. " +
"Max 128 chars; no control characters or angle brackets.",
);

const emailEnvelope = {
cc: z
.preprocess(csvOrJsonArray, z.array(z.string()))
Expand Down Expand Up @@ -156,6 +178,8 @@ export const sendInput = {
metadata,
scheduledAt,
idempotencyKey,
from: fromOverride,
fromName,
...emailEnvelope,
};

Expand All @@ -172,7 +196,8 @@ export const sendRawInput = {
preheader: z.string().optional().describe("Email preheader (email)."),
html: z.string().optional().describe("Email HTML body (email)."),
text: z.string().optional().describe("Email plain-text body (email)."),
from: z.string().optional().describe("From override (email)."),
from: fromOverride,
fromName,
// sms + push + web-push
body: z.string().optional().describe("Message body (sms, push, web-push)."),
// push + web-push
Expand Down
24 changes: 23 additions & 1 deletion packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export interface SendRequest extends EmailEnvelope {
template: string;
/** Recipient address. */
to: string;
/**
* Per-message From address override (email only, bare address — put the
* display name in `fromName`). Falls back to the connection's From address.
* On managed sending it is honored only on the workspace's verified domain.
*/
from?: string;
/**
* Per-message From display name override (email only), rendered as
* `Name <address>`. Falls back to the connection's From name. Max 128 chars;
* no control characters or angle brackets.
*/
fromName?: string;
/** Template variables. */
vars?: Record<string, unknown>;
/** Force a specific channel. Defaults to the template's primary channel. */
Expand Down Expand Up @@ -116,8 +128,18 @@ interface SendRawBase {
export interface SendRawEmailRequest extends SendRawBase {
channel: "email";
content: RawEmailContent;
/** Per-message From override (email only). */
/**
* Per-message From address override (email only, bare address — put the
* display name in `fromName`). Falls back to the connection's From address.
* On managed sending it is honored only on the workspace's verified domain.
*/
from?: string;
/**
* Per-message From display name override (email only), rendered as
* `Name <address>`. Falls back to the connection's From name. Max 128 chars;
* no control characters or angle brackets.
*/
fromName?: string;
}

export interface SendRawSmsRequest extends SendRawBase {
Expand Down
10 changes: 7 additions & 3 deletions packages/sdk/test/send-raw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("sendRaw", () => {
text: "Hello {{name}}",
},
from: "no-reply@example.com",
fromName: "Acme Support",
vars: { name: "Ada" },
metadata: { source: "test", attempt: 1 },
interpolate: true,
Expand All @@ -71,6 +72,7 @@ describe("sendRaw", () => {
metadata: { source: "test", attempt: 1 },
interpolate: true,
from: "no-reply@example.com",
fromName: "Acme Support",
});
});

Expand Down Expand Up @@ -165,22 +167,24 @@ describe("sendRaw", () => {
});
});

it("does not include `from` for non-email channels", async () => {
it("does not include `from`/`fromName` for non-email channels", async () => {
const mock = createMockFetch([
{ status: 202, body: { id: "msg_sms", status: "queued", livemode: false } },
]);
const sk = new SenderKit({ apiKey: "sk_test_x", fetch: mock.fetch });

// Cast-through to verify the runtime guard rejects a stray `from` on SMS.
// Cast-through to verify the runtime guard rejects stray From overrides on SMS.
await sk.sendRaw({
channel: "sms",
to: "+15555550123",
content: { body: "hello" },
// @ts-expect-error from is email-only
// @ts-expect-error from and fromName are email-only
from: "no-reply@example.com",
fromName: "Acme Support",
});

expect(mock.calls[0]!.body).not.toHaveProperty("from");
expect(mock.calls[0]!.body).not.toHaveProperty("fromName");
});

it("uses caller idempotencyKey verbatim", async () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/sdk/test/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,26 @@ describe("send", () => {
});
});

it("forwards from and fromName overrides when provided", async () => {
const mock = createMockFetch([
{ status: 202, body: { id: "msg_1", status: "queued", livemode: false } },
]);
const sk = new SenderKit({ apiKey: "sk_test_x", fetch: mock.fetch });
await sk.send({
template: "welcome",
to: "user@example.com",
from: "no-reply@example.com",
fromName: "Acme Support",
});
expect(mock.calls[0]!.body).toEqual({
template: "welcome",
to: "user@example.com",
vars: {},
from: "no-reply@example.com",
fromName: "Acme Support",
});
});

it("throws when template or to is missing", async () => {
const sk = new SenderKit({ apiKey: "sk_test_x", fetch: createMockFetch().fetch });
// @ts-expect-error
Expand Down