Initial commit — multi-provider + SMTP plans
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
# 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 <provider>").
|
||||
- `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):
|
||||
|
||||
```sql
|
||||
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.rs` — `auto_charge_consecutively_failing` detector
|
||||
(windowed counter)
|
||||
- `payment/zaprite/client.rs` + `payment/btcpay/client.rs` —
|
||||
`payment_provider.auth_failed` on N consecutive 401s
|
||||
- `webhooks.rs` (delivery worker) — `webhook_endpoint.failing` when
|
||||
an endpoint hits dead-letter
|
||||
- `tier.rs` — `master_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.
|
||||
Reference in New Issue
Block a user