diff --git a/README.md b/README.md index 9db882f..2c22c9a 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,18 @@ sk.send( vars={"order": "#1234"}, metadata={"order_id": "ord_1"}, # arbitrary key/values for filtering & webhooks cc=["ops@example.com"], # email only + from_="hello@acme.com", # email only — From address override (bare address) + from_name="Acme Support", # email only — From display name → "Acme Support " scheduled_at=datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc), # datetime or ISO-8601 string idempotency_key="order-1234-shipped", # optional; see Idempotency below ) ``` +`from_` and `from_name` are email-only and each optional — either can be set alone, and 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; `from_name` always applies. +The same two arguments work on `send_raw`. + ### Raw content (no template) Pass a content object — the channel is inferred from its type. Set `interpolate=True` to diff --git a/src/senderkit/_serialize.py b/src/senderkit/_serialize.py index 04198b2..cfd421b 100644 --- a/src/senderkit/_serialize.py +++ b/src/senderkit/_serialize.py @@ -125,6 +125,8 @@ def template_send_to_body(req: TemplateSend) -> Dict[str, Any]: "cc": req.cc, "bcc": req.bcc, "replyTo": req.reply_to, + "from": req.from_, + "fromName": req.from_name, "attachments": ( [_attachment_to_wire(a) for a in req.attachments] if req.attachments else None ), @@ -139,6 +141,7 @@ def raw_send_to_body(req: RawSend) -> Dict[str, Any]: "channel": channel, "to": req.to, "from": req.from_, + "fromName": req.from_name, "interpolate": req.interpolate, "content": content_to_wire(req.content), "vars": req.vars, diff --git a/src/senderkit/client.py b/src/senderkit/client.py index 45501db..ce6b51a 100644 --- a/src/senderkit/client.py +++ b/src/senderkit/client.py @@ -85,6 +85,8 @@ def send( bcc: Optional[List[str]] = None, reply_to: Optional[str] = None, attachments: Optional[list] = None, + from_: Optional[str] = None, + from_name: Optional[str] = None, idempotency_key: Optional[str] = None, ) -> SendResult: """Send a stored template to one recipient.""" @@ -101,6 +103,8 @@ def send( bcc=bcc, reply_to=reply_to, attachments=attachments, + from_=from_, + from_name=from_name, idempotency_key=idempotency_key, ) ) @@ -112,6 +116,7 @@ def send_raw( *, channel: Optional[ChannelLike] = None, from_: Optional[str] = None, + from_name: Optional[str] = None, interpolate: Optional[bool] = None, vars: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None, @@ -125,6 +130,7 @@ def send_raw( content=content, channel=channel, from_=from_, + from_name=from_name, interpolate=interpolate, vars=vars, metadata=metadata, @@ -215,6 +221,8 @@ async def send( bcc: Optional[List[str]] = None, reply_to: Optional[str] = None, attachments: Optional[list] = None, + from_: Optional[str] = None, + from_name: Optional[str] = None, idempotency_key: Optional[str] = None, ) -> SendResult: return await self._send( @@ -230,6 +238,8 @@ async def send( bcc=bcc, reply_to=reply_to, attachments=attachments, + from_=from_, + from_name=from_name, idempotency_key=idempotency_key, ) ) @@ -241,6 +251,7 @@ async def send_raw( *, channel: Optional[ChannelLike] = None, from_: Optional[str] = None, + from_name: Optional[str] = None, interpolate: Optional[bool] = None, vars: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None, @@ -253,6 +264,7 @@ async def send_raw( content=content, channel=channel, from_=from_, + from_name=from_name, interpolate=interpolate, vars=vars, metadata=metadata, diff --git a/src/senderkit/models.py b/src/senderkit/models.py index b7d2420..ec14623 100644 --- a/src/senderkit/models.py +++ b/src/senderkit/models.py @@ -112,6 +112,11 @@ class TemplateSend: bcc: Optional[List[str]] = None reply_to: Optional[str] = None attachments: Optional[List[Attachment]] = None + #: Email-only From address override (bare address). ``from_`` maps to the + #: wire field ``from``; the display name goes in ``from_name``. + from_: Optional[str] = None + #: Email-only From display name override, rendered as ``Name
``. + from_name: Optional[str] = None idempotency_key: Optional[str] = None @@ -124,6 +129,8 @@ class RawSend: content: Content channel: Optional[ChannelLike] = None from_: Optional[str] = None + #: Email-only From display name override, rendered as ``Name
``. + from_name: Optional[str] = None interpolate: Optional[bool] = None vars: Optional[Vars] = None metadata: Optional[Metadata] = None diff --git a/tests/test_send.py b/tests/test_send.py index 95e0c6f..e9a1ea2 100644 --- a/tests/test_send.py +++ b/tests/test_send.py @@ -49,6 +49,20 @@ def test_send_serializes_envelope_and_datetime(client): assert body["scheduledAt"].startswith("2026-01-01T09:00:00") +@respx.mock +def test_send_serializes_from_overrides(client): + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) + client.send( + "welcome", + "user@example.com", + from_="hello@acme.com", + from_name="Acme Support", + ) + body = request_body(route.calls.last.request) + assert body["from"] == "hello@acme.com" + assert body["fromName"] == "Acme Support" + + def test_api_key_required(): with pytest.raises(ValueError): SenderKit(api_key="") diff --git a/tests/test_send_raw.py b/tests/test_send_raw.py index da9887f..fc2a13f 100644 --- a/tests/test_send_raw.py +++ b/tests/test_send_raw.py @@ -12,12 +12,16 @@ def test_send_raw_email_infers_channel(client): client.send_raw( "user@example.com", EmailContent(subject="Hi", html="

Hi {{name}}

", text="Hi"), + from_="hello@acme.com", + from_name="Acme Support", interpolate=True, vars={"name": "Ada"}, ) body = request_body(route.calls.last.request) assert body["channel"] == "email" assert body["to"] == "user@example.com" + assert body["from"] == "hello@acme.com" + assert body["fromName"] == "Acme Support" assert body["interpolate"] is True assert body["content"] == {"subject": "Hi", "html": "

Hi {{name}}

", "text": "Hi"} assert body["vars"] == {"name": "Ada"}