Files
keysat-plans/keysat-smtp-emails.md

24 KiB

Keysat email integration — operator alerts + buyer transactional emails

Context

Today Keysat does NOT send any email. The architecture is deliberately delegation-only: every notable event fires a signed HMAC webhook to whatever endpoints operators have configured (webhook_endpoints table), and the operator's app decides whether to render that as an email, push, in-app banner, Slack DM, etc. The renewal-worker comment in subscriptions.rs makes this explicit: "Keysat does not email buyers itself — operator-driven communication, same as license issuance."

Two cases push against that stance:

  1. Operator-facing operational alerts. When something goes wrong INSIDE Keysat (Zaprite auth dies, webhook deliveries to the operator's own endpoint fail 25 times in a row, the master Keysat license is about to expire) — the operator needs to know NOW. Webhooks are the wrong channel because the operator's webhook receiver is itself part of what might be broken, and the operator doesn't have a separate "Keysat health monitor" service waiting to act on these. Email straight from Keysat to the operator, via StartOS SMTP, is the right channel.

  2. Buyer-facing emails when Keysat IS the brand. The master Keysat instance (running at keysat.xyz, selling Keysat licenses themselves) is the one operator that legitimately wants Keysat-branded buyer emails — the buyer is buying Keysat, so the email should come from "Keysat support." Most operators (selling Recaps, etc.) should NOT have Keysat send buyer emails — their buyer relationship belongs to their own brand and their existing email pipeline. So this needs to be opt-in per merchant profile, with sensible defaults (off everywhere except the master Keysat profile).

The plan implements both, with clean separation between the two audiences so future operators can opt into one without the other.

Dependency on the merchant-profile model

This plan composes with multi-provider-payment-model.md (the merchant-profile foundation). Per-merchant-profile SMTP override fields already land in that plan's merchant_profiles table (so a Pro/Patron operator running 3 businesses can send each one's buyer emails from a different domain with proper SPF/DKIM/DMARC). The per-business email branding fields (sender_name, reply_to, support_url, brand_color) also live on merchant profiles, not products.

This plan's scope is therefore:

  • The notifications module + StartOS SMTP wiring + the Send test email UI (Phase 1 below).
  • The operator-alerts module + Tier-1 alert call sites (Phase 2).
  • The buyer-email infrastructure + templates + per-profile opt-in flag
    • per-event-type opt-outs (Phase 3).
  • Flipping the master Keysat profile's flag to ON (Phase 4).

It assumes the merchant-profile migration has already shipped. If that order flips (we want to ship some email work before merchant-profiles), the schema in this plan would need to be temporarily on products instead, with a follow-up migration to move to merchant_profiles later. That's mechanical but ugly; recommend shipping merchant-profiles first.

High-level shape

  • New notifications module (src/notifications/mod.rs) — provider- agnostic interface (send_email(to, subject, body)), implemented against StartOS SMTP. Future-extensible to other channels (Slack, Discord, etc.) if any operator ever asks.
  • New operator_alerts module — fires notifications::send_email to the operator's address whenever a tracked system-level condition trips. Always on; no opt-in. Recipient = operator's email from StartOS or a Keysat setting.
  • Extension of the existing webhook-dispatch path to ALSO send a buyer email when a per-merchant-profile keysat_sends_buyer_emails = true flag is set AND the event type is in the buyer-relevant set. Default off everywhere except the master Keysat profile (Pro/Patron tier required to flip it on for non-master profiles).
  • Template system — markdown/HTML templates per event type, with per-profile override fields already on merchant_profiles (sender name, reply-to, brand color, support URL, custom intro paragraph) so even Keysat-direct emails are sent from the right business with the right branding for each profile. Default templates use the merchant profile's name field.

StartOS SMTP integration

StartOS exposes operator-configured SMTP credentials at the OS level so services don't each have to re-prompt for them. Verify the exact shape before coding (read StartOS 0.4.x SDK docs + look at how an existing StartOS package consumes SMTP — hello-world-startos or nostr-rs-relay likely have examples).

Expected shape based on StartOS 0.4 conventions:

  • StartOS exposes SMTP host + port + username + password + from-address via either an env var injected into the package's container OR a read-only API call to the host (start-cli system smtp get or similar).
  • The start-sdk's TypeScript layer (in our startos/main.ts) is the right place to read and pass them through — either as env vars set on daemon startup or as a generated config file mounted into /data/.
  • Keysat daemon reads them at boot, holds them in AppState::config.smtp, every send_email() call uses them.

