Freshly shipped updates
24 updates in this release, covering 12 additions, 4 improvements, 8 fixes.
Dashboard redesign
Improvedthe signed-in app now has its own compact, near-neutral surface instead of inheriting the marketing site's brand-forward styling. Geist and Geist Mono replace Manrope and Space Grotesk in the app; type drops to a 13-14px UI scale with a 24px page title; controls are 32px with an 8px radius and no drop shadows; surfaces follow a canvas < elevated < base < recessed ladder with hairline borders. Cyan is now an accent rather than decoration: the overview metric cards lost their seven-colour top rules and tinted icon tiles, and StatusBadge is a neutral pill with a semantic dot so a genuinely bad status still stands out in a full table. The theme is scoped to html[data-ui="app"], set per route in the root layout, so the marketing pages are untouched. See dashboard/DESIGN.md.
Agent Plugin package
Addedpackages/agent-plugin/ packages the existing MCP server to the Agent Plugins 1.0.0 standard published in August 2026, so Sendry installs the same way in ChatGPT, Codex, Cursor, GitHub Copilot, Kiro and VS Code. Four skills cover sending, diagnosing a delivery, verifying a domain and reviewing deliverability posture. The manifest carries no env block on purpose: the spec forbids embedding credentials there and expands only PLUGIN_ROOT and PLUGIN_DATA, so a placeholder would be passed through literally and fail silently.
SendGrid as a migration source
Addedcontacts and suppressions import from SendGrid alongside Resend, sharing the same scan, approve and import lifecycle, credential sealing and resume-on-retry. Contact export is asynchronous, so the client starts the job, polls with backoff, downloads, inflates when gzipped and parses. Suppressions are applied strongest first (complaint, hard bounce, unsubscribe) so a retry converges. Templates are deliberately not imported: SendGrid dynamic templates are full Handlebars and Sendry's HTML engine substitutes {{word}} only, so a machine translation would silently corrupt anything carrying logic.
Backup freshness alarm
Addedscripts/check-backup-age.sh alerts when the newest database dump in R2 passes 36 hours or the prefix is empty, delivered through scripts/notify-ops.ts from inside the worker container. Backups had previously stopped for six weeks with nothing to surface it.
Per-recipient delivery for tracked multi-recipient email
Addeda message addressed to several people is now delivered as one copy per recipient when open or click tracking is on, each copy carrying that recipient's own pixel and tracked links, while the To and Cc headers still list everyone. Opening the message in one inbox records an open for that person alone instead of for the message as a whole. Each copy has its own provider message id, so a new email_messages table (migration 0069) maps ids back to (email, recipient) for delivery, bounce and complaint correlation, and makes retries safe by never sending twice to a recipient already delivered to. Providers gained an envelopeTo field: SES uses the raw send path, Mailgun h:To, SMTP the nodemailer envelope.
Country headers are only believed from a trusted edge
Addedthe tracking pixel is a public URL, so anyone could call the origin with a cf-ipcountry of their choosing and poison an org's analytics. When GEO_EDGE_SECRET is set, country headers are read only from requests carrying the matching secret header, which the edge proxy adds. Unset, the header is trusted as before, which suits a deployment with nothing in front of it.
Country is no longer recorded for proxied opens
Addeda Gmail open is fetched by Google's image proxy, not the reader, so geolocating it reported Google's country for every Gmail reader. The user agent parser now flags provider fetchers (Gmail, Yahoo Mail, Apple Privacy Proxy, crawlers) and those opens record no country at all. Clicks are fetched by the reader's own browser and keep theirs, which makes click country the trustworthy signal.
IP to country ranges refresh monthly
Addedthe ranges were baked in at image build time and only changed on deploy. A new geoip-refresh worker re-downloads them on the 2nd of each month and drops the parsed cache, so a long-lived deployment does not drift. A failed refresh leaves the existing files in place.
IP to country data shipped with the image
Addedcountry resolution needed either an edge proxy country header or a MaxMind database, and production had neither, so every open recorded unknown. The image now bakes in the public-domain ip-location-db ranges (scripts/fetch-geoip.ts, no account needed) and resolves against them via GEOIP_CSV_DIR. Private and reserved addresses are never geolocated: the whois-derived data maps 192.168.0.0/16 to AU, so a hit forwarded with a LAN address reports no country rather than a wrong one.
Device, client and country on opens and clicks
Addedthe tracking pixel and click redirect already saw the User-Agent and threw it away. Open and click events now carry the device type, OS, mail client and, when an edge proxy sets a country header or a MaxMind database is configured (GEO_COUNTRY_HEADER, GEOIP_MMDB_PATH), the recipient's country. GET /v1/analytics/engagement-breakdown aggregates any of the three dimensions, the email detail timeline shows them as chips, and the analytics page adds a breakdown section (migration 0065). The raw IP address is never stored.
MTA-STS and TLS-RPT
Addeddomain verification now checks _mta-sts and _smtp._tls, fetches and validates the published MTA-STS policy over HTTPS through the SSRF guard, and the domain detail page gained a Transport security card with the recommended records and policy body. Both are advisory: a domain never becomes unverified because they are missing (migration 0066).
Bulk sender readiness
AddedGmail and Microsoft classify a domain sending 5,000 or more messages a day to their consumer addresses as a bulk sender, permanently, and reject non-compliant mail outright. Sendry now tracks trailing 24 hour volume per domain per mailbox provider, warns from 80% of the threshold when SPF, DKIM or DMARC is not verified, and notifies the org once per domain per provider per day. GET /v1/deliverability/bulk-sender-readiness, a card on the deliverability page, and an hourly bulk-sender-watch worker. BULK_SENDER_THRESHOLD overrides the line for testing.
Template folders
Addedtemplates can be organised into folders, filtered by folder (?folder=, with none for unfiled), moved between folders from the list, and counted via GET /v1/templates/folders (migration 0068). PATCH /v1/templates/:id was added as an alias of the existing PUT, which the docs already described.
Creating an automation is now plan-gated too
Improvedthe run-time check refused a Free org at trigger time, but POST /v1/automations still let one build a workflow that could never fire (a TODO(billing) in the service). Creation now refuses with the same automation_not_available 403. Editing and deleting stay open, so an org that downgrades keeps control of the automations it already has.
The Free plan no longer includes automation runs
Improvedthe pricing page always said automations were not included on Free, but the plan config allowed 100 runs a month and the quota check honoured them. Free is now 0, and a 0-quota plan is refused before any counting with a dedicated automation_not_available (403) instead of a quota-exceeded 429. The silent trigger path for event, schedule and segment-join automations swallowed only plan_limit_reached, so it would have failed open on the new code; it now recognises both. The dashboard presents automations as an upgrade rather than a create flow that could never run, and the automations docs advertised run quotas (Pro 50,000, Business 500,000) that matched no plan and are corrected to 10,000 and 100,000.
Log retention raised across every plan
ImprovedFree goes from 1 day to 30 (Resend gives 30 free, and one day made the free tier feel broken during evaluation), Pro from 7 to 90, Business from 30 to 180, Enterprise from 90 to 365. The ladder was previously non-monotonic in the plan config.
Monitoring alerts never actually sent
FixedsendAlertEmail POSTed to /internal/monitoring/send-alert, a route that exists nowhere in the codebase, so every component-down, SLA-breach and latency alert failed at the fetch and survived only as a log line. The worker already has the provider registry and now sends directly. MONITORING_ALERT_EMAIL and CONTACT_TO were also both empty in production, so ops alerts had no destination and the contact form returned 503.
Published ID prefixes were wrong in four of eight rows
Fixedthe API reference documented em_, ak_, con_ and camp_ where the code generates email_, key_, ct_ and cp_, sending anyone debugging a not_found to look in their own code. The table is corrected and extended to eleven resources, every stale example across the SDK and API docs is fixed, and a test now pins the table to src/lib/id.ts.
Invalidating cached data blanked the view instead of reloading it
FixedinvalidateCache deleted the entry and re-rendered every mounted consumer, but nothing re-triggered their fetch, so a list that invalidated its own cache after a mutation showed its empty state until the next full page load. It now refetches, which is what the helper always documented. Affects every dashboard page that mutates and then invalidates: templates, domains, webhooks, dedicated IPs, DMARC, blocklist, validation, settings and regions.
Multi-recipient emails only tracked the first recipient: a message addressed to several people recorded one sent event against to[0], and the unique index behind open deduplication was keyed on email_id alone, so a single open could exist per message however many people opened it.
FixedThe delivery timeline showed the first address with a full history and everyone else with just the delivery event SES reported. sent events now fan out across to, cc and bcc, and opens deduplicate per (email_id, recipient) (migration 0064). Since one MIME body reaches every addressee, opens and clicks on a multi-recipient message are now recorded against the sentinel recipient *, shown as "All recipients" in the timeline, instead of being credited to whoever is first on the list; single-recipient messages are attributed by address as before.
Public pages ignored the session
Fixedsigning in had no effect on sendry.online: the marketing header still offered "Log in / Start free" and every call to action pushed a second signup. The root loader now exposes the signed-in user to all routes, so the public header shows a Dashboard button plus an account menu (account settings, log out), and the signup CTAs on the landing, features, pricing and feature pages become "Go to dashboard"; pricing plan buttons point at billing instead of registration. Session cookies are now scoped to COOKIE_DOMAIN (default .sendry.online in production, host-only in development), so a session started on app.sendry.online is also recognised on sendry.online.
Campaign send count + unsubscribe merge tag
Fixeddraft and scheduled campaigns now report an estimated recipient count from the selected audience or segment before the send worker seeds campaign_recipients, so the dashboard confirmation no longer says it will send to 0 recipients while preview contacts are visible. Campaign rendering now also exposes {{unsubscribe_url}} as a per-recipient merge variable for senders who want to place their own visible unsubscribe link; the automatic unsubscribe footer and headers remain in place. Test scripts now run Bun with --isolate so module mocks cannot leak across test files.
Campaign rates use recipient event state
Fixedcampaign delivery/bounce/complaint/unsubscribe webhooks now advance the campaign aggregate counters, and campaign reads reconcile stats from campaign_recipients so already-sent campaigns no longer show 0% delivery/open rates when recipient-level delivery and open timestamps exist.
Campaign send plan errors
Fixedimmediate campaign sends now check marketing-plan eligibility before queueing the worker, so Free-plan accounts see the upgrade-required error in the dashboard instead of watching the campaign flip to cancelled without context.