Transactional email
Send email from your works through your own provider, never twice for the same thing, and see what was sent and what failed.
Many store features end in an email: a quote reply, a back-in-stock alert, a review request. Your agent builds the sending once, as a small module in the feature that sends: it records what should be sent, sends it through your email provider, records the outcome so a retry never emails anyone twice, and shows recent sends and failures in Shopify admin. Your provider delivers the email and keeps its reputation, suppression list and templates; Littleworks has no mail service of its own.
Use Littleworks to send email from my store’s works through my email provider (Postmark, Resend or SendGrid; Klaviyo events for marketing flows). Start with [the feature, such as replies to quote requests]. Keep the provider key in Littleworks secrets, use provider-hosted templates, record every send so retries never send twice, honour unsubscribes and bounces, and add an admin page of recent sends and failures with a retry action. Don’t send real email while testing: use the provider’s test mode or my test addresses. Read https://littleworks.app/docs/recipe-email.md and adapt it to my store.What you are building
- A send step in each feature that emails: it writes an outbox record and sends it right away, or leaves it for a scheduled dispatcher.
- Provider templates with a small, explicit model, holding no personal data beyond what the message needs.
- An admin page for the feature: Needs attention (failed or unknown), Queued, Sent, Skipped and All, with counts, and Retry, Send again, Mark handled, Suppress address and Pause sending actions.
- Unsubscribe links for subscribed and marketing email, and suppression of addresses that bounce or complain.
Decide what kind of message it is
| Message | Kind | Consent | Unsubscribe |
|---|---|---|---|
| Quote reply, a note about an order | Transactional: a reply to something the customer asked for | The customer’s request | Not required; still honour bounces and complaints |
| Back-in-stock or price-drop alert the customer signed up for | Subscribed | The signup, with the address confirmed first | A link in every email; the provider’s one-click unsubscribe for bulk senders |
| Review request, win-back, newsletter | Marketing | Shopify email marketing consent, or the merchant’s lawful basis | Required; prefer Klaviyo or the provider’s broadcast tools |
This isn’t legal advice: the merchant decides how each message is classified where they sell. Gmail asks bulk senders (5,000 or more a day) for one-click unsubscribe and a visible link on marketing and subscribed mail, and a spam rate below 0.3%. Don’t send marketing through Postmark’s transactional stream; use a broadcast stream.
Choose the provider call
| Provider | Send | Auth | Batch | Repeats | Notes |
|---|---|---|---|---|---|
| Postmark | POST https://api.postmarkapp.com/email/withTemplate or /email/batchWithTemplates ({Messages: […]}) | X-Postmark-Server-Token | Up to 500 messages per call | No idempotency key. A batch returns 200 even when some messages fail: check each ErrorCode (0 is accepted) by position | MessageStream defaults to transactional (outbound); TemplateAlias and TemplateModel; Metadata and Tag |
| Resend | POST https://api.resend.com/emails or /emails/batch (a JSON array) | Authorization: Bearer … | Up to 100 per batch, no attachments | Idempotency-Key header, kept 24 hours | Templates or HTML; tags; GET /emails/{id} shows the latest event |
| SendGrid | POST https://api.sendgrid.com/v3/mail/send | Authorization: Bearer … | Up to 1,000 personalizations | None | 202 means accepted. Dynamic templates (template_id, dynamic_template_data); custom_args; asm.group_id for unsubscribe groups |
| Klaviyo (marketing flows) | POST https://a.klaviyo.com/api/events, or bulk event jobs | Authorization: Klaviyo-API-Key … and the revision header | Bulk jobs take up to 1,000 events | unique_id: a repeat for the same profile and metric is ignored | 202 means accepted, not processed. Klaviyo flows own the template, consent and unsubscribe |
Check the provider’s current documentation for limits before relying on them. In practice Littleworks’ request size sets the batch: one request body is at most 64 KiB, so use provider-hosted templates and send only each message’s template model. About 100 Postmark template messages fit in 60 KiB; measure the serialized body and stop adding messages before it. A run can make 50 external requests of up to 5 seconds each within its 10 seconds.
Where the data lives
Keep these collections in the feature that sends (for example quotes). Data is scoped by store and feature, and an atomic db.batch works within one feature, so the outbox lives beside the records it emails about.
| Collection | Document | Why |
|---|---|---|
outbox | ID: the SHA-256 of a send key such as quote_reply:{quoteId}:{address} (document IDs allow letters, digits, _ and -). purpose, kind, to, toHash, customerId, subjectId, model, status, reason, attempts, claimedRunId, claimedAt, sentAt, providerMessageId, lastError (a code, never a body), test | Creating an existing ID conflicts, so the same send is recorded once. The page reads it. Expires after 90 days to limit documents and personal data |
suppressions | ID: the SHA-256 of the lowercased address, which isn’t stored. reason (unsubscribed, hard_bounce, complaint, manual), purpose, source, at | Checked before every send |
settings | One document: from, enabled, testRecipients, reconciledThrough | A sending switch and test allowlist without redeploying |
Statuses: queued, then sending (claimed by a run), then sent; failed when the provider refused it (nothing was sent; safe to retry); unknown when a timeout or interrupted run means it may have been sent (never retried automatically); skipped with a reason (suppressed, test, consent); handled once staff dismiss it. Keep addresses out of logs and errors, and store customerId beside the address so a privacy request can find it. Each outbox record is one document until it expires; check the document allowance (Starter has 2,000) before choosing retention.
Before you build
- Secrets, added by the merchant in Littleworks → Works → Secrets (
get_context.secrets.manageUrl), never in chat: the provider key with the least access that sends, such asPOSTMARK_SERVER_TOKEN(one server),RESEND_API_KEY,SENDGRID_API_KEY(Mail Send) orKLAVIYO_PRIVATE_KEY(events:write); a…_TESTkey for test runs; andEMAIL_LINK_KEY, a random value of at least 32 bytes for unsubscribe links. - A verified sending domain or sender at the provider, with SPF, DKIM and DMARC. Littleworks can’t check this.
- Shopify: no permission is needed to send. Product details for the template come from the included Storefront catalog access. Littleworks can’t read customers’ email addresses or marketing consent without Shopify’s protected customer data approval, and can’t write consent back to Shopify, so unsubscribes are recorded in Littleworks and at the provider.
- Where the address comes from: the customer’s own submission (confirmed by a double opt-in email before alerts), a Liquid prefill for signed-in shoppers (still untrusted), or a merchant’s Shopify Flow workflow that passes the address and consent as Input fields to a Flow work.
- A test plan agreed with the merchant: Postmark’s test token accepts sends without delivering them; Resend has documented test addresses; SendGrid has a sandbox mode; Klaviyo needs a test metric and profile.
The works to build
| Work | Entry point | Behavior |
|---|---|---|
<feature>.email-setup | Callable, run once for real | Write the settings: From, the sending switch and the test recipients |
| The feature’s own work, such as a quote reply action | Wherever the email starts | Create the outbox record with its send key and send it now, or leave it queued |
<feature>.email-dispatch | Schedule, every 15 to 60 minutes | Settle interrupted sends, send queued records in batches, import the provider’s suppressions |
<feature>.unsubscribe | Public HTTP POST from the storefront | Verify the signed link and write the suppression |
<feature>.outbox, <feature>.outbox-stats | Page data | The list (viewPage) and the counts |
<feature>.outbox-manage | Page action | Retry, Send again, Mark handled, Suppress address, Pause and Resume, with revisions |
Works can’t call each other, so keep the send function in one module: CLI users bundle it into each work that sends, and MCP-only agents copy it unchanged. A producer can also only enqueue and let the dispatcher send, which is simpler but waits up to one schedule interval. A validated example of every piece is in the Littleworks repository under examples/recipe-email.
Implementation outline for the agent
- Render the model, not the message. Each purpose maps to a provider template and a small model: product title, URL and price with its currency from Storefront, or a quote reply excerpt. Escape anything customer-written if the merchant insists on local templates.
- Record once. Create the outbox record with the hashed send key as its ID. A conflict means the same send was requested before: report its status and never send it again. This makes repeated calls, duplicate events and double clicks safe.
- Gate every send. Check the sending switch, then suppressions for the address hashes (
$intakes 20 values a query): an unsubscribe skips subscribed and marketing email, and a bounce or complaint skips everything. Marketing needs consent passed in by the caller. In test runs, send only to the test recipients and skip everyone else with reasontest. - Claim before calling. Move records from queued to sending with this run’s ID, in revision-checked batches of 50. Records another run claimed first fail their revision check and are skipped. Never send a record this run didn’t claim.
- One provider request per batch. Fill it to about 60 KiB and set
timeoutMs. Use Resend’sIdempotency-Keyor Klaviyo’sunique_idwhere available, and carry the send key in PostmarkMetadataor SendGridcustom_args. Checkresponse.ok, then each message’s result by position. - Record outcomes. Accepted becomes sent with the provider’s message ID. A refused request (4xx or 5xx) becomes failed with its code; a Postmark 406 (inactive recipient) also writes a bounce suppression. A timeout or network error after the request left becomes unknown, which is never retried automatically; Resend is the exception, where resending with the same idempotency key within 24 hours is safe.
- Drain across runs. The dispatcher sends up to five batches, about 500 emails, and stops starting batches after 6 seconds. A longer queue continues next run, oldest first. Records still sending a minute after their run started belong to a run that ended, and become unknown.
- Reconcile on the schedule. No webhook is needed. Import Postmark’s suppressions with
GET /message-streams/{stream}/suppressions/dump?fromdate=…from the last import (SendGrid: its bounce, spam report and unsubscribe lists; Resend:GET /emails/{id}for recent sends), and advancereconciledThrough. - Sign unsubscribe links. The token is the base64url of
{h: SHA-256 of the address, p: purpose, f: feature}and its HMAC-SHA-256 withEMAIL_LINK_KEY. The link opens a storefront page (/pages/email-preferences#t=…) whose section removes the token from the address bar and posts it to the unsubscribe work after a click. A token for another feature is refused. While rotating the key, keep the old value asEMAIL_LINK_KEY_PREVIOUS. - Keep it private. Log counts and codes only, never return addresses from public works, and name the provider in the merchant’s privacy policy as a recipient of customer data.
const encoder = new TextEncoder();
const base64url = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
async function unsubscribeToken(secrets, addressHash, purpose) {
const key = await crypto.subtle.importKey("raw", encoder.encode(await secrets.get("EMAIL_LINK_KEY")),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const payload = base64url(encoder.encode(JSON.stringify({ h: addressHash, p: purpose, f: "quotes" })));
return `${payload}.${base64url(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)))}`;
}See what was sent
Build a merchant page for the feature’s outbox: a table with to (email), purpose, a status field (sent success, queued and sending info, failed critical, unknown warning, skipped and handled neutral), reason, lastError.code, customerId (customer), sentAt and $createdAt; tabs Needs attention, Queued, Sent, Skipped and All; search on to. Metrics: sent in 7 days, failed, unknown, queued and suppressed addresses. Retry applies to failed records. Send again applies to unknown ones and confirms that it may email twice. Mark handled and Suppress address close a record, and page-level Pause sending and Resume sending flip the switch. The page uses one published page slot (Starter has 2).
What it costs to run
| Pattern | Runs |
|---|---|
| Quote replies, 200 a month, sent inside the reply action | No extra runs: the email is part of the action’s run |
| Back-in-stock, 2,000 notified a month | Sent inside the scheduled runs, about 500 per run |
| Dispatcher every 15 minutes | 2,880 a month (hourly 720; every 5 minutes 8,640, most of Starter’s 10,000) |
| Flow review requests, 300 a month | About 300 |
Documents: one per outbox record until it expires, one per suppression, and the settings. External requests are included on every plan, up to 50 per run; the provider bills its own emails. Choose the least frequent schedule that keeps the promise, such as “alerts within an hour”.
Acceptance checks
- The same send twice, a duplicate Shopify event, a double click or overlapping runs produce one outbox record and one provider message.
- A timeout after the provider may have accepted becomes unknown and isn’t resent automatically.
- Suppressed, unsubscribed, bounced or non-consenting addresses are skipped with a recorded reason. A tampered token, or one from another feature, changes nothing.
- Test runs never email anyone outside the test recipients, and use the test key. No address or key appears in logs, errors or public responses.
- A queue of more than one batch completes over scheduled runs, oldest first, within the per-run limits. A paused dispatcher holds the queue and sends it after resuming.
- The page shows failures, and Retry respects revisions and suppressions. A provider outage leaves the quote saved and the reply marked failed; the customer’s request never fails because email did.
Make it yours
Send SMS through the same module, add a preference page per purpose, send digests on a schedule, or confirm alert signups with a double opt-in, as the back-in-stock recipe does. Not provided: a mail service, inbound email, attachments beyond the 64 KiB request body, provider webhook signature checks (works receive the JSON body without headers), and an email log across features.