Behavior when SMTP isn't configured on the StartOS host: log a WARN ("operator alert dropped: no SMTP configured at StartOS layer; configure in StartOS Settings → Email"), no-op the send, do NOT block the operation that triggered the alert. Keysat's primary job is licensing; email is a notification side-channel.

Behavior when SMTP send fails (auth error, host unreachable): log ERROR with the underlying SMTP error, optionally queue for retry with a small bounded retry budget (3 attempts, exponential backoff). Don't implement a full mailqueue for v1 — failed alerts that retry too many times silently drop, the daemon log is the source of truth. Add a real mailqueue when an operator reports lost alerts.

Operator alerts catalog (always-on, recipient = operator)

Each one is a small tracing-style call site that emits an OperatorAlert event, the operator_alerts module routes to email + audit log. Listed by priority of "operator would want to know within hours."

Tier 1 — operator action required, send immediately

  • payment_provider.auth_failed — Zaprite or BTCPay API call returns 401 on N consecutive attempts. Means the API key was rotated / revoked. Body includes which provider, when last-good was, suggested fix ("re-run Connect ").
  • subscriptions.auto_charge_consecutively_failing — N (default 3) consecutive renewals failed across the active subscription pool, OR M% (default 25%) of recent renewals failed. Pattern detector, not per-sub. Body includes sample failure reasons.
  • webhook_endpoint.failing — a single configured webhook endpoint has hit dead-letter status (failed MAX_ATTEMPTS deliveries). Body includes the endpoint URL, recent error responses, suggested fix.
  • master_license.expiring_soon — master Keysat license expires in N days (default 14). Body includes the upgrade URL.
  • master_license.expired — master Keysat license has expired. Body flags which features are now locked (per tier-cap matrix), upgrade URL.

Tier 2 — operator should know within a day

  • disk_space.low — Keysat-specific (DB growth). StartOS likely also alerts on overall disk, but Keysat can warn earlier if the DB grows unexpectedly. Skip until operator-reported.
  • db.backup_overdue — if Keysat tracks last-backup timestamps. Defer.
  • update_available — new Keysat version published to registry, opt-in. Useful but not v1.

Tier 3 — informational digest

  • weekly_summary — N licenses issued, N revoked, N renewals settled, $X gross revenue this week. Optional, opt-in. Defer.

Throttling. Each alert has a per-alert-kind cooldown (default 6 hours) so a flapping provider doesn't email the operator every minute. Cooldown state persists in a operator_alert_throttle (kind, last_sent_at) table.

Buyer transactional emails (opt-in per merchant profile)

When merchant_profiles.keysat_sends_buyer_emails = 1 for the profile the event's product belongs to, ALSO send a buyer email (in addition to dispatching the webhook). Default 0 everywhere except the master Keysat profile, which defaults to 1 because that's where Keysat IS the brand. Flipping the flag to 1 on a non-default profile is tier-gated to Pro/Patron (Creator-tier operators only have one profile, so the question only matters for them in the master-keysat case; the gate exists for symmetry with the merchant-profile count cap).

Event-to-email mapping

Webhook event Buyer email
license.issued (one-shot purchase OR first-cycle recurring settle) Purchase confirmation + license key delivery — receipt, the actual key, download/install instructions link. Critical for buyers who closed the tab before saving the key.
subscription.renewal_pending Renewal reminder — your next charge is X on Y, link to update card / cancel, link to current subscription state.
subscription.auto_charge_initiated (Optional, off by default) Auto-charge receipt — "we charged $X to your saved card, here's the receipt." Some operators want this; most don't (transaction email fatigue).
subscription.auto_charge_failed Payment failed — action needed — your card declined, here's the manual-pay link, your subscription is in a grace period until Y. Most-important buyer email since it's the only path to recover the cycle.
subscription.renewed Renewal receipt (after settle confirmed). Same content as auto-charge initiated but only sent after confirmation. Pick ONE of this or auto-charge-initiated to avoid double-emails — default to this one.
subscription.lapsed Subscription ended — grace period expired, license no longer active, link to re-subscribe.
code.redeemed (Optional, off by default) Discount-code-tied notification, mostly internal.

Template system

  • One default template per event type, shipped with the binary in src/notifications/templates/<event>.txt (markdown source rendered to HTML + plain-text alternatives).
  • Per-merchant-profile branding overrides already exist on the merchant_profiles table from the multi-provider plan (sender_name, reply_to, support_url, brand_color, support_email, plus the optional SMTP-override block). This plan adds the templating-specific fields (intro_paragraph_md, footer_md, include_license_key, disabled_events) as additional columns on merchant_profiles.
  • Default sender_name = profile's name. Default reply_to = profile's support_email (or operator-level fallback if NULL). Default support_url = profile's support_url. So even with zero customization, emails come from "Recaps support@recaps.cc" not "Keysat noreply@keysat" — the profile's identity is the source of truth.
  • Templates use a small {{handlebars-style}} variable system. Variables available: buyer_email, license_id (last 8 chars for display), policy_slug, product_name, merchant_profile_name, listed_price
    • currency, next_renewal_at, custom fields from the event payload.
  • License-key-delivery template is special: includes the FULL license key (verifiable offline against the operator's pubkey). Operators selling high-value products may want to disable this (deliver out-of-band). Controlled by merchant_profiles.include_license_key (default 1).

Anti-double-email guard

When BOTH a webhook AND a buyer-email fire for the same event, the operator's webhook receiver might ALSO be sending its own email — double inbox spam for the buyer. Mitigations:

  • Admin UI surfaces a clear warning when the operator enables both keysat_sends_buyer_emails AND has an active webhook endpoint subscribed to overlapping events: "Recaps's webhook receiver is also subscribed to subscription.renewal_pending. If it's already sending emails, enabling Keysat-direct emails will duplicate buyer mail."
  • Don't try to programmatically prevent — that's brittle and Keysat can't know what the operator's webhook code does. Warn + let them decide.

Schema changes

New migration 0021_email_notifications.sql (ships AFTER 0020_merchant_profiles.sql from the multi-provider plan):

PRAGMA foreign_keys = ON;

-- Operator's email (the human running the box). Used for operator
-- alerts. Auto-populated from StartOS account on first boot when
-- available; editable in admin UI Settings page.
INSERT OR IGNORE INTO settings(key, value)
    VALUES ('operator_email', NULL);

-- Per-merchant-profile email flags + customization. Most identity-
-- and-branding fields (sender_name, reply_to, support_url, brand_color,
-- support_email, optional SMTP override block) are already on
-- merchant_profiles from migration 0020. This migration adds the
-- email-specific behavior columns.
ALTER TABLE merchant_profiles
    ADD COLUMN keysat_sends_buyer_emails INTEGER NOT NULL DEFAULT 0
    CHECK (keysat_sends_buyer_emails IN (0, 1));
ALTER TABLE merchant_profiles
    ADD COLUMN intro_paragraph_md TEXT;          -- prepended to every email body
ALTER TABLE merchant_profiles
    ADD COLUMN footer_md TEXT;                   -- replaces default footer
ALTER TABLE merchant_profiles
    ADD COLUMN include_license_key INTEGER NOT NULL DEFAULT 1
    CHECK (include_license_key IN (0, 1));
ALTER TABLE merchant_profiles
    ADD COLUMN disabled_events TEXT;             -- JSON array of event_type strings

-- On the master Keysat instance, flip the default profile's flag to ON
-- (it's the one place buyers are buying Keysat-the-product and the
-- email comes from Keysat). On every other operator's instance, the
-- default profile starts with the flag OFF — they keep using their own
-- email pipeline via webhooks. Operators can flip it ON later
-- (tier-gated for non-default profiles).
--
-- This UPDATE is conditional: only flip if the operator_name resolves
-- to "Keysat" (i.e., this is the master instance). Other operators
-- will see their default profile stay at 0.
UPDATE merchant_profiles
   SET keysat_sends_buyer_emails = 1
 WHERE is_default = 1
   AND name = 'Keysat';

-- Throttle state for operator alerts (one cooldown per alert kind).
CREATE TABLE operator_alert_throttle (
    kind            TEXT PRIMARY KEY,
    last_sent_at    TEXT NOT NULL,
    last_payload    TEXT             -- JSON, last alert body for dedup
);

-- Audit trail of emails sent. Operator-debuggable; bounded by a TTL
-- sweep so it doesn't grow forever.
CREATE TABLE email_log (
    id                  TEXT PRIMARY KEY,
    sent_at             TEXT NOT NULL,
    to_address          TEXT NOT NULL,
    subject             TEXT NOT NULL,
    audience            TEXT NOT NULL,   -- 'operator' | 'buyer'
    merchant_profile_id TEXT,            -- which profile's branding/SMTP was used; NULL for operator alerts
    event_type          TEXT,            -- nullable; some operator alerts not event-driven
    product_id          TEXT,            -- nullable for operator alerts
    invoice_id          TEXT,            -- nullable
    subscription_id     TEXT,            -- nullable
    license_id          TEXT,            -- nullable
    status              TEXT NOT NULL,   -- 'sent' | 'failed' | 'dropped_no_smtp'
    error_message       TEXT,
    CHECK (audience IN ('operator', 'buyer')),
    CHECK (status IN ('sent', 'failed', 'dropped_no_smtp'))
);
CREATE INDEX idx_email_log_sent_at ON email_log(sent_at);
CREATE INDEX idx_email_log_audience ON email_log(audience);

Rust code changes

  • New src/notifications/mod.rs:
    • pub struct NotificationsService { default_smtp: SmtpConfig, db: SqlitePool }
    • pub async fn send_email(&self, profile: Option<&MerchantProfile>, to: &str, subject: &str, body_text: &str, body_html: Option<&str>) -> Result<()> — when profile is Some, use the profile's SMTP override fields if present; otherwise fall back to the StartOS-level SMTP. Operator alerts pass None (always uses the default SMTP since they don't belong to any business).
    • Logs to email_log table on every attempt regardless of outcome, tagging which profile's config was used.
    • Implementation: use lettre crate (well-maintained Rust SMTP client) with TLS/STARTTLS support.
  • New src/notifications/templates.rs:
    • Template registry mapping event_type → default template.
    • Render function: takes template + variables + per-profile overrides → returns subject, text body, HTML body.
    • Templates embedded via include_str! so no filesystem dependency in container.
  • New src/operator_alerts.rs:
    • pub fn alert(state: &AppState, kind: OperatorAlertKind, payload: serde_json::Value)
    • Reads throttle state, sends email (via default SMTP, no profile) if cooldown elapsed, persists last_sent_at.
  • New call sites in existing modules:
    • subscriptions.rsauto_charge_consecutively_failing detector (windowed counter)
    • payment/zaprite/client.rs + payment/btcpay/client.rspayment_provider.auth_failed on N consecutive 401s
    • webhooks.rs (delivery worker) — webhook_endpoint.failing when an endpoint hits dead-letter
    • tier.rsmaster_license.expiring_soon / master_license.expired daily check
  • Extend webhooks::dispatch to also call notifications when the product's merchant_profile.keysat_sends_buyer_emails = 1 AND the event is buyer-relevant. The notification call passes the profile, so per-profile SMTP / branding applies.
  • AppState::config gains an smtp: Option<SmtpConfig> field (default StartOS-level SMTP), populated at startup.
  • startos/main.ts (TypeScript wrapper): read StartOS SMTP config and pass it to the daemon as env vars or a generated config file.

