Steeren/Issues: write, generate, schedule & sendlive from the platform← site
Engage your audience

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_id argument 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_markdown is the source of truth. body_html is 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 require body_html to 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_markdown through the API directly, make sure body_html reflects it. The dashboard editor saves both together; a raw API patch that updates only body_markdown leaves 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 emails body_html as-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 — ensureUnsubscribeFooter treats 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:

  1. Loads the issue + parent newsletter + tenant brand.
  2. Fetches recent published posts when use_recent_posts is true (the default) — capped at 12 candidates internally (this is what the writer actually sees, not the 20 the standalone recent_posts tool returns).
  3. 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.
  4. Writes title, subject, and body_markdown back to the issue, and re-derives referenced_post_ids from 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 sent and sending issues. The MCP/HTTP route returns 409 for both; the worker also throws "cannot regenerate" if it reaches one.
  • If SERVICE_JOBS_URL is unset, the route returns 503 jobs_unavailable and never enqueues.
  • A failed issue, when regenerated, is reset to draft on 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 = null on 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-derives body_html from 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_html matches 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 same referenced_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_ids stays 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 to card / 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:

  1. A verified custom blog domain — a tenant_domains row with target IN ('blog','both') and verified_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.
  2. <slug>.<PUBLIC_APP_HOST> (dev/staging), then <slug>.<CANONICAL_PLATFORM_HOST>, then <slug>.<first non-localhost platform host>.
  3. 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_posts to get the exact marker, and newsletters_get_issue after saving to confirm referenced_post_ids picked 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 + ensureUnsubscribeFooter path a real send uses, so the preview is faithful.
  • Addresses exactly one recipient and prefixes the subject with [TEST] .
  • Defaults to to your own email (derived from your token) if you omit it.
  • Does not touch sends or deliveries — 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_html to 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_for is a valid ISO timestamp. The send worker doesn't re-validate either: it uses issue.subject || issue.title for the subject and issue.body_html || '' for the body. So a scheduled issue whose body_html is 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 require body_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 scheduled so 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_URL unset) or the enqueue throws, it logs newsletters_send_enqueue_failed, returns 202 with job_id: null, and leaves the issue stuck in sending with no job. Unlike the schedule path — which rolls back to scheduled on enqueue failure — send-now does not roll back, and the scheduler only re-dispatches scheduled rows, not sending ones. So there is no automatic recovery: a sending issue with job_id: null is 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:

  1. Loads the issue + parent newsletter. If the issue is already sent, it's a no-op (safe to re-trigger).
  2. Checks the sender domain (see the gate states below). On a gate failure it flips the issue to failed, calls ctx.fail(...), and creates no send row — no emails were attempted. The failure reason lands in issue.metrics as an error object ({error, reason, from_email}), so a gate-failed issue's metrics holds the error, not {sent, failed} counts.
  3. Resolves the audience. Today this is all subscribers with status='active' on the parent newsletter (the only honoured audience_filter is {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.
  4. Opens a sends row in state running with recipient_count, and pre-inserts one queued delivery per recipient (upserted on UNIQUE(send_id, email), chunked at 500). If the audience is empty, the worker marks the issue succeeded with 0 recipients and returns before opening any sends row — so a zero-audience send writes no sends row, and newsletters_list_issue_sends returns nothing for it.
  5. 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.
  6. Sends in batches of 100 (Resend's /emails/batch cap), through the gateway. For each recipient it mints a per-recipient unsubscribe JWT, fills the per-recipient tokens, and adds RFC 8058 List-Unsubscribe headers (required for Gmail/Yahoo bulk-sender compliance). On success it patches each delivery to sent with its resend_message_id; on failure (or a result-shape mismatch) it marks the delivery failed with the error.
  7. Rolls up the result. The sends row becomes succeeded (no failures), partial (some delivered, some failed), or failed (none delivered). The issue is set to sent (or failed) with sent_at and metrics: { sent: delivered, failed } — note the persisted sent key holds the delivered count, which is distinct from the sent delivery 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 unless NEWSLETTERS_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:

  1. It finds the sibling issues in the group.
  2. It enqueues one child send job per language sibling, each with its own language_filter, then exits via ctx.succeed without opening a sends row or sending anything itself.
  3. 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 language match.

[!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_id columns) 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 sent or sending issue 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.

Served live from the platform · /docs/issues-write-generate-send