sending
Skill0 starsChanged 5 months agoMITView on GitHub
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).
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Write the send in the user's own stack using the installed SDK, then verify it actually went out.
- Distinctive: line 17
Never invent method names from memory — the generated reference is the source of truth and is regenerated from the SDKs.
- Very specific: line 46
The key is yours: No SDK invents one for you — except lettr-laravel, which derives one per queued job so that a job retry does not re-send.
- Distinctive: line 68
If they want verification:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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— thePOST /emailsfield 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 | Pass an idempotency key derived from the business event (e.g. order-confirmation-{orderId}) and reuse the same value on every retry — the API returns the original result instead of sending again. See §3a. You no longer need a DB flag or cache key for this |
| Send later | POST /emails/scheduled, 5 min – 30 days out. Keep the returned request_id (sch_…) — that is what reads and cancels it. The transmission_id on the same response is the provider's, is null until the email actually sends, and is the id webhook events carry |
| Find a scheduled email again | GET /emails/scheduled?status=scheduled — the list endpoint, if the sch_ id wasn't kept |
3. Preconditions that cause most failures
frommust be on a verified sending domain. Unverified →400witherror_code: unconfigured_domain(not422— see error handling below). If the user hasn't verified one, route toinstall(domain step) before sending.- 50 recipients max per call, counted across
to+cc+bcccombined (not 50 each). More than that → batch (see the table above). - Quota is charged per recipient on the same
to+cc+bccbasis — a 50-address call costs 50, not 1. If you're adding a fixedbcc(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. transactionaldefaults totrue— 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 setoptions.transactional: false; leaving it unset sends to unsubscribed contacts.
3a. Retrying safely: idempotency keys
A timeout tells you nothing about whether the email went out. The send may well have succeeded and only the response was lost, so a blind retry delivers twice.
Pass an idempotency key and the retry is safe: reusing a key returns the original result rather than sending again.
- Derive the key from the business event —
order-4417-receipt,invoice-2026-03-payment-failed. Never from a timestamp, a UUID, or anything random: those differ on the retry, which is precisely when the key has to match, and a fresh key makes the mechanism a no-op. - The key is yours. No SDK invents one for you — except
lettr-laravel, which derives one per queued job so that a job retry does not re-send. Everywhere else, if you did not pass a key there is no protection. - Read the replay flag. Responses expose whether the result was replayed (
replayedin Node/Rust/PHP,Replayedin Go, equivalent elsewhere).replayed: truemeans no second email went out — that is a success, not an error. Log it differently, don't treat it as a failure. - Scope: keys are held 24 hours and are scoped per team and API key. The same string through a different API key is a different key.
- Two 409s, opposite reactions.
idempotency_in_progressmeans an earlier send with this key is still running — retry with the same key afterRetry-Afterseconds.idempotency_key_conflictmeans the key was already used with a different payload — never retry it; it will fail identically forever. Use a new key, or resend the original payload.
The old guidance — guard with your own DB flag or cache key — is no longer necessary for this. It is still reasonable if you want a business-level record of what was sent, but it is not what stops a duplicate.
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 perRetry-After) vsquota_exceeded/daily_quota_exceeded(plan limit — waiting won't help until the window resets). - 400
unconfigured_domain— thefromdomain isn't verified. ⚠️ This is the most common first-send failure and it is not a validation error: it will not be caught by aValidationException/422 branch. Match on the status orerror_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_idfrom 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}). Adeliveryevent = the receiving server accepted it. Abounce/policy_rejection= hand off to thediagnoseskill.
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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