Admin UI changes (in web/index.html)

  • Settings page → new "Notifications" disclosure (operator-level):
    • Operator email field (auto-populated from StartOS, editable)
    • Default SMTP status (connected/error/not configured), inherited from StartOS
    • "Send test email to operator" button to verify the default SMTP path
    • List of recent emails sent (paginated, from email_log), filterable by audience (operator vs buyer) and by merchant profile
  • Merchant Profile edit page (already exists from multi-provider plan) → "Buyer emails" disclosure:
    • Toggle: "Have Keysat send buyer emails for this merchant profile" (default off; default on for the master Keysat profile only)
    • Per-event opt-outs (checkboxes for each buyer-event row in the Event-to-email mapping above)
    • "Intro paragraph" + "Footer" markdown fields
    • "Include license key in confirmation email" checkbox
    • "Test send" button rendering one of the templates against the profile's SMTP config + branding
    • Inline warning if any active webhook is subscribed to overlapping events (anti-double-email guard from above)
  • (Branding + SMTP-override fields live on the existing Merchant Profile edit page sections, not duplicated here — they're owned by the multi-provider plan.)

Sequencing

Single cycle if it's all you ship. Otherwise consider this order:

  1. Phase 1 — StartOS SMTP wiring + notifications module (:NN+1). No call sites yet, just the plumbing + a Send test email button on the Settings page so operators can validate the path. Low risk, immediately testable.
  2. Phase 2 — Operator alerts (:NN+2). Wire in the 4-5 Tier-1 alerts. No buyer-facing changes. Operators get immediate value.
  3. Phase 3 — Buyer emails infrastructure (:NN+3). Schema + template engine + admin UI for the per-merchant-profile config. Default everything off — no behavior change yet.
  4. Phase 4 — Buyer email triggers (:NN+4). Wire the webhook- dispatch path to also send emails when configured. Default ON for the master Keysat profile, OFF everywhere else. This is the user-visible cut.

Phases can be one release each (4 routine bumps) or rolled together if they're shipped as a coherent feature.

Verification

  1. Test send from Settings page — verifies StartOS SMTP integration end-to-end without needing a real event to trigger.
  2. Operator alert test: revoke the Zaprite API key on Zaprite's dashboard, trigger any purchase attempt → expect a Tier-1 email within the throttle window naming the provider and suggesting the fix.
  3. Buyer email test (master Keysat): buy a Keysat self-license on the master keysat.xyz instance (its profile has the flag = 1 from the migration) → expect a Keysat-branded confirmation email with the license key, sent from the Keysat profile's SMTP config + branding.
  4. Buyer email test (Recaps operator path): on an operator instance with a Recaps merchant profile (flag = 0), buy a Recaps license → confirm Keysat sends NO buyer email; only the webhook fires for Recaps's receiver to handle.
  5. Per-profile SMTP test (Pro/Patron): on a Pro-tier operator instance, create two profiles, each with its own SMTP override (different sender domains). Buy a product on each. Confirm each buyer email arrives from the correct sender domain with the correct profile branding (verifies the per-profile SMTP routing).
  6. Anti-double-email warning: enable merchant_profiles.keysat_sends_buyer_emails on a profile that has a webhook subscribed to overlapping events → confirm the admin UI shows the warning.
  7. Throttle test: trigger the same operator alert twice in quick succession → confirm only one email sent, second one suppressed, audit log shows the cooldown.

Out of scope for this work — future considerations

  • Other notification channels (Slack, Discord, push). Email is the highest-leverage channel and matches StartOS's existing infrastructure. Other channels are a natural extension if any operator asks.
  • Operator-side email-domain authentication setup (DKIM/SPF configuration help). Keysat consumes whatever StartOS gives it; operators handle their own deliverability hygiene. We can link to a docs page from the Settings UI.
  • HTML email template editor in the admin UI. Operators get markdown + a small variable set; if they want full template control, they use webhooks + their own pipeline. Adding a WYSIWYG editor is a much bigger UI investment.
  • Per-customer email preferences (let buyers opt out of renewal reminders). Not a v1 feature — buyers can ignore emails. Build when someone hits GDPR / CAN-SPAM compliance edge cases at scale.
  • Multi-language email templates. Defer; templates ship English-only.
  • Transactional email service integrations (SendGrid/Postmark/etc. in addition to or instead of StartOS SMTP). StartOS SMTP is the right default — it's already configured and the operator pays nothing extra. Other providers are a deferred option.

How this composes with the multi-provider / merchant-profile plan

The merchant-profile plan ships first (migration 0020) and creates the merchant_profiles table along with the identity, branding, and optional-SMTP-override fields. This plan layers on top (migration 0021) adding the email-behavior columns (keysat_sends_buyer_emails, intro_paragraph_md, footer_md, include_license_key, disabled_events) plus the operator-level operator_email setting, the operator_alert_throttle table, and the email_log table.

Admin UI work is symmetric: the multi-provider plan owns the Merchant Profile edit page's identity, branding, and providers sections. This plan adds a "Buyer emails" disclosure on the same page.

No code conflict — the merchant-profile plan touches payment routing, this plan touches the dispatch tail. They compose cleanly. Shipping them in sequence (rather than as one combined cut) keeps each cycle's scope reviewable.