Issues: write, generate, schedule & send
An issue is one email you send to a newsletter's subscribers. This article covers the full path: drafting the body, generating a draft from your recent blog posts, embedding posts as cards, sending a test copy to yourself, and finally scheduling or sending it for real — plus what actually happens once a send is running.
Everything here runs in the newsletters service (schema newsletters). Issues live under a newsletter (publication), move through the states draft → scheduled → sending → sent (or failed), and are sent via Resend through the platform gateway.
[!NOTE] The MCP tools below operate on the active workspace — they resolve the tenant from your session, so there's no
workspace_idargument to pass. You only pass ids and fields.
[!NOTE] Brand (logo, colours, font) is global, read from your tenant settings — not stored per newsletter or per issue. You write the body; the send wraps it in your brand and one of the built-in templates (
plain/editorial/promo). You don't manage HTML shells.
1. Drafting an issue
Every issue starts as a draft. The key fact to internalise:
[!IMPORTANT]
body_markdownis the source of truth.body_htmlis a rendered cache that the send worker actually emails. Both are persisted together when you save through the dashboard. The test-send and send-now routes both requirebody_htmlto be present (non-empty) — so the HTML cache must exist before you can preview or send through those paths.
Create a draft under a newsletter:
newsletters_create_issue(
newsletter_id = "<newsletter-uuid>",
title = "June digest",
subject = "What we shipped in June",
body_markdown = "## Hello\n\nThis month...",
body_html = "<h2>Hello</h2><p>This month...</p>"
)
title is the only required field beyond newsletter_id. You can omit subject and bodies and fill them in later with newsletters_update_issue:
newsletters_update_issue(
id = "<issue-uuid>",
patch = { subject: "...", body_markdown: "...", body_html: "..." }
)
| Field | Role |
|---|---|
title |
Internal label for the issue. Used as the subject line if subject is empty at send time (`issue.subject |
subject |
The email subject. Required by the send-now route. |
body_markdown |
Editor source of truth. What you (or the writer agent) edit. |
body_html |
Rendered cache the worker emails. Required by the test-send and send-now routes. |
referenced_post_ids |
Auto-derived on save from the post markers in the body (see §3). Per the schema notes it backs the writer agent's context and tracks what's already been featured; the llm.md also lists "analytics", though there's no analytics consumer in the send path itself. |
[!WARNING] If you patch
body_markdownthrough the API directly, make surebody_htmlreflects it. The dashboard editor saves both together; a raw API patch that updates onlybody_markdownleaves a stale (or empty) HTML cache, and that stale cache is what gets sent. There is no server-side markdown→HTML step at send time — the worker emailsbody_htmlas-is.
Newsletter-level tokens are substituted at send time, so you can use them in the subject or body: {{newsletter_name}}, {{newsletter_description}}, {{year}}, {{date}}. Per-recipient tokens — {{recipient_email}}, {{recipient_name}}, {{unsubscribe_url}}, and {{custom.<key>}} from a subscriber's custom_fields — are filled in per message during the send. {{recipient_name}} falls back to the email's local-part (sub.email.split('@')[0]) when custom_fields.name is unset.
The send worker runs ensureUnsubscribeFooter() over the body. Its rule is narrow:
[!WARNING] A compliant unsubscribe footer is appended only when the literal token
{{unsubscribe_url}}is entirely absent from the body. If the token appears anywhere — even inside a code block, an attribute, or a comment —ensureUnsubscribeFootertreats it as "you've placed it yourself" and does nothing. So placement is trusted to you whenever the token is present; the auto-footer is purely a fallback for bodies that omit it.
2. Generating a draft from recent posts
If you don't want to write from scratch, the writer agent can draft an issue for you from your recently published blog posts. This is an async job, not an inline call.
First (optionally) see what's available to feature:
newsletters_recent_posts(
days = 90,
limit = 20,
q = "migration",
language = "en"
)
Each result carries an embed_marker — a ready-to-paste {{post:UUID}} string. Defaults: a 90-day window, 20 results (limit max 50, min 1; days min 1, max 365). q is a title/excerpt substring; language filters by post language.
Then enqueue the draft worker against an existing issue (create the draft first, then generate into it):
newsletters_generate_issue(
id = "<issue-uuid>",
briefing = "Lead with the migration guide; audience is existing customers.",
length = "medium",
use_recent_posts = true,
lookback_days = 90
)
What the worker does:
- Loads the issue + parent newsletter + tenant brand.
- Fetches recent published posts when
use_recent_postsis true (the default) — capped at 12 candidates internally (this is what the writer actually sees, not the 20 the standalonerecent_poststool returns). - Calls the writer model (Claude Sonnet by default — the model is
process.env.NEWSLETTERS_WRITER_MODEL || 'claude-sonnet-4-6') once, single-turn, and parses the JSON result. - Writes
title,subject, andbody_markdownback to the issue, and re-derivesreferenced_post_idsfrom the markers the model used. The body uses{{post:UUID}}markers to embed posts as cards.
| Parameter | Default | Notes |
|---|---|---|
briefing |
— | Free-text angle/audience/which posts to lead with. Up to 8000 chars. |
length |
medium |
short / medium (≈400 words) / long. |
use_recent_posts |
true |
Feed recent published posts to the writer as candidates. |
lookback_days |
90 |
Recency window for candidate posts. Min 7, max 365. (The standalone recent_posts tool has a lower floor of 1 — the two floors are distinct.) |
The call returns a job id immediately; the draft lands roughly 15–45s later. Poll with newsletters_get_issue and watch for body_markdown to change, or track the job in jobs.jobs by related_id = issue id.
What the generate path refuses or clears:
- It refuses
sentandsendingissues. The MCP/HTTP route returns 409 for both; the worker also throws "cannot regenerate" if it reaches one. - If
SERVICE_JOBS_URLis unset, the route returns 503jobs_unavailableand never enqueues. - A
failedissue, when regenerated, is reset todrafton save. Other statuses (draft) are preserved. - It can fail the job on an unparseable LLM response ("writer returned unparseable response") or a parsed result missing
title/subject/body_markdown. - Generation always sets
body_html = nullon save — the HTML cache is cleared. So a test-send or send-now immediately after generate will 400 ("save the draft first") until the body is re-saved (the dashboard editor re-derivesbody_htmlfrom markdown on its next save).
[!NOTE] Generation is a starting point, not a finished issue. Review and edit the result, then re-save through the editor so
body_htmlmatches what you reviewed.
3. Embedding posts: {{post:UUID}}
A post marker is a placeholder you put in the body that expands into a rich email card (cover image, title, excerpt, CTA button) at send time. The marker syntax is the same one the CMS posts pipeline uses, so the same hand-authored markdown works in both places.
Here's the piece I mentioned:
{{post:3f2504e0-4f89-41d3-9a0c-0305e82c3301}}
{{post:3f2504e0-4f89-41d3-9a0c-0305e82c3301|compact}}
Two styles:
{{post:UUID}}— the full card (default).{{post:UUID|compact}}— a smaller row-style card.
Unknown style suffixes (anything other than card / compact) fall through to the full card.
[!NOTE] The expander also resolves a second shape — the
<post-embed id="…" data-style="…">HTML tag that the dashboard editor emits when it serialises its body for preview. You don't author that by hand; both shapes dedupe into the samereferenced_post_ids. For hand-written markdown, the{{post:UUID}}marker is the one to use.
Expansion happens server-side at send time, not in the editor. This is deliberate:
- Posts can change after you draft. You get the latest title, excerpt, and cover at send time, not whatever was current when you inserted the marker.
- Email needs table-based HTML for Gmail/Outlook. That markup stays out of your markdown source.
referenced_post_idsstays accurate because it's derived from the same parse.
Resolution is tenant-scoped and only matches published posts. A marker that points to a deleted post, a draft, or a post in another tenant is stripped — a regex-valid marker that doesn't resolve never leaves a raw {{post:...}} string in the email.
[!WARNING] That "never leaks" guarantee only covers markers that match the marker regex. The style suffix must be lowercase and start with a letter (
[a-z][a-z0-9_-]{0,32}). A malformed suffix — e.g.{{post:UUID|BadStyle}}with an uppercase or leading-digit style — won't match the regex at all, so it isn't expanded or stripped; the literal text stays in the body. Stick tocard/compact(or no suffix).
Where the card links
The card links to ${base}/blog/<slug-or-id> — a fixed /blog/ path on the Nuxt public blog surface. base is resolved by resolveTenantPublicUrl, in order:
- A verified custom blog domain — a
tenant_domainsrow withtarget IN ('blog','both')andverified_at IS NOT NULL. A site-only custom domain (target='site', the per-tenant Next.js site) is deliberately excluded, because it doesn't serve/blog/URLs and would 404. <slug>.<PUBLIC_APP_HOST>(dev/staging), then<slug>.<CANONICAL_PLATFORM_HOST>, then<slug>.<first non-localhost platform host>.null— when there's no verified blog domain AND no slug/platform host resolves. In that case the card URL becomes/blog/<slug>with an empty host, i.e. a relative/broken link. If your tenant has neither a verified blog domain nor a resolvable platform host, card links won't point anywhere useful.
[!TIP] Use
newsletters_recent_poststo get the exact marker, andnewsletters_get_issueafter saving to confirmreferenced_post_idspicked up what you embedded. If a card you expected is missing from a test send, the most likely cause is that the post isn't published or isn't in this tenant.
4. Test send
Before a real send, send one preview copy to yourself:
newsletters_test_send_issue(
id = "<issue-uuid>",
to = "you@example.com"
)
What it does, and just as importantly what it doesn't:
- Renders the body through the same
applyTokens+ensureUnsubscribeFooterpath a real send uses, so the preview is faithful. - Addresses exactly one recipient and prefixes the subject with
[TEST]. - Defaults
toto your own email (derived from your token) if you omit it. - Does not touch
sendsordeliveries— it's a pure preview, leaves no execution record. - Tolerates any issue status — draft, scheduled, even already-sent (handy to re-inspect what went out).
- The unsubscribe link in a test points at
#test-send-no-unsubscribe, not a live unsubscribe.
[!WARNING] Test-send requires
body_htmlto be present, i.e. the issue has been saved at least once (it returns 400 otherwise). If you generated or patched the body and the HTML cache is empty, save it first.
5. Scheduling vs. immediate send
You have two ways to send: schedule for later, or send now.
Schedule
newsletters_schedule_issue(
id = "<issue-uuid>",
scheduled_for = "2026-07-01T09:00:00Z"
)
This flips the issue to scheduled and stores scheduled_for. It does not enqueue anything immediately.
[!WARNING] The schedule route performs no subject/body validation — it only checks
scheduled_foris a valid ISO timestamp. The send worker doesn't re-validate either: it usesissue.subject || issue.titlefor the subject andissue.body_html || ''for the body. So a scheduled issue whosebody_htmlis empty will send an empty-bodied email at its time; it is not blocked "at send time". If you schedule, confirm the body is non-empty yourself (e.g. test-send first, which does requirebody_html).
How scheduled issues actually fire: an in-process scheduler, not a cron service. A setInterval runs every 30 seconds (plus a one-shot tick ~2s after the service boots). On each tick it scans for issues where status='scheduled' and scheduled_for <= now(), capped at 25 per tick, race-safely claims each one (flips scheduled → sending with a guarded update), then enqueues the send job.
[!NOTE] Two practical consequences. First, a scheduled send can fire up to ~30 seconds after its time. Second, the "race-safe claim" means that even across multiple service processes only one process wins the claim for a given issue and dispatches it — you won't get a double send. If enqueueing the job fails after the claim, the scheduler rolls the issue back to
scheduledso the next tick retries.
Cancel a schedule before it fires:
newsletters_unschedule_issue(
id = "<issue-uuid>"
)
This reverts the issue to draft and clears scheduled_for. It returns 409 if the issue isn't currently scheduled — once a tick has claimed it and flipped it to sending, you can't unschedule it.
Send now
newsletters_send_issue(
id = "<issue-uuid>"
)
This requires subject and body_html (400 otherwise; 409 if the issue is already sending or sent). It sets the issue to sending, then enqueues the send job and returns 202 with {issue_id, status, job_id}.
[!WARNING] Send-now's status flip and enqueue are not atomic. The route sets
status='sending'first, then tries to enqueue. If the jobs client is unconfigured (SERVICE_JOBS_URLunset) or the enqueue throws, it logsnewsletters_send_enqueue_failed, returns 202 withjob_id: null, and leaves the issue stuck insendingwith no job. Unlike the schedule path — which rolls back toscheduledon enqueue failure — send-now does not roll back, and the scheduler only re-dispatchesscheduledrows, notsendingones. So there is no automatic recovery: asendingissue withjob_id: nullis stuck until you intervene.
| Schedule | Send now | |
|---|---|---|
| Tool | newsletters_schedule_issue |
newsletters_send_issue |
| Status after call | scheduled |
sending |
| Enqueues a job? | No — next scheduler tick (≤30s) | Attempts immediately (but see below) |
Validates subject + body_html? |
No — neither route nor worker checks; empty body sends empty | Yes, up front (400 if missing) |
| On enqueue failure | Rolls back to scheduled; retried next tick |
No rollback — stuck in sending, job_id: null |
| Cancellable? | Yes, while still scheduled (newsletters_unschedule_issue) |
No (already dispatched) |
6. The send state machine
Once the job runs (whether from a schedule tick or send-now), one worker handles it. The chain of records:
issue (status: sending → sent | failed)
└─ send (one row per execution: running → succeeded | partial | failed)
└─ deliveries (one row per recipient: queued → sent | failed,
then async → delivered | opened | clicked | bounced …)
What the worker does, in order:
- Loads the issue + parent newsletter. If the issue is already
sent, it's a no-op (safe to re-trigger). - Checks the sender domain (see the gate states below). On a gate failure it flips the issue to
failed, callsctx.fail(...), and creates no send row — no emails were attempted. The failure reason lands inissue.metricsas an error object ({error, reason, from_email}), so a gate-failed issue'smetricsholds the error, not{sent, failed}counts. - Resolves the audience. Today this is all subscribers with
status='active'on the parent newsletter (the only honouredaudience_filteris{all:true}; segment/tag filters aren't built yet). Bounced/complained/unsubscribed/pending subscribers are excluded by status. A suppression-list pass then drops any suppressed addresses — but that pass is fail-open: if the suppression query errors, the worker logs a warning and returns the unfiltered audience, so suppressed addresses could be contacted when the suppression read fails. - Opens a
sendsrow in staterunningwithrecipient_count, and pre-inserts onequeueddelivery per recipient (upserted onUNIQUE(send_id, email), chunked at 500). If the audience is empty, the worker marks the issuesucceededwith 0 recipients and returns before opening anysendsrow — so a zero-audience send writes nosendsrow, andnewsletters_list_issue_sendsreturns nothing for it. - Renders once. Applies newsletter-level tokens, expands
{{post:UUID}}markers into cards, wraps the body in your template, and ensures the unsubscribe footer. This rendered shell is shared across recipients. - Sends in batches of 100 (Resend's
/emails/batchcap), through the gateway. For each recipient it mints a per-recipient unsubscribe JWT, fills the per-recipient tokens, and adds RFC 8058List-Unsubscribeheaders (required for Gmail/Yahoo bulk-sender compliance). On success it patches each delivery tosentwith itsresend_message_id; on failure (or a result-shape mismatch) it marks the deliveryfailedwith the error. - Rolls up the result. The
sendsrow becomessucceeded(no failures),partial(some delivered, some failed), orfailed(none delivered). The issue is set tosent(orfailed) withsent_atandmetrics: { sent: delivered, failed }— note the persistedsentkey holds the delivered count, which is distinct from thesentdelivery state.
Opens, clicks, and bounces are not part of the send. They arrive asynchronously later via the Resend webhook and update the matching deliveries row's state and timestamp. A hard bounce or complaint also flips the subscriber's subscriptions.status, so future sends skip that address automatically.
Sender-domain gate states
The gate (checkSenderDomain) decides whether the newsletter's from_email is allowed. Acceptance order, first match wins: an empty from_email is OK (system default sender); a host on the PLATFORM_OWNED_SENDER_DOMAINS env list is OK; otherwise a per-tenant verified_domains row with status='verified' is required. The distinct failure reasons:
| Reason | Meaning |
|---|---|
invalid_from_email |
from_email isn't a syntactically valid address (control chars in the display form are rejected too). |
domain_not_added |
The domain has no verified_domains row in this workspace — add it under Newsletters → Settings → Sender domains. |
domain_status_<status> |
The domain row exists but isn't verified yet (e.g. pending). Different message from "not added": publish the DNS records, then "Check now". |
gate_query_failed |
A non-schema DB error reading verified_domains. |
gate_schema_missing |
The verified_domains table/column is absent (pre-migration). See the asymmetry below. |
[!WARNING] The pre-migration case (
42P01/42703) fails closed in production (gate_schema_missing, send blocked) but open in dev/staging — outside production it allows the send unlessNEWSLETTERS_GATE_FAIL_OPEN_ON_SCHEMA=false, and you can force closed everywhere with that flag. So "the worker fails the issue before sending" is universally true only in production; in non-production with the table missing, it can send anyway.
Multi-language fan-out (translation groups)
If your issue belongs to a translation group (it has translation_group_id set with more than one translated sibling) and the send worker is invoked without a language_filter, the first call doesn't send — it acts as a coordinator:
- It finds the sibling issues in the group.
- It enqueues one child send job per language sibling, each with its own
language_filter, then exits viactx.succeedwithout opening asendsrow or sending anything itself. - Each child then runs the normal single-language flow against its bucket. The sibling you clicked Send on becomes the leader and gets the fallback bucket (subscribers with NULL language preference, or a preference no sibling covers); every other sibling gets an exact
languagematch.
[!NOTE] If the jobs client is missing at fan-out time, the coordinator throws ("jobs client missing — cannot fan out send"). On pre-migration tenants (no
language/translation_group_idcolumns) the worker reads the legacy column set and skips fan-out entirely — it sends as a single bucket. The rest of this article describes that single-language path; fan-out only changes who lands in which audience bucket.
Cancellation mid-send
The worker checks a cancel signal between batches (ctx.cancelSignal.aborted). If a send is aborted, it stops before the next batch of 100 — batches already sent stay sent; remaining recipients are not contacted. There's no per-recipient rollback. (Scheduling, by contrast, is cancellable cleanly via newsletters_unschedule_issue while still scheduled — see §5.)
Watching a send
newsletters_list_issue_sends(id) # one row per execution: recipient_count / delivered / failed / timing
newsletters_list_issue_deliveries(id) # per-recipient state + timestamps; filter by state= or q= (email)
The /issues/:id/metrics endpoint returns the persisted issue.metrics plus a live block (delivered, opened, clicked, bounced, complained, unsubscribed, sent, failed) recomputed from the deliveries rows on every call, so the dashboard view doesn't drift from reality as webhooks land.
[!NOTE] A
sentorsendingissue cannot be deleted — it's kept for the audit trail, since deliveries reference it (the delete route returns 409). Drafts, scheduled, and failed issues can be deleted.
FAQ
Do I need to render body_html myself?
The dashboard editor produces and saves it alongside body_markdown. If you author through the API or the generate worker, make sure the HTML cache reflects your final markdown — the worker emails body_html, and the test-send and send-now routes both require it to be present. (The scheduler/worker do not require it, and will send an empty body if it's empty — so don't rely on the schedule path to catch a missing body.)
Why did my embedded post not show up?
Markers only expand for published posts in your tenant. Deleted, draft, or cross-tenant ids are stripped. Also check the style suffix is card/compact (or absent) — a malformed suffix leaves the literal marker text in the body. Confirm the post is published, then re-run a test send.
Why did my scheduled issue go out a little late?
The scheduler ticks every 30s, so a send fires on the first tick at or after scheduled_for — up to ~30 seconds after the scheduled time.
My send-now returned but the issue is stuck on sending. Why?
If the jobs client was unconfigured or the enqueue threw, send-now still returns 202 but with job_id: null and leaves the issue sending with no job. There's no automatic retry for that state (the scheduler only re-dispatches scheduled rows). Check SERVICE_JOBS_URL is set and the jobs service is reachable.
Can I cancel a send once it's started?
You can cancel a scheduled issue (revert to draft) while it's still scheduled. Once it's sending, you can't unschedule it; an in-flight send only stops cleanly between 100-recipient batches, so anything already dispatched is already gone.
Why did my send fail immediately with no deliveries?
Most likely the sender-domain gate. If the newsletter's from_email host isn't verified (and isn't platform-owned or the system default), the worker fails the issue before sending anything and writes the reason into metrics (e.g. domain_not_added, domain_status_pending, invalid_from_email). Add the domain and publish its DNS records, then re-send.
Can I send to a segment or tag?
Not yet. Audience is all active subscribers of the newsletter ({all:true}). Segment/tag filtering is a planned extension point, not a current feature.