diff --git a/.changeset/per-send-from-overrides.md b/.changeset/per-send-from-overrides.md
new file mode 100644
index 0000000..20634c8
--- /dev/null
+++ b/.changeset/per-send-from-overrides.md
@@ -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
`. 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`.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 7029d76..1a8cd1f 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -40,9 +40,10 @@ Mode (live vs test) is derived from the key prefix — `sk_live_…` or `sk_test
```bash
senderkit send [--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 --channel email --subject "Hi" --html "…
" [--text …] [--from …]
+senderkit send-raw --channel email --subject "Hi" --html "…
" [--text …] [--from …] [--from-name …]
senderkit send-raw --channel sms --body "Your code is 123456"
senderkit send-raw --channel push --title "Shipped" --body "…" [--badge 1] [--push-data '{…}']
senderkit send-raw '' --channel web-push --title "Back in stock" --body "…" \
diff --git a/packages/cli/src/core/commands/send-raw.ts b/packages/cli/src/core/commands/send-raw.ts
index 5a273f2..4a8123e 100644
--- a/packages/cli/src/core/commands/send-raw.ts
+++ b/packages/cli/src/core/commands/send-raw.ts
@@ -24,6 +24,7 @@ function buildRequest(input: z.infer): SendRawRequest {
...base,
channel: "email",
from: input.from,
+ fromName: input.fromName,
content: {
subject: input.subject,
html: input.html,
diff --git a/packages/cli/src/core/commands/send.ts b/packages/cli/src/core/commands/send.ts
index 0f595e4..2731ff3 100644
--- a/packages/cli/src/core/commands/send.ts
+++ b/packages/cli/src/core/commands/send.ts
@@ -25,6 +25,8 @@ export const sendCommand = defineCommand({
bcc: input.bcc,
replyTo: input.replyTo,
attachments: input.attachments,
+ from: input.from,
+ fromName: input.fromName,
}),
format: (res) =>
`${success(`Queued message ${res.id}`)}\n${keyValues({
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index a832a54..1743015 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -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 "
+});
+```
+
+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.
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index 81ae0c4..e6b5093 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -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({
method: "POST",
@@ -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({
method: "POST",
diff --git a/packages/sdk/src/mcp-schemas.ts b/packages/sdk/src/mcp-schemas.ts
index 5915037..bd0e160 100644
--- a/packages/sdk/src/mcp-schemas.ts
+++ b/packages/sdk/src/mcp-schemas.ts
@@ -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 `. " +
+ "Max 128 chars; no control characters or angle brackets.",
+ );
+
const emailEnvelope = {
cc: z
.preprocess(csvOrJsonArray, z.array(z.string()))
@@ -156,6 +178,8 @@ export const sendInput = {
metadata,
scheduledAt,
idempotencyKey,
+ from: fromOverride,
+ fromName,
...emailEnvelope,
};
@@ -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
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index efb243c..bee5652 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -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 `. Falls back to the connection's From name. Max 128 chars;
+ * no control characters or angle brackets.
+ */
+ fromName?: string;
/** Template variables. */
vars?: Record;
/** Force a specific channel. Defaults to the template's primary channel. */
@@ -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 `. Falls back to the connection's From name. Max 128 chars;
+ * no control characters or angle brackets.
+ */
+ fromName?: string;
}
export interface SendRawSmsRequest extends SendRawBase {
diff --git a/packages/sdk/test/send-raw.test.ts b/packages/sdk/test/send-raw.test.ts
index 4dd2ffa..7d32179 100644
--- a/packages/sdk/test/send-raw.test.ts
+++ b/packages/sdk/test/send-raw.test.ts
@@ -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,
@@ -71,6 +72,7 @@ describe("sendRaw", () => {
metadata: { source: "test", attempt: 1 },
interpolate: true,
from: "no-reply@example.com",
+ fromName: "Acme Support",
});
});
@@ -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 () => {
diff --git a/packages/sdk/test/send.test.ts b/packages/sdk/test/send.test.ts
index a894750..ea6f5b5 100644
--- a/packages/sdk/test/send.test.ts
+++ b/packages/sdk/test/send.test.ts
@@ -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