trackslash
TRACK-96 P2

Send email through SMTP2GO's free plan, starting with verified addresses

0
All issues

Description

Trackslash can't send email. This issue adds the minimum that forgot password (TRACK-100) and anything later, such as healthchecks or notifications, will need: SMTP sending and verified email addresses. Trackslash earns no money, so the aim is $0 and simple: no self-hosted mail server, no relay, no queue.

Decision (2026-09-28)

  • Provider: SMTP2GO's free plan. The app talks to it directly over SMTP.
  • Fallback if we outgrow it: Resend's free plan (3,000 a month, 100 a day). Switching is a config change, not a code change.
  • Why SMTP2GO:
    • It's free.
    • It had the best inbox rate in the one independent test that included it (EmailToolTester 2026, 95.5%).
    • It's plain SMTP, so self-hosters of Trackslash can use any provider.
  • Rejected:
    • Self-hosted sending (see the research below).
    • A Postfix relay in front of a provider: no gain for a single app.
    • Postmark: the best inbox placement, but $15 a month.
    • Cloudflare Email Sending: $5 a month for Workers Paid, still in beta, SMTP on port 465 only.
    • A send queue (see Scope 3).

Where things stand (checked 2026-09-28)

  • No email code. There's no SMTP, provider or mailer anywhere.
  • Sign-up doesn't ask for an email. It takes a username and a password or passkey.
  • Nothing is verified. users.email is nullable. It's set in Settings (POST /settings/profile → uiUpdateProfile → store.UpdateUserProfile, which only checks for an "@" via ValidateEmail) or by the admin user API (users.go).
  • Addresses can be claimed by anyone. The column is still UNIQUE from 0001_init.sql, and case-sensitive. So the first account to type an address owns it, even if it isn't theirs.
  • trackslash.com DNS (on Cloudflare) has no MX, SPF or DMARC records.
    • Anyone can spoof mail from the domain.
    • The contact addresses on it bounce. TRACK-101 moves security reports to [email protected] and decides what happens to privacy@ and legal@.
    • badbundle.com already uses Cloudflare Email Routing for inbound mail.
  • Patterns to copy:
    • Env config lives in internal/config/config.go. Web Push's TRACK_SLASH_VAPID_* vars must be set all together or not at all.
    • Features hide themselves when unconfigured, as in PushEnabled: s.webPushPublicKey != "".
    • Links use TRACK_SLASH_PUBLIC_ORIGIN.
    • The limiter is fixedWindowLimiter in auth_rate_limit.go.
    • Reauthentication is in passkeys.go (/settings/passkeys/reauth/*).
    • The per-request deadline is requestTimeoutFor in request_deadline.go.

SMTP2GO facts (checked 2026-09-28)

  • Free plan:
    • Limits: 1,000 emails a month and 200 a day, plus 25 an hour until the sender domain is verified.
    • It never expires.
    • Over the limit, mail is rejected at the server, not queued. SMTP2GO emails warnings at 80%, 90%, 95% and 100%.
  • If we ever pay: the Starter plan is $10 a month for 10,000 emails, then $1 per 1,000.
  • SMTP: host mail.smtp2go.com.
    • STARTTLS on port 2525 (their recommendation), 587, 8025, 80 or 25.
    • Implicit TLS on 465, 8465 or 443.
    • The username and password come from Sending → SMTP Users.
  • Signup: it refuses public email addresses like Gmail and Yahoo. Sign up with a badbundle.com address, which Email Routing forwards.
  • Sender domain verification: three CNAME records:
    • one starting em…, the return path, which gives SPF alignment;
    • one for DKIM;
    • one for link tracking.

Setup (ops, no code)

  1. Apex SPF:
    • Publish v=spf1 -all at trackslash.com, so nobody can pass SPF as the bare domain.
    • SMTP2GO's mail uses its own em… return-path subdomain, which carries its own SPF, so this doesn't affect our sending.
    • If TRACK-101 keeps any contact address on trackslash.com, turn on Cloudflare Email Routing instead and use its record (v=spf1 include:_spf.mx.cloudflare.net ~all).
    • Keep exactly one SPF record at the apex.
  2. SMTP2GO account:
    • Add trackslash.com as a sender domain.
    • Publish its three CNAMEs in Cloudflare as DNS only (grey cloud), then press Verify.
    • Turn off open and click tracking, so links in verification and reset emails aren't rewritten.
    • Create an SMTP user just for Trackslash.
  3. DMARC:
    • Turn on Cloudflare DMARC Management (free). It adds _dmarc v=DMARC1; p=none; rua=<Cloudflare's report address>.
    • After 2–4 weeks of passing, aligned reports, change it to p=quarantine.
  4. Dokploy env for trackd:
    • TRACK_SLASH_SMTP_HOST=mail.smtp2go.com
    • TRACK_SLASH_SMTP_PORT=2525
    • TRACK_SLASH_SMTP_USERNAME=…
    • TRACK_SLASH_SMTP_PASSWORD=…
    • TRACK_SLASH_EMAIL_FROM="Trackslash <[email protected]>"
    • Make sure TRACK_SLASH_PUBLIC_ORIGIN=https://trackslash.com is set.

Scope (code)

  1. Config:
    • The env vars are TRACK_SLASH_SMTP_HOST, _PORT, _USERNAME, _PASSWORD, _TLS (starttls by default, or implicit for 465) and TRACK_SLASH_EMAIL_FROM.
    • They're all-or-nothing, like VAPID, and require TRACK_SLASH_PUBLIC_ORIGIN.
    • Unset means email is off: the app runs as today and the email UI is hidden.
    • TRACK_SLASH_SMTP_HOST=log selects a log mailer for local development, which prints each message and its link.
  2. Mailer: a Mailer interface with a Send(ctx, Message) error method, and three implementations:
    • SMTP. It must use TLS (STARTTLS, or implicit TLS); never authenticate in plaintext. Use a timeout of about 10s, safely under the request deadline.
    • Log, for local development.
    • Capturing, for tests.
  3. Send inline, with no outbox.
    • The handler sends and waits for the result.
    • On failure, including SMTP2GO rejecting mail over quota, the page says "Couldn't send the email. Try again." Nothing is marked as sent, and the error is logged.
    • The verification token is committed before sending, so a failed send only leaves a harmless token that expires.
    • Add a queue later, when notification emails arrive, because those mustn't block requests.
    • TRACK-100 sends password resets in the background instead, so response timing can't reveal whether an account exists.
  4. Messages:
    • A plain-text part plus minimal HTML.
    • Plain subjects, for example "Confirm your email for Trackslash".
    • Links only to TRACK_SLASH_PUBLIC_ORIGIN, never built from the request Host.
    • No images, tracking or link shorteners, and nothing promotional.
  5. Verified addresses:
    • Schema: for example, users.email_verified_at plus an email_verifications table (user, address, token hash, expiry, used-at).
    • Settings: saving a new address makes it pending and sends a confirmation link to it. Tokens are random, stored hashed, single use and expire after 1 hour.
    • Confirming: opening the link (GET) only shows a "Confirm this email" button. Only the POST that button submits verifies the address and uses up the token. Mail scanners such as Outlook Safe Links open links before the person does, so a verifying GET would confirm addresses nobody confirmed.
    • Until confirmed: the previous verified address, if any, stays in effect.
    • Uniqueness: it applies to verified addresses only, and case-insensitively. A pending address never blocks anyone else.
    • Settings UI: Settings shows whether the address is verified, with a "Resend" action.
    • Existing and admin-created addresses start unverified.
    • Rate limits: use the existing limiter, per user and per recipient (about 3 an hour). This stops the form being used to spam an address and keeps us under SMTP2GO's daily cap.
  6. Changing your email:
    • It requires the existing reauthentication.
    • Once the new address is confirmed, the old verified address gets a short "your Trackslash email was changed" notice.
  7. Docs:
    • DEPLOYMENT.md gets an "Email" section: the env vars, the DNS records needed (SPF, DKIM, DMARC), and SMTP2GO as a worked example. It stays provider-neutral otherwise.
    • SECURITY_MODEL.md says what a verified email does and doesn't grant. It also replaces the accepted trade-off "Emails are not verified".

Out of scope

  • Forgot password: TRACK-100, which this issue blocks.
  • Contact addresses (security@, privacy@, legal@): TRACK-101.
  • Healthchecks, notification emails and digests. These will need a queue and a link to notification settings.
  • Self-hosted sending.

Done when

  • With SMTP configured: changing your email in Settings sends a confirmation email. It lands in the inbox, not spam, at a Gmail and an Outlook.com test account, and confirming through the link verifies the address.
  • With it unset: the app runs as today and no email UI shows.
  • When SMTP is down or rejects a message: the user sees an error they can retry, and nothing is marked verified.
  • Deliverability checks: a real message scores 9/10 or better on mail-tester.com, and learndmarc.com shows SPF, DKIM and DMARC passing and aligned.
  • Integration tests cover:
    • token expiry and reuse;
    • a GET of the link not verifying anything;
    • the rate limits;
    • reauthentication being required;
    • the change notice;
    • pending addresses not blocking uniqueness;
    • links using the public origin under a spoofed Host.
  • DEPLOYMENT.md and SECURITY_MODEL.md are updated.

Research (2026-09-28)

Why not self-host sending

  • Mail leaves from the server's own IP. Production is reached through a Cloudflare Tunnel, which only carries incoming web traffic. So sending directly needs a static IPv4 address, reverse DNS you control that matches the server name (PTR, forward DNS and HELO), an IP off Spamhaus's list of home and end-user ranges (PBL), and open port 25.
  • A home broadband connection fails that test:
    • The ISP sets a generic reverse DNS name, which can't be changed.
    • Home ranges are normally on the PBL.
    • Port 25 outbound being open (as it was on the connection checked) isn't enough.
  • If the Dokploy server runs on home broadband, direct sending would land in spam or be rejected.
  • A VPS could work, but costs money and Microsoft is a risk:
    • Most clouds block port 25. GCP and DigitalOcean block it outright; Azure does outside Enterprise plans; AWS, Hetzner Cloud, Vultr and Linode unblock it on request; OVH allows it by default.
    • Even a clean VPS IP is often blocked by Outlook despite correct SPF, DKIM and DMARC, and getting delisted means reaching a human at Microsoft.
  • If self-hosting is ever revisited:
    • Use a static-IP VPS with port 25 open, running Postal (a send-only, Postmark-like server; needs 4 GB of RAM and MariaDB) or Postfix.
    • Send directly, but route Microsoft's consumer domains (outlook.com, hotmail.*, live.*, msn.com) through a free relay. Businesses on Microsoft 365 use their own domains, so they can't be caught this way.
    • Warm the IP up, and watch Google Postmaster Tools and Microsoft SNDS/JMRP.
  • A relay on the current server (Postfix in Dokploy, relaying everything through the provider) works from home broadband too. It only pays off with several apps sharing one provider login and DKIM key, for example Trackslash plus Split Thing.

Free plans

Provider Free allowance Inbox rate in tests
SMTP2GO 1,000/month, 200/day 95.5% (EmailToolTester)
Resend 3,000/month, 100/day not tested; runs on Amazon SES
Brevo 300/day, after account approval 79.8% (EmailToolTester)
Mailtrap 4,000/month, 150/day 77.0% (EmailToolTester), 78.8% (its own test)
Mailgun / MailerSend / Postmark 100/day / 500/month / 100/month too small

Paid, if there's ever revenue

  • Postmark: $15 a month for up to 10,000 emails. 93.8% (EmailToolTester) and 83.3% (Mailtrap's test), top in both.
  • Cloudflare Email Sending: $5 a month (Workers Paid), which includes 3,000 emails, then $0.35 per 1,000. It's in beta: public beta since 2026-04-16, SMTP since 2026-06-08.
  • Amazon SES: about $0.10 per 1,000.

Caveats on the tests

  • EmailToolTester's page uses affiliate links.
  • Mailtrap ran its test as a competitor.
  • Seed tests mostly measure the providers' shared IPs. Our domain's own SPF, DKIM, DMARC and reputation matter as much.

Mailbox rules

  • Gmail, Yahoo and Outlook.com require full SPF, DKIM and DMARC alignment from bulk senders (about 5,000 a day).
  • Outlook.com has rejected failures since May 2025, and Gmail has been ramping up rejections since November 2025.
  • We're far below bulk volume but meet those rules anyway.

Sources

  • SMTP2GO: smtp2go.com/pricing, smtp2go.com/blog/know-your-sending-limits, support.smtp2go.com (SMTP Settings, Verified Senders).
  • Deliverability tests: emailtooltester.com/en/blog/best-transactional-email-service, mailtrap.io/blog/transactional-email-services.
  • Other providers: resend.com/pricing, postmarkapp.com/pricing, developers.cloudflare.com/email-service (pricing, changelog, SMTP docs).
  • Sender rules: support.google.com/mail/answer/14229414, senders.yahooinc.com/best-practices, dmarcian.com/microsoft-enforces-spf-dkim-dmarc.
  • Self-hosting: spamhaus.org/faqs/policy-blocklist-pbl, docs.postalserver.io, and each cloud host's port 25 documentation.

GitHub

0

No branches or pull requests linked.

Comments

1
Bradley

Follow-up filed: TRACK-100 (forgot password), which depends on this issue.

One detail for this issue that came up while writing it: the confirmation link must not verify the address on a GET. Mail scanners such as Outlook Safe Links open links before the person does, so a GET would verify the address without them. Make GET show a "Confirm this email" button, and only the POST it submits verifies the address and uses up the token. Please add this to the tests.