Transactional email
SkillOx sends transactional email only. No marketing, no third-party sharing of your address, no shadow contact lists. This page lists every kind of message we send, what triggers it, and how the opt-out works — critical mail is deliberately not opt-outable, and the page is explicit about which is which.
What we send
Six template names. Four go to creators; two go to the SkillOx admin address only.
creator_welcome
Trigger: first time you sign in via GitHub OAuth and a creator profile is either created from scratch or auto-claimed from a crawler-discovered match. Fires exactly once per account.
Contains: link to your dashboard, creator profile URL, a one-paragraph reminder of which channels are opt-in vs always-on.
grade_drop_alert
Trigger: a re-scan of a SKILL.md claimed by your creator account produces a worse letter grade than the previous one (e.g. B → D). No email when grade stays the same or improves. New skills (no previous scan) don't trigger this.
Contains: old grade, new grade, top five findings by severity, a deep link to the Report Card and to the specific scan ID. Includes an unsubscribe link.
pro_subscription_started
Trigger: Stripe's checkout.session.completed webhook fires successfully. Stripe sends the receipt separately — this is our own confirmation + feature inventory.
pro_subscription_ended
Trigger: Stripe's customer.subscription.deleted webhook (cancellation completes at the end of the current period; we email when the actual end-event lands, not when you click Cancel).
admin_new_creator + admin_critical_finding
These two go to the address in SMTP_ADMIN_TO only — not to any creator. The first fires on every new creator link; the second fires whenever a scan lands at grade F. Useful for spotting outliers fast on a small team.
Opt-out semantics
/account/billing only touches the optional category.Two ways to opt out of the optional category:
- Click Unsubscribe in any grade-drop email. The link lands on a public
/unsubscribe?token=page; one click flips the flag, no sign-in required. - Sign in and use the Grade-drop alerts & digests card on
/account/billing. Toggle flips the same database column either way.
To opt back in, use the same card on /account/billing. The unsubscribe page doesn't have a re-subscribe button because we don't want it to be a one-click reverse — if you unsubscribed, you meant to, and re-subscribing should be a conscious signed-in action.
Unsubscribe-link security model
Unsubscribe links are stateless. The token in /unsubscribe?token=<slug>.<sig> is creator_slug + "." + base64url(HMAC-SHA256(AUTH_SECRET, slug)). No row in any table per token, no expiry table to clean up, no DB write to send a link.
- Tamper-resistant. Flipping even one bit of the signature fails the timing-safe compare and the page returns "token signature invalid".
- Not cross-slug usable. Lifting a signature off Alice's email and pasting it after Bob's slug fails verification — the HMAC is over the slug itself.
- No expiry. Links never expire. That's deliberate: an unsubscribe link from a six-month-old email should still work without making you sign in.
What happens if the mailserver is down
Our outbound stack is a soft-fail singleton — if SMTP_HOST is unset, or the connection fails, the calling flow logs a warning and continues. Sign-in still succeeds, scans still complete, the Stripe webhook still 200s. The trade-off is that you might miss an alert; the alternative (hard-failing on email) was worse.
For production we run a single SMTP transporter per process via Hetzner's webhosting mailserver (port 465, implicit TLS). The transporter is hoisted onto globalThis so dev-time HMR doesn't open a new socket on every code edit.
Admin editor
Template bodies ship as built-in English defaults. Each (name, locale) pair can be overridden from /admin/emails without redeploying. Saving an override writes to the email_templates table; reset to default re-upserts the hardcoded copy so the audit log keeps the edit history.
Each editor card has a Send test to me button that renders the active template with stub vars and ships one copy to the admin's own session email. The subject is prefixed with [TEST]; every attempt — success or failure — lands in the audit log. Useful for sanity-checking the HTML chrome across Gmail, Apple Mail, and Outlook before publishing an override.
Operator config (for self-hosters)
# .env.local — add these and restart the worker + web app SMTP_HOST=mail.skillox.io SMTP_PORT=465 # 465 = implicit TLS, 587 = STARTTLS SMTP_USER=hello@skillox.io SMTP_PASS=… # from your mailserver console SMTP_FROM=SkillOx <hello@skillox.io> SMTP_ADMIN_TO=admin@skillox.io # admin_* templates land here
Leave any of these unset and outbound mail enters soft-fail mode. Useful in CI; surfaced as a banner on /admin/emails so you don't lose track of it in production.
See also
- Data handling + security — the broader policy around what we store and for how long
- Self-hosting — running SkillOx end-to-end on your own infra