Send email with Lettr
Write the send in the user's own stack using the installed SDK, then verify it actually went out. The exact SDK calls are generated per language — this skill is the judgment around them.
1. Know the stack and load the SDK reference
Detect the language using ../_shared/detect-stack.md, then read the matching generated reference for the exact, current SDK surface:
- SDK calls:
../_generated/sdk/<lang>/sending.md — sendHtml/sendText/sendTemplate, the fluent builder, attachments, options, response shape, typed exceptions.
- Wire contract (any language):
../_generated/api/index.md — the POST /emails field list. Bold fields are required (from, to, subject). Read this when the SDK reference doesn't cover a field, or when there's no SDK and you're calling the API directly.
Never invent method names from memory — the generated reference is the source of truth and is regenerated from the SDKs.
2. Pick the send shape
| Situation |
Use |
| One-off HTML or text, few options |
the quick sendHtml / sendText helper |
| Tracking, metadata, cc/bcc, reply-to, attachments together |
the fluent email builder |
| Content managed in Lettr (merge tags) |
template send — pair with the templates skill |
| Same content to many recipients |
put up to 50 addresses in to (one API call delivers a separate copy to each); substitution_data applies to the whole batch, not per-recipient |
| More than 50 recipients |
chunk into batches of ≤50 and send a separate call per batch — sequentially or in parallel, staying under 3 req/s. There is no bulk endpoint; looping batches is the intended pattern |
| Must not double-send on retry |
Lettr has no server-side idempotency. Guard in your own code: check a DB flag or cache key derived from the business event (e.g. order-confirmation-{orderId}) before sending, and record the returned request_id after. There is no idempotency_key field — passing one is silently discarded, not rejected. Put the key in metadata if you want it traceable later |
| Send later |
POST /emails/scheduled — keep the returned transmissionId to cancel |
3. Preconditions that cause most failures
from must be on a verified sending domain. Unverified → 400 with error_code: unconfigured_domain (not 422 — see error handling below). If the user hasn't verified one, route to install (domain step) before sending.
- 50 recipients max per call, counted across
to + cc + bcc combined (not 50 each). More than that → batch (see the table above).
- Quota is charged per recipient on the same
to + cc + bcc basis — a 50-address call costs 50, not 1. If you're adding a fixed bcc (an archive or audit address) to every send, say so out loud: it doubles the user's quota consumption. Enforcement is all-or-nothing, so a call that would cross the limit is rejected entirely rather than partially delivered.
transactional defaults to true — you must opt out. It bypasses unsubscribe suppression, which is correct for password resets and receipts. For anything a user can opt out of, explicitly set options.transactional: false; leaving it unset sends to unsubscribed contacts.
4. Wrap the send in error handling
The SDKs surface typed errors — map them, don't swallow them. Read the generated reference for this language's exact names and shapes; they differ a lot (PHP/Java throw exceptions, Go returns a single lettr.Error plus Is* helpers, Rust returns an Error enum, Node returns a { type, error_code } result). The cases to handle:
- 401 — bad or revoked key.
- 422 — a malformed request: missing field, bad address format.
- 429 — two distinct causes, told apart by
error_code: rate_limit_exceeded (3 req/s per team — back off per Retry-After) vs quota_exceeded / daily_quota_exceeded (plan limit — waiting won't help until the window resets).
- 400
unconfigured_domain — the from domain isn't verified. ⚠️ This is the most common first-send failure and it is not a validation error: it will not be caught by a ValidationException/422 branch. Match on the status or error_code, not on the validation type.
Production sends must handle all four.
5. Offer to verify the send actually happened
A 2xx only means Lettr accepted the request, not that it was delivered. Don't send a live test silently — ask the user whether they want one (it sends a real email and consumes quota). If they decline, just wire the code and capture request_id in it.
If they want verification:
- Capture
request_id from the response (store it — it's how every later lookup works).
- Send one real test to an address the user controls, using a verified
from.
- Fetch the event timeline for that
request_id (GET /emails/{requestId}). A delivery event = the receiving server accepted it. A bounce/policy_rejection = hand off to the diagnose skill.
Either way, report what was wired up and the request_id (plus the last event seen, if verified).
What this skill does not do
- It doesn't author template HTML — that's
templates.
- It doesn't triage a failed/bouncing send — that's
diagnose.
- It doesn't install the SDK or verify domains — that's
install.
1---2name: sending3description: Use when sending transactional email from a project with Lettr — composing a message (HTML/text/template), attachments, cc/bcc, tracking and metadata options, batch sends, scheduling, and idempotency. Assumes the SDK is already installed (run `install` first if not).4---56# Send email with Lettr78Write the send in the user's own stack using the installed SDK, then verify it actually went out. The exact SDK calls are generated per language — this skill is the judgment around them.910## 1. Know the stack and load the SDK reference1112Detect the language using [`../_shared/detect-stack.md`](../_shared/detect-stack.md), then read the matching generated reference for the exact, current SDK surface:1314- **SDK calls:** [`../_generated/sdk/<lang>/sending.md`](../_generated/sdk) — `sendHtml`/`sendText`/`sendTemplate`, the fluent builder, attachments, options, response shape, typed exceptions.15- **Wire contract (any language):** [`../_generated/api/index.md`](../_generated/api/index.md) — the `POST /emails` field list. Bold fields are required (`from`, `to`, `subject`). Read this when the SDK reference doesn't cover a field, or when there's no SDK and you're calling the API directly.1617Never invent method names from memory — the generated reference is the source of truth and is regenerated from the SDKs.1819## 2. Pick the send shape2021| Situation | Use |22|-|-|23| One-off HTML or text, few options | the quick `sendHtml` / `sendText` helper |24| Tracking, metadata, cc/bcc, reply-to, attachments together | the fluent email builder |25| Content managed in Lettr (merge tags) | template send — pair with the `templates` skill |26| Same content to many recipients | put up to 50 addresses in `to` (one API call delivers a separate copy to each); `substitution_data` applies to the whole batch, not per-recipient |27| More than 50 recipients | chunk into batches of ≤50 and send a separate call per batch — sequentially or in parallel, staying under 3 req/s. There is no bulk endpoint; looping batches is the intended pattern |28| Must not double-send on retry | **Lettr has no server-side idempotency.** Guard in your own code: check a DB flag or cache key derived from the business event (e.g. `order-confirmation-{orderId}`) *before* sending, and record the returned `request_id` after. There is no `idempotency_key` field — passing one is silently discarded, not rejected. Put the key in `metadata` if you want it traceable later |29| Send later | `POST /emails/scheduled` — keep the returned `transmissionId` to cancel |3031## 3. Preconditions that cause most failures3233- **`from` must be on a verified sending domain.** Unverified → **`400`** with `error_code: unconfigured_domain` (not `422` — see error handling below). If the user hasn't verified one, route to `install` (domain step) before sending.34- **50 recipients max per call, counted across `to` + `cc` + `bcc` combined** (not 50 each). More than that → batch (see the table above).35- **Quota is charged per recipient on the same `to` + `cc` + `bcc` basis** — a 50-address call costs 50, not 1. If you're adding a fixed `bcc` (an archive or audit address) to every send, say so out loud: it doubles the user's quota consumption. Enforcement is all-or-nothing, so a call that would cross the limit is rejected entirely rather than partially delivered.36- **`transactional` defaults to `true` — you must opt *out*.** It bypasses unsubscribe suppression, which is correct for password resets and receipts. For anything a user can opt out of, explicitly set `options.transactional: false`; leaving it unset sends to unsubscribed contacts.3738## 4. Wrap the send in error handling3940The SDKs surface typed errors — map them, don't swallow them. Read the generated reference for this language's exact names and shapes; they differ a lot (PHP/Java throw exceptions, Go returns a single `lettr.Error` plus `Is*` helpers, Rust returns an `Error` enum, Node returns a `{ type, error_code }` result). The cases to handle:4142- **401** — bad or revoked key.43- **422** — a malformed request: missing field, bad address format.44- **429** — two distinct causes, told apart by `error_code`: `rate_limit_exceeded` (3 req/s per team — back off per `Retry-After`) vs `quota_exceeded` / `daily_quota_exceeded` (plan limit — waiting won't help until the window resets).45- **400 `unconfigured_domain`** — the `from` domain isn't verified. ⚠️ This is the most common first-send failure and it is **not** a validation error: it will not be caught by a `ValidationException`/422 branch. Match on the status or `error_code`, not on the validation type.4647Production sends must handle all four.4849## 5. Offer to verify the send actually happened5051A `2xx` only means Lettr **accepted** the request, not that it was delivered. Don't send a live test silently — **ask the user** whether they want one (it sends a real email and consumes quota). If they decline, just wire the code and capture `request_id` in it.5253If they want verification:54551. Capture `request_id` from the response (store it — it's how every later lookup works).562. Send one real test to an address the user controls, using a verified `from`.573. Fetch the event timeline for that `request_id` (`GET /emails/{requestId}`). A `delivery` event = the receiving server accepted it. A `bounce`/`policy_rejection` = hand off to the `diagnose` skill.5859Either way, report what was wired up and the `request_id` (plus the last event seen, if verified).6061## What this skill does not do6263- It doesn't author template HTML — that's `templates`.64- It doesn't triage a failed/bouncing send — that's `diagnose`.65- It doesn't install the SDK or verify domains — that's `install`.