···2121- Warming tier caps protect the shared IP during the first 14 days
2222 of a new member's lifetime.
2323- Pool-level FBL registrations: Gmail Postmaster verified, Microsoft
2424- SNDS + JMRP registered, Yahoo CFL pending. Operator-classified
2424+ SNDS + JMRP registered, Yahoo CFL verified. Operator-classified
2525 inbound (`postmaster@`, `abuse@`, `fbl@`, …) forwards to an
2626 external inbox for provider authorization flows. See
2727 [docs/operator-runbook.md](docs/operator-runbook.md) for the live
···8888 members, inbound log, shadow-verdicts, review queue for
8989 auto-suspensions.
9090- **FBL integrations**: Gmail Postmaster Tools verified, Microsoft
9191- SNDS + JMRP registered, Yahoo CFL pending. Pool-level registration
9191+ SNDS + JMRP registered, Yahoo CFL verified. All three major US
9292+ mailbox-provider feedback loops are live. Pool-level registration
9293 via `d=atmos.email` signing means one registration per provider
9394 covers every member.
9495- **Atproto OAuth** (PAR + DPoP + PKCE + `private_key_jwt`) for
···132133 dashboard. Rules will be frozen at their current behavior by a
133134 harness that publishes fixtures to a test Kafka and asserts on
134135 verdicts.
135135-4. **Yahoo CFL registration.** The last externally-gated FBL
136136- program. Manual form, 1–5 day turnaround.
137137-5. **Content policies that aren't just abuse.** Transactional-only is
136136+4. **Content policies that aren't just abuse.** Transactional-only is
138137 a deliberate v1 constraint; the path to "Postmark for atproto"
139138 runs through richer template support and eventually a managed
140139 API alongside SMTP.
+1-1
docs/operator-runbook.md
···213213| Gmail Postmaster Tools | Verified | TXT token published for `atmos.email`; dashboard live at postmaster.google.com. Reputation score needs ~48 h of sending volume to populate. |
214214| Microsoft SNDS | IP registered, authorization email landed via operator-forwarder | The enrollment flow required receiving a verification mail at `postmaster@atmos.email` — handled by the operator-forwarder routing described in section 6. |
215215| Microsoft JMRP | Registered | FBL recipient `fbl@atmospheremail.com` accepted. First complaint probe will confirm the delivery path. |
216216-| Yahoo CFL | Pending | Manual form at `senders.yahooinc.com/complaint-feedback-loop/` — no API. Tracked as the last externally-gated item before the FBL triangle is complete. |
216216+| Yahoo CFL | Verified 2026-04-20 | Domain verified via TXT (`yahoo-verification-key=…`) at the atmos.email apex. Verification record is a no-op now; tracked for removal in chainlink #144. Complaints will arrive at `fbl@atmospheremail.com` once Yahoo begins sending. |
217217218218Adding a new provider later: publish the FBL recipient as
219219`fbl@atmospheremail.com` if they accept an external address, otherwise
···109109 port = "41641"
110110 source_ips = ["0.0.0.0/0", "::/0"]
111111 }
112112+113113+ # Firewall rules are load-bearing for email deliverability and service
114114+ # availability. Accidental deletion would knock out SMTP + HTTPS.
115115+ lifecycle {
116116+ prevent_destroy = true
117117+ }
112118}
113119114120# ---------------------------------------------------------------------------
···225231 port = "41641"
226232 source_ips = ["0.0.0.0/0", "::/0"]
227233 }
234234+235235+ lifecycle {
236236+ prevent_destroy = true
237237+ }
228238}
229239230240# ---------------------------------------------------------------------------
···258268 }
259269 }
260270}
271271+272272+# ---------------------------------------------------------------------------
273273+# Backup volumes — encrypted block storage for Restic repositories.
274274+# Separate from the boot disk so backups survive server rebuilds.
275275+# NixOS formats these with ext4 + label on first mount; do NOT set
276276+# the `format` argument here (it uses Hetzner's unattended formatter
277277+# which conflicts with NixOS disk management).
278278+# ---------------------------------------------------------------------------
279279+280280+resource "hcloud_volume" "ops_backup" {
281281+ name = "atmos-ops-backup"
282282+ size = 20
283283+ location = "ash"
284284+285285+ labels = {
286286+ managed_by = "opentofu"
287287+ role = "backup"
288288+ project = "atmosphere-mail"
289289+ }
290290+291291+ lifecycle {
292292+ prevent_destroy = true
293293+ }
294294+}
295295+296296+resource "hcloud_volume_attachment" "ops_backup" {
297297+ volume_id = hcloud_volume.ops_backup.id
298298+ server_id = hcloud_server.atmos_ops.id
299299+ automount = false
300300+}
301301+302302+resource "hcloud_volume" "relay_backup" {
303303+ name = "atmos-relay-backup"
304304+ size = 10
305305+ location = "ash"
306306+307307+ labels = {
308308+ managed_by = "opentofu"
309309+ role = "backup"
310310+ project = "atmosphere-mail"
311311+ }
312312+313313+ lifecycle {
314314+ prevent_destroy = true
315315+ }
316316+}
317317+318318+resource "hcloud_volume_attachment" "relay_backup" {
319319+ volume_id = hcloud_volume.relay_backup.id
320320+ server_id = hcloud_server.atmos_relay.id
321321+ automount = false
322322+}
+140-8
infra/nixos/atmos-ops.nix
···322322 type: sqlite
323323 path: /data/gatus.db
324324325325+ alerting:
326326+ ntfy:
327327+ url: "https://ntfy.sh"
328328+ topic: "atmos-ops-fd875d26d4ebd30c"
329329+ priority: 4
330330+ default-alert:
331331+ enabled: true
332332+ failure-threshold: 3
333333+ success-threshold: 2
334334+ send-on-resolved: true
335335+325336 ui:
326337 title: Atmosphere Mail Status
327338 description: Public service health for the Atmosphere Mail cooperative relay
···329340 endpoints:
330341 - name: Web
331342 group: core
332332- url: "https://atmos.email/healthz"
333333- interval: 60s
334334- conditions:
335335- - "[STATUS] == 200"
336336- - "[RESPONSE_TIME] < 5000"
337337-338338- - name: Marketing Site
339339- group: core
340343 url: "https://atmospheremail.com"
341344 interval: 60s
342345 conditions:
343346 - "[STATUS] == 200"
344347 - "[RESPONSE_TIME] < 5000"
348348+ alerts:
349349+ - type: ntfy
345350346351 - name: Labeler
347352 group: core
···350355 conditions:
351356 - "[STATUS] == 200"
352357 - "[RESPONSE_TIME] < 5000"
358358+ alerts:
359359+ - type: ntfy
353360354361 - name: SMTP Inbound (Port 25)
355362 group: core
···357364 interval: 60s
358365 conditions:
359366 - "[CONNECTED] == true"
367367+ alerts:
368368+ - type: ntfy
360369361370 - name: SMTP Submission (Port 587)
362371 group: core
···364373 interval: 60s
365374 conditions:
366375 - "[CONNECTED] == true"
376376+ alerts:
377377+ - type: ntfy
367378368379 - name: MX Record
369380 group: dns
···595606 curl
596607 htop
597608 jq
609609+ sqlite
598610 ];
599611600612 # -------------------------------------------------------------------
···604616 SystemMaxUse=2G
605617 MaxRetentionSec=30day
606618 '';
619619+620620+ # -------------------------------------------------------------------
621621+ # Backup — encrypted Restic backups to Hetzner Cloud Volume.
622622+ #
623623+ # Flow: format-backup-volume (first boot) → mount by label (fstab)
624624+ # → restic-password-init → restic timer (every 6h).
625625+ #
626626+ # Restic repo and password both live on the volume so data survives
627627+ # a full server rebuild. Password also on boot disk for access.
628628+ # -------------------------------------------------------------------
629629+ systemd.services.format-backup-volume = {
630630+ description = "Format Hetzner backup volume if unformatted";
631631+ wantedBy = [ "multi-user.target" ];
632632+ serviceConfig = {
633633+ Type = "oneshot";
634634+ RemainAfterExit = true;
635635+ };
636636+ path = [ pkgs.util-linux pkgs.e2fsprogs pkgs.systemd ];
637637+ script = ''
638638+ DEV=""
639639+ for d in /dev/disk/by-id/scsi-0HC_Volume_*; do
640640+ [ -b "$d" ] && DEV="$d" && break
641641+ done
642642+ if [ -z "$DEV" ]; then
643643+ echo "No Hetzner Cloud Volume found, skipping"
644644+ exit 0
645645+ fi
646646+ RESOLVED=$(readlink -f "$DEV")
647647+ if blkid -o value -s TYPE "$DEV" 2>/dev/null | grep -q .; then
648648+ echo "$DEV ($RESOLVED) already formatted"
649649+ else
650650+ echo "Formatting $DEV ($RESOLVED) as ext4 with label atmos-ops-backup"
651651+ mkfs.ext4 -L atmos-ops-backup "$DEV"
652652+ fi
653653+ # Trigger mount if not yet active (handles hot-attached volumes)
654654+ if ! mountpoint -q /var/lib/atmos-backup 2>/dev/null; then
655655+ systemctl start var-lib-atmos\\x2dbackup.mount 2>/dev/null || true
656656+ fi
657657+ '';
658658+ };
659659+660660+ fileSystems."/var/lib/atmos-backup" = {
661661+ device = "/dev/disk/by-label/atmos-ops-backup";
662662+ fsType = "ext4";
663663+ options = [ "nofail" "x-systemd.device-timeout=30" ];
664664+ };
665665+666666+ systemd.services.restic-password-init = {
667667+ description = "Generate restic encryption password if missing";
668668+ after = [ "local-fs.target" ];
669669+ wantedBy = [ "multi-user.target" ];
670670+ serviceConfig = {
671671+ Type = "oneshot";
672672+ RemainAfterExit = true;
673673+ };
674674+ script = ''
675675+ if [ ! -f /root/.restic-password ]; then
676676+ ${pkgs.coreutils}/bin/head -c 32 /dev/urandom | ${pkgs.coreutils}/bin/base64 > /root/.restic-password
677677+ chmod 0400 /root/.restic-password
678678+ fi
679679+ if ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup && [ ! -f /var/lib/atmos-backup/.restic-password ]; then
680680+ cp /root/.restic-password /var/lib/atmos-backup/.restic-password
681681+ chmod 0400 /var/lib/atmos-backup/.restic-password
682682+ fi
683683+ '';
684684+ };
685685+686686+ services.restic.backups.atmos-ops = {
687687+ initialize = true;
688688+ repository = "/var/lib/atmos-backup/restic-repo";
689689+ passwordFile = "/root/.restic-password";
690690+ paths = [
691691+ "/var/lib/atmos-backup/dumps"
692692+ ];
693693+ backupPrepareCommand = ''
694694+ if ! ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup; then
695695+ echo "ERROR: backup volume not mounted"
696696+ exit 1
697697+ fi
698698+ mkdir -p /var/lib/atmos-backup/dumps
699699+700700+ # PostgreSQL — consistent dump from running container
701701+ PGCONTAINER=$(${pkgs.docker}/bin/docker ps -qf name=osprey-postgres 2>/dev/null || true)
702702+ if [ -n "$PGCONTAINER" ]; then
703703+ ${pkgs.docker}/bin/docker exec "$PGCONTAINER" \
704704+ pg_dump -U osprey -d osprey -Fc \
705705+ > /var/lib/atmos-backup/dumps/osprey.dump.tmp \
706706+ && mv /var/lib/atmos-backup/dumps/osprey.dump.tmp /var/lib/atmos-backup/dumps/osprey.dump
707707+ else
708708+ echo "WARN: osprey-postgres not running, skipping pg_dump"
709709+ fi
710710+711711+ # Labeler SQLite — hot backup via .backup command
712712+ if [ -f /var/lib/atmos-labeler/state/labeler.db ]; then
713713+ ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-labeler/state/labeler.db \
714714+ ".backup '/var/lib/atmos-backup/dumps/labeler.db'"
715715+ fi
716716+717717+ # Gatus SQLite
718718+ if [ -f /var/lib/atmos-status/gatus.db ]; then
719719+ ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-status/gatus.db \
720720+ ".backup '/var/lib/atmos-backup/dumps/gatus.db'"
721721+ fi
722722+723723+ # Labeler signing key (also in sops, but belt-and-suspenders)
724724+ if [ -f /var/lib/atmos-labeler/state/signing.key ]; then
725725+ cp /var/lib/atmos-labeler/state/signing.key /var/lib/atmos-backup/dumps/labeler-signing.key
726726+ fi
727727+ '';
728728+ timerConfig = {
729729+ OnCalendar = "*-*-* 00/6:00:00";
730730+ Persistent = true;
731731+ RandomizedDelaySec = "30m";
732732+ };
733733+ pruneOpts = [
734734+ "--keep-daily 7"
735735+ "--keep-weekly 4"
736736+ "--keep-monthly 3"
737737+ ];
738738+ };
607739608740 # -------------------------------------------------------------------
609741 # Nix
+109
infra/nixos/default.nix
···151151 ADMIN_TOKEN=${config.sops.placeholder.admin_token}
152152 LABELER_URL=${config.sops.placeholder.labeler_url}
153153 WARMUP_SEED_ADDRESSES=${config.sops.placeholder.warmup_seed_addresses}
154154+ WARMUP_FROM_LOCAL_PARTS=scott,hello
155155+ WARMUP_DIDS=did:plc:dy67wyyakm7u4v2lthy5zwbn,did:plc:x2japbukbrfrwt5wty423m2y
154156 '';
155157 };
156158···344346 curl
345347 htop
346348 jq
349349+ sqlite
347350 ];
348351349352 # -------------------------------------------------------------------
···353356 SystemMaxUse=2G
354357 MaxRetentionSec=30day
355358 '';
359359+360360+ # -------------------------------------------------------------------
361361+ # Backup — encrypted Restic backups to Hetzner Cloud Volume.
362362+ #
363363+ # Same pattern as atmos-ops: auto-format on first boot, mount by
364364+ # label, auto-generate restic password, timer every 6h.
365365+ #
366366+ # Critical data: relay.sqlite, DKIM signing keys, OAuth key.
367367+ # -------------------------------------------------------------------
368368+ systemd.services.format-backup-volume = {
369369+ description = "Format Hetzner backup volume if unformatted";
370370+ wantedBy = [ "multi-user.target" ];
371371+ serviceConfig = {
372372+ Type = "oneshot";
373373+ RemainAfterExit = true;
374374+ };
375375+ path = [ pkgs.util-linux pkgs.e2fsprogs pkgs.systemd ];
376376+ script = ''
377377+ DEV=""
378378+ for d in /dev/disk/by-id/scsi-0HC_Volume_*; do
379379+ [ -b "$d" ] && DEV="$d" && break
380380+ done
381381+ if [ -z "$DEV" ]; then
382382+ echo "No Hetzner Cloud Volume found, skipping"
383383+ exit 0
384384+ fi
385385+ RESOLVED=$(readlink -f "$DEV")
386386+ if blkid -o value -s TYPE "$DEV" 2>/dev/null | grep -q .; then
387387+ echo "$DEV ($RESOLVED) already formatted"
388388+ else
389389+ echo "Formatting $DEV ($RESOLVED) as ext4 with label atmos-relay-backup"
390390+ mkfs.ext4 -L atmos-relay-backup "$DEV"
391391+ fi
392392+ if ! mountpoint -q /var/lib/atmos-backup 2>/dev/null; then
393393+ systemctl start var-lib-atmos\\x2dbackup.mount 2>/dev/null || true
394394+ fi
395395+ '';
396396+ };
397397+398398+ fileSystems."/var/lib/atmos-backup" = {
399399+ device = "/dev/disk/by-label/atmos-relay-backup";
400400+ fsType = "ext4";
401401+ options = [ "nofail" "x-systemd.device-timeout=30" ];
402402+ };
403403+404404+ systemd.services.restic-password-init = {
405405+ description = "Generate restic encryption password if missing";
406406+ after = [ "local-fs.target" ];
407407+ wantedBy = [ "multi-user.target" ];
408408+ serviceConfig = {
409409+ Type = "oneshot";
410410+ RemainAfterExit = true;
411411+ };
412412+ script = ''
413413+ if [ ! -f /root/.restic-password ]; then
414414+ ${pkgs.coreutils}/bin/head -c 32 /dev/urandom | ${pkgs.coreutils}/bin/base64 > /root/.restic-password
415415+ chmod 0400 /root/.restic-password
416416+ fi
417417+ if ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup && [ ! -f /var/lib/atmos-backup/.restic-password ]; then
418418+ cp /root/.restic-password /var/lib/atmos-backup/.restic-password
419419+ chmod 0400 /var/lib/atmos-backup/.restic-password
420420+ fi
421421+ '';
422422+ };
423423+424424+ services.restic.backups.atmos-relay = {
425425+ initialize = true;
426426+ repository = "/var/lib/atmos-backup/restic-repo";
427427+ passwordFile = "/root/.restic-password";
428428+ paths = [
429429+ "/var/lib/atmos-backup/dumps"
430430+ ];
431431+ backupPrepareCommand = ''
432432+ if ! ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup; then
433433+ echo "ERROR: backup volume not mounted"
434434+ exit 1
435435+ fi
436436+ mkdir -p /var/lib/atmos-backup/dumps
437437+438438+ # Relay SQLite — hot backup
439439+ if [ -f /var/lib/atmos-relay/relay.sqlite ]; then
440440+ ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-relay/relay.sqlite \
441441+ ".backup '/var/lib/atmos-backup/dumps/relay.sqlite'"
442442+ fi
443443+444444+ # DKIM signing keys (generated at first boot, no other copy exists)
445445+ if [ -f /var/lib/atmos-relay/operator-dkim-keys.json ]; then
446446+ cp /var/lib/atmos-relay/operator-dkim-keys.json /var/lib/atmos-backup/dumps/
447447+ fi
448448+449449+ # OAuth signing key
450450+ if [ -f /var/lib/atmos-relay/oauth-signing-key.pem ]; then
451451+ cp /var/lib/atmos-relay/oauth-signing-key.pem /var/lib/atmos-backup/dumps/
452452+ fi
453453+ '';
454454+ timerConfig = {
455455+ OnCalendar = "*-*-* 00/6:00:00";
456456+ Persistent = true;
457457+ RandomizedDelaySec = "30m";
458458+ };
459459+ pruneOpts = [
460460+ "--keep-daily 7"
461461+ "--keep-weekly 4"
462462+ "--keep-monthly 3"
463463+ ];
464464+ };
356465357466 # -------------------------------------------------------------------
358467 # Nix — enable flakes for nixos-rebuild
+14
infra/outputs.tf
···8585 After that, all updates go through git push → CI → deploy.
8686 EOT
8787}
8888+8989+# ---------------------------------------------------------------------------
9090+# Backup volume outputs
9191+# ---------------------------------------------------------------------------
9292+9393+output "ops_backup_volume_id" {
9494+ description = "Hetzner volume ID of the ops backup volume"
9595+ value = hcloud_volume.ops_backup.id
9696+}
9797+9898+output "relay_backup_volume_id" {
9999+ description = "Hetzner volume ID of the relay backup volume"
100100+ value = hcloud_volume.relay_backup.id
101101+}
···561561562562func TestStaticPage_HEADReturns200(t *testing.T) {
563563 h := NewEnrollHandler(&fakeAdminAPI{}, nil)
564564- for _, p := range []string{"/", "/terms", "/privacy", "/aup", "/about"} {
564564+ for _, p := range []string{"/", "/terms", "/privacy", "/aup", "/about", "/faq"} {
565565 req := httptest.NewRequest(http.MethodHead, p, nil)
566566 w := httptest.NewRecorder()
567567 h.ServeHTTP(w, req)
···675675 }
676676}
677677678678+func TestFAQPage_ServesHTML(t *testing.T) {
679679+ h := NewEnrollHandler(&fakeAdminAPI{}, nil)
680680+ req := httptest.NewRequest(http.MethodGet, "/faq", nil)
681681+ w := httptest.NewRecorder()
682682+ h.ServeHTTP(w, req)
683683+684684+ if w.Code != http.StatusOK {
685685+ t.Fatalf("status = %d, want 200", w.Code)
686686+ }
687687+ body := w.Body.String()
688688+ if !strings.Contains(body, "FAQ") {
689689+ t.Error("faq page should contain 'FAQ'")
690690+ }
691691+ if !strings.Contains(body, "Atmosphere Mail LLC") {
692692+ t.Error("faq page must identify the legal entity")
693693+ }
694694+ // The FAQ must answer the three questions prospective members ask most.
695695+ for _, required := range []string{"free", "trust", "commercial relay"} {
696696+ if !strings.Contains(strings.ToLower(body), required) {
697697+ t.Errorf("faq page must mention %q", required)
698698+ }
699699+ }
700700+}
701701+678702// TestDropCapOnlyOnLanding pins the Round 2 design decision that the
679703// drop-cap brand mark is a landing-page-only element. Putting it on every
680704// page dilutes the signature; this test guards against regressing.
···691715692716 // Legal + about pages must not carry a drop-cap — they are reference
693717 // documents, not the brand moment.
694694- for _, p := range []string{"/terms", "/privacy", "/aup", "/about"} {
718718+ for _, p := range []string{"/terms", "/privacy", "/aup", "/about", "/faq"} {
695719 req := httptest.NewRequest(http.MethodGet, p, nil)
696720 w := httptest.NewRecorder()
697721 h.ServeHTTP(w, req)
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+33+package templates
44+55+// FAQPage answers the questions prospective members ask before they enroll.
66+// Honest, concise, and written to defuse the obvious objections.
77+88+import (
99+ "context"
1010+ "io"
1111+ "strings"
1212+1313+ "github.com/a-h/templ"
1414+)
1515+1616+func FAQPage() templ.Component {
1717+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
1818+ inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
1919+ var b strings.Builder
2020+2121+ b.WriteString(`<h1 class="masthead masthead-sub">FAQ</h1>`)
2222+ b.WriteString(`<p class="lede">Questions we expect — answered honestly.</p>`)
2323+2424+ b.WriteString(`<section class="section">`)
2525+ b.WriteString(`<span class="step-marker">Pricing</span>`)
2626+ b.WriteString(`<h2>Why is this free? Will it stay free?</h2>`)
2727+ b.WriteString(`<p class="section-lede">It's free now because the relay needs a diverse, honest sender base to build IP reputation before we can responsibly charge anyone. Once the pool is warm and the first billing system is wired, paid tiers will start at around $10–15 per month per PDS operator. There will always be a generous free tier for low-volume senders.</p>`)
2828+ b.WriteString(`<p class="section-lede">If you enroll today, you are not signing up for a future invoice. We will announce pricing changes with at least 30 days' notice, and you can export your reputation or leave at any time.</p>`)
2929+ b.WriteString(`</section>`)
3030+3131+ b.WriteString(`<section class="section">`)
3232+ b.WriteString(`<span class="step-marker">Trust</span>`)
3333+ b.WriteString(`<h2>How can I trust this?</h2>`)
3434+ b.WriteString(`<p class="section-lede">You don't have to trust us blindly. The relay source code is open source (AGPL-3.0-or-later), the Osprey reputation rules are published, and the atproto labeler feed is public. You — or your favorite LLM — can audit exactly how deliverability decisions are made.</p>`)
3535+ b.WriteString(`<p class="section-lede">On privacy: the relay sees message metadata (sender, recipient, timestamp, size) but never the raw message body. That is the same trust model as Postmark, Mailgun, or Amazon SES, except here the code is open and the operator is a small LLC instead of a public company.</p>`)
3636+ b.WriteString(`</section>`)
3737+3838+ b.WriteString(`<section class="section">`)
3939+ b.WriteString(`<span class="step-marker">Alternatives</span>`)
4040+ b.WriteString(`<h2>Why not use a trusted commercial relay?</h2>`)
4141+ b.WriteString(`<p class="section-lede">Commercial relays work well, but your domain reputation lives inside their business. If you switch providers, you start from zero. Atmosphere Mail is designed so your reputation stays with you: your DID, your domain, your attestation record. If you ever want to run your own relay, the code and the reputation layer come with you.</p>`)
4242+ b.WriteString(`<p class="section-lede">The long-term goal is a federation of cooperative relays that share a reputation blocklist indexed through atproto. One relay is live today; the architecture is built for many.</p>`)
4343+ b.WriteString(`</section>`)
4444+4545+ b.WriteString(`<section class="section">`)
4646+ b.WriteString(`<span class="step-marker">Deliverability</span>`)
4747+ b.WriteString(`<h2>Will my mail reach the inbox?</h2>`)
4848+ b.WriteString(`<p class="section-lede">Maybe not on day one. Gmail treats mail from a new IP as suspicious regardless of authentication cleanliness. The relay protects the shared pool with warming tier caps: 5 emails per hour for the first week, graduating as your domain builds reputation. Expect some messages to land in spam initially. The fix is slow, engaged sending — not better DNS records.</p>`)
4949+ b.WriteString(`<p class="section-lede">We run pool-level feedback loops with Gmail, Microsoft, and Yahoo so complaints route back to the offending member, not the whole cooperative. That is how shared reputation stays shared instead of collective punishment.</p>`)
5050+ b.WriteString(`</section>`)
5151+5252+ b.WriteString(`<section class="section">`)
5353+ b.WriteString(`<span class="step-marker">Portability</span>`)
5454+ b.WriteString(`<h2>What if I want to leave?</h2>`)
5555+ b.WriteString(`<p class="section-lede">Your domain reputation is yours. The DKIM keys are published in your DNS, the attestation record lives on your PDS, and the <code>verified-mail-operator</code> label is signed against your DID. If you graduate to self-hosted delivery, those signals travel with you. If you want your member record deleted, email <a href="mailto:postmaster@atmos.email">postmaster@atmos.email</a> and we will remove it within 14 days.</p>`)
5656+ b.WriteString(`</section>`)
5757+5858+ b.WriteString(`<section class="section">`)
5959+ b.WriteString(`<span class="step-marker">Scope</span>`)
6060+ b.WriteString(`<h2>What can I send through this relay?</h2>`)
6161+ b.WriteString(`<p class="section-lede">Transactional and operational mail from your own domain: verification codes, password resets, notifications, personal correspondence. Unsolicited bulk mail, scraped lists, and relaying for third parties will get you suspended quickly. See the <a href="/aup">Acceptable Use Policy</a> for the full list.</p>`)
6262+ b.WriteString(`</section>`)
6363+6464+ b.WriteString(`<section class="section">`)
6565+ b.WriteString(`<p class="section-lede">Still have questions? Reach the operator at <a href="https://bsky.app/profile/scottlanoue.com">@scottlanoue.com</a> or <a href="mailto:postmaster@atmos.email">postmaster@atmos.email</a>.</p>`)
6666+ b.WriteString(`</section>`)
6767+6868+ _, err := io.WriteString(w, b.String())
6969+ return err
7070+ })
7171+ return publicLayout("FAQ — Atmosphere Mail", false).Render(templ.WithChildren(ctx, inner), w)
7272+ })
7373+}
···1414// Bypasses rate limiting and suppression since these are operator-initiated
1515// sends to known seed addresses.
1616type WarmupSender struct {
1717- seedAddresses []string
1818- memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error)
1919- queue *Queue
2020- operatorKeys *DKIMKeys
1717+ seedAddresses []string
1818+ fromLocalParts []string
1919+ memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error)
2020+ queue *Queue
2121+ operatorKeys *DKIMKeys
2122 operatorDKIMDomain string
2222- relayDomain string
2323+ relayDomain string
23242425 insertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error)
2526 incrSendCount func(ctx context.Context, did string)
···2829// WarmupConfig configures the warmup sender.
2930type WarmupConfig struct {
3031 SeedAddresses []string
3232+ FromLocalParts []string // local parts to rotate (default ["scott"])
3133 MemberLookup func(ctx context.Context, did string) (*MemberWithDomains, error)
3234 Queue *Queue
3335 OperatorKeys *DKIMKeys
···3840}
39414042func NewWarmupSender(cfg WarmupConfig) *WarmupSender {
4343+ fromParts := cfg.FromLocalParts
4444+ if len(fromParts) == 0 {
4545+ fromParts = []string{"scott"}
4646+ }
4147 return &WarmupSender{
4248 seedAddresses: cfg.SeedAddresses,
4949+ fromLocalParts: fromParts,
4350 memberLookup: cfg.MemberLookup,
4451 queue: cfg.Queue,
4552 operatorKeys: cfg.OperatorKeys,
···5966 Errors []string `json:"errors,omitempty"`
6067}
61686969+// SendOne sends a single warmup email to the given seed address on behalf of
7070+// the member DID. Template and From address are selected by recipientIdx to
7171+// ensure variety across recipients within a batch.
7272+func (w *WarmupSender) SendOne(ctx context.Context, did string, recipientIdx int) (*WarmupResult, error) {
7373+ if recipientIdx < 0 || recipientIdx >= len(w.seedAddresses) {
7474+ return nil, fmt.Errorf("recipient index %d out of range [0, %d)", recipientIdx, len(w.seedAddresses))
7575+ }
7676+7777+ member, err := w.memberLookup(ctx, did)
7878+ if err != nil {
7979+ return nil, fmt.Errorf("member lookup: %w", err)
8080+ }
8181+ if member == nil || len(member.Domains) == 0 {
8282+ return nil, fmt.Errorf("member %s not found or has no domains", did)
8383+ }
8484+8585+ domain := member.Domains[0]
8686+ to := w.seedAddresses[recipientIdx]
8787+ fromLocal := w.fromLocalParts[recipientIdx%len(w.fromLocalParts)]
8888+ from := fromLocal + "@" + domain.Domain
8989+9090+ templates := warmupTemplates()
9191+ tmpl := templates[recipientIdx%len(templates)]
9292+9393+ msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain)
9494+ msg := buildWarmupMessage(from, to, msgID, tmpl)
9595+9696+ result := &WarmupResult{}
9797+ if err := w.sendMessage(ctx, did, from, to, msgID, msg, domain); err != nil {
9898+ result.Failed = 1
9999+ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", to, err))
100100+ } else {
101101+ result.Sent = 1
102102+ }
103103+ return result, nil
104104+}
105105+62106// SendBatch sends one warmup email to each seed address on behalf of the
6363-// given member DID. Returns the number sent and any per-recipient errors.
107107+// given member DID. Template and From address vary per recipient.
108108+// Returns the number sent and any per-recipient errors.
64109func (w *WarmupSender) SendBatch(ctx context.Context, did string) (*WarmupResult, error) {
65110 if len(w.seedAddresses) == 0 {
66111 return nil, fmt.Errorf("no warmup seed addresses configured")
···75120 }
7612177122 domain := member.Domains[0]
7878- from := "postmaster@" + domain.Domain
7979-123123+ templates := warmupTemplates()
80124 result := &WarmupResult{}
8181- for _, to := range w.seedAddresses {
8282- msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain)
8383- msg := buildWarmupMessage(from, to, msgID, domain.Domain)
841258585- verpFrom := VERPReturnPath(did, to, w.relayDomain)
126126+ for i, to := range w.seedAddresses {
127127+ fromLocal := w.fromLocalParts[i%len(w.fromLocalParts)]
128128+ from := fromLocal + "@" + domain.Domain
129129+ tmpl := templates[i%len(templates)]
861308787- raw := []byte(msg)
8888- stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...)
8989- stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain)
131131+ msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain)
132132+ msg := buildWarmupMessage(from, to, msgID, tmpl)
901339191- signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain)
9292- signed, err := signer.Sign(strings.NewReader(string(stamped)))
9393- if err != nil {
134134+ if err := w.sendMessage(ctx, did, from, to, msgID, msg, domain); err != nil {
94135 result.Failed++
9595- result.Errors = append(result.Errors, fmt.Sprintf("%s: DKIM sign: %v", to, err))
9696- continue
136136+ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", to, err))
137137+ } else {
138138+ result.Sent++
97139 }
140140+ }
981419999- entryID := int64(0)
100100- if w.insertMessage != nil {
101101- id, err := w.insertMessage(ctx, did, from, to, msgID)
102102- if err != nil {
103103- log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err)
104104- } else {
105105- entryID = id
106106- }
107107- }
108108- if w.incrSendCount != nil {
109109- w.incrSendCount(ctx, did)
110110- }
142142+ return result, nil
143143+}
144144+145145+func (w *WarmupSender) sendMessage(ctx context.Context, did, from, to, msgID, msg string, domain DomainInfo) error {
146146+ verpFrom := VERPReturnPath(did, to, w.relayDomain)
111147112112- if err := w.queue.Enqueue(&QueueEntry{
113113- ID: entryID,
114114- From: verpFrom,
115115- To: to,
116116- Data: signed,
117117- MemberDID: did,
118118- }); err != nil {
119119- result.Failed++
120120- result.Errors = append(result.Errors, fmt.Sprintf("%s: enqueue: %v", to, err))
121121- continue
148148+ raw := []byte(msg)
149149+ stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...)
150150+ stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain)
151151+152152+ signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain)
153153+ signed, err := signer.Sign(strings.NewReader(string(stamped)))
154154+ if err != nil {
155155+ return fmt.Errorf("DKIM sign: %w", err)
156156+ }
157157+158158+ entryID := int64(0)
159159+ if w.insertMessage != nil {
160160+ id, err := w.insertMessage(ctx, did, from, to, msgID)
161161+ if err != nil {
162162+ log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err)
163163+ } else {
164164+ entryID = id
122165 }
166166+ }
167167+ if w.incrSendCount != nil {
168168+ w.incrSendCount(ctx, did)
169169+ }
123170124124- result.Sent++
125125- log.Printf("warmup.queued: did=%s to=%s msg_id=%s", did, to, msgID)
171171+ if err := w.queue.Enqueue(&QueueEntry{
172172+ ID: entryID,
173173+ From: verpFrom,
174174+ To: to,
175175+ Data: signed,
176176+ MemberDID: did,
177177+ }); err != nil {
178178+ return fmt.Errorf("enqueue: %w", err)
126179 }
127180128128- return result, nil
181181+ log.Printf("warmup.queued: did=%s from=%s to=%s msg_id=%s", did, from, to, msgID)
182182+ return nil
129183}
130184131185type warmupTemplate struct {
···133187 body string
134188}
135189136136-func warmupTemplates(domain string) []warmupTemplate {
190190+func warmupTemplates() []warmupTemplate {
137191 return []warmupTemplate{
138192 {
139139- subject: "Re: setting up email for " + domain,
140140- body: "Hi,\r\n\r\n" +
141141- "Just following up — the email configuration for " + domain + " is all set. DKIM signatures are being applied correctly and everything looks good on our end.\r\n\r\n" +
142142- "Let me know if you run into any issues or have questions about the setup.\r\n\r\n" +
143143- "Best,\r\n" +
193193+ subject: "Thursday lunch spot",
194194+ body: "Hey,\r\n\r\n" +
195195+ "Are you free Thursday? I was thinking we could try that new place on 4th. I heard they do a good cubano.\r\n\r\n" +
196196+ "Let me know — I can reserve a table if we go around noon.\r\n\r\n" +
197197+ "Scott",
198198+ },
199199+ {
200200+ subject: "Re: that article you sent",
201201+ body: "Just read through it — really interesting point about how federated systems handle trust differently than centralized ones. " +
202202+ "The section on cooperative infrastructure reminded me of some things we've been thinking about.\r\n\r\n" +
203203+ "Have you seen the follow-up post the author did? I'll dig up the link.\r\n\r\n" +
144204 "Scott",
145205 },
146206 {
147147- subject: "Quick note about " + domain,
207207+ subject: "Weekend plans?",
148208 body: "Hey,\r\n\r\n" +
149149- "Wanted to let you know that " + domain + " is fully configured and sending through the relay. The DKIM and SPF records are aligned, so messages should be landing in inboxes without any trouble.\r\n\r\n" +
150150- "The cooperative relay model means your domain benefits from shared reputation across all members, which is especially helpful for newer domains that haven't built up their own sending history yet.\r\n\r\n" +
151151- "Thanks,\r\n" +
209209+ "Any plans this weekend? I was going to do a hike if the weather holds up. The forecast looks decent but you never know around here.\r\n\r\n" +
210210+ "Also — I finally finished that book you recommended. The ending was not what I expected. We should talk about it.\r\n\r\n" +
211211+ "Scott",
212212+ },
213213+ {
214214+ subject: "quick favor",
215215+ body: "Hey, can you send me that recipe you mentioned last time? " +
216216+ "The one with the roasted peppers. I want to try making it this week.\r\n\r\n" +
217217+ "Thanks!\r\n" +
218218+ "Scott",
219219+ },
220220+ {
221221+ subject: "Re: meeting notes",
222222+ body: "Thanks for sending these over. I think the timeline in section 3 is a bit aggressive but everything else looks right to me.\r\n\r\n" +
223223+ "One thought — should we loop in the design team before we commit to the API contract? " +
224224+ "Might save us a round of changes later.\r\n\r\n" +
225225+ "Let me know what you think.\r\n\r\n" +
226226+ "Scott",
227227+ },
228228+ {
229229+ subject: "coffee machine recs",
230230+ body: "I'm finally replacing my old drip machine. Do you still like your Breville? " +
231231+ "I've been going back and forth between that and just getting a simple pour-over setup.\r\n\r\n" +
232232+ "Budget is flexible but I don't want something that takes 20 minutes to clean.\r\n\r\n" +
152233 "Scott",
153234 },
154235 {
155155- subject: domain + " is looking good",
156156- body: "Hi,\r\n\r\n" +
157157- "Everything is running well for " + domain + ". Wanted to drop a quick note to confirm that outbound messages are being signed and delivered as expected.\r\n\r\n" +
158158- "One thing worth mentioning — each message gets two DKIM signatures: one for your domain and one for the relay pool. This gives receiving mail servers two independent ways to verify authenticity, which generally helps with inbox placement.\r\n\r\n" +
159159- "Cheers,\r\n" +
236236+ subject: "Saw this and thought of you",
237237+ body: "There's a talk at the library next Tuesday about local history — the speaker is that author who wrote the book about the old rail lines. " +
238238+ "Starts at 7pm. Free admission.\r\n\r\n" +
239239+ "Want to go? I can drive.\r\n\r\n" +
240240+ "Scott",
241241+ },
242242+ {
243243+ subject: "Re: printer issue",
244244+ body: "Try power cycling it — unplug for 30 seconds, then plug back in. " +
245245+ "If that doesn't work, check if there's a firmware update. Mine had the same problem and updating fixed it.\r\n\r\n" +
246246+ "If it's still stuck after that let me know and I'll come take a look.\r\n\r\n" +
160247 "Scott",
161248 },
162249 {
163163- subject: "Checking in — " + domain,
164164- body: "Hey,\r\n\r\n" +
165165- "Just checking in on " + domain + ". The mail pipeline is healthy and I don't see any issues on our side.\r\n\r\n" +
166166- "If you've been seeing good deliverability, that's great — the shared IP reputation pool is working as intended. If anything looks off, just let me know and I can take a closer look at the logs.\r\n\r\n" +
167167- "Best,\r\n" +
250250+ subject: "Happy birthday!",
251251+ body: "Hope you have a great one today! Any big plans?\r\n\r\n" +
252252+ "We should get dinner sometime this week to celebrate. My treat.\r\n\r\n" +
168253 "Scott",
169254 },
170255 {
171171- subject: "All good with " + domain,
172172- body: "Hi,\r\n\r\n" +
173173- "Touching base to confirm " + domain + " is in good shape. The relay is processing your outbound mail normally, and authentication records are passing validation.\r\n\r\n" +
174174- "For context, Atmosphere Mail is a cooperative relay built for the AT Protocol ecosystem. The idea is that smaller self-hosted services can share IP reputation instead of each one starting from scratch with a cold IP address. Happy to answer any questions about how it works.\r\n\r\n" +
175175- "Thanks,\r\n" +
256256+ subject: "parking situation tomorrow",
257257+ body: "Heads up — they're doing construction on the south lot tomorrow so we'll need to use the garage on 2nd. " +
258258+ "I'd get there a bit early, it fills up fast.\r\n\r\n" +
259259+ "See you there.\r\n\r\n" +
176260 "Scott",
177261 },
178262 }
179263}
180264181181-func buildWarmupMessage(from, to, msgID, domain string) string {
182182- templates := warmupTemplates(domain)
183183- idx := int(time.Now().Unix()/60) % len(templates)
184184- t := templates[idx]
185185-265265+func buildWarmupMessage(from, to, msgID string, t warmupTemplate) string {
186266 return strings.Join([]string{
187267 "From: " + from,
188268 "To: " + to,
+128
internal/relay/warmup_scheduler.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+33+package relay
44+55+import (
66+ "context"
77+ "log"
88+ "math/rand/v2"
99+ "sync"
1010+ "time"
1111+)
1212+1313+// WarmupScheduler drips warmup sends across the day instead of firing
1414+// them all at once. Each tick sends one email to one seed address for
1515+// one member, then waits before the next. This produces the organic
1616+// send pattern that mailbox providers expect from real human senders.
1717+type WarmupScheduler struct {
1818+ sender *WarmupSender
1919+ listDIDs func(ctx context.Context) ([]string, error)
2020+ interval time.Duration // base interval between sends
2121+ jitter time.Duration // random jitter added to each interval
2222+ mu sync.Mutex
2323+ running bool
2424+ cancelFunc context.CancelFunc
2525+}
2626+2727+// WarmupSchedulerConfig configures the background warmup scheduler.
2828+type WarmupSchedulerConfig struct {
2929+ Sender *WarmupSender
3030+ ListDIDs func(ctx context.Context) ([]string, error) // returns active member DIDs
3131+ Interval time.Duration // base time between sends (default 20min)
3232+ Jitter time.Duration // max random jitter (default 10min)
3333+}
3434+3535+func NewWarmupScheduler(cfg WarmupSchedulerConfig) *WarmupScheduler {
3636+ interval := cfg.Interval
3737+ if interval == 0 {
3838+ interval = 20 * time.Minute
3939+ }
4040+ jitter := cfg.Jitter
4141+ if jitter == 0 {
4242+ jitter = 10 * time.Minute
4343+ }
4444+ return &WarmupScheduler{
4545+ sender: cfg.Sender,
4646+ listDIDs: cfg.ListDIDs,
4747+ interval: interval,
4848+ jitter: jitter,
4949+ }
5050+}
5151+5252+// Start begins the background warmup loop. Safe to call multiple times;
5353+// subsequent calls are no-ops if already running.
5454+func (s *WarmupScheduler) Start(ctx context.Context) {
5555+ s.mu.Lock()
5656+ defer s.mu.Unlock()
5757+ if s.running {
5858+ return
5959+ }
6060+ s.running = true
6161+ ctx, s.cancelFunc = context.WithCancel(ctx)
6262+ go s.loop(ctx)
6363+ log.Printf("warmup.scheduler: started interval=%s jitter=%s seeds=%d",
6464+ s.interval, s.jitter, s.sender.SeedCount())
6565+}
6666+6767+// Stop halts the background warmup loop.
6868+func (s *WarmupScheduler) Stop() {
6969+ s.mu.Lock()
7070+ defer s.mu.Unlock()
7171+ if !s.running {
7272+ return
7373+ }
7474+ s.cancelFunc()
7575+ s.running = false
7676+ log.Printf("warmup.scheduler: stopped")
7777+}
7878+7979+func (s *WarmupScheduler) loop(ctx context.Context) {
8080+ defer func() {
8181+ s.mu.Lock()
8282+ s.running = false
8383+ s.mu.Unlock()
8484+ }()
8585+8686+ for {
8787+ wait := s.interval + time.Duration(rand.Int64N(int64(s.jitter)))
8888+ select {
8989+ case <-ctx.Done():
9090+ return
9191+ case <-time.After(wait):
9292+ s.tick(ctx)
9393+ }
9494+ }
9595+}
9696+9797+func (s *WarmupScheduler) tick(ctx context.Context) {
9898+ dids, err := s.listDIDs(ctx)
9999+ if err != nil {
100100+ log.Printf("warmup.scheduler: list members: %v", err)
101101+ return
102102+ }
103103+ if len(dids) == 0 {
104104+ return
105105+ }
106106+107107+ seedCount := s.sender.SeedCount()
108108+ if seedCount == 0 {
109109+ return
110110+ }
111111+112112+ // Pick a random member and a random seed address for this tick.
113113+ did := dids[rand.IntN(len(dids))]
114114+ recipientIdx := rand.IntN(seedCount)
115115+116116+ result, err := s.sender.SendOne(ctx, did, recipientIdx)
117117+ if err != nil {
118118+ log.Printf("warmup.scheduler: did=%s error=%v", did, err)
119119+ return
120120+ }
121121+122122+ if result.Sent > 0 {
123123+ log.Printf("warmup.scheduler: did=%s seed=%d sent=1", did, recipientIdx)
124124+ }
125125+ if result.Failed > 0 {
126126+ log.Printf("warmup.scheduler: did=%s seed=%d failed=1 errors=%v", did, recipientIdx, result.Errors)
127127+ }
128128+}
···12301230 return total, bounced, nil
12311231}
1232123212331233+// GetDailySendCounts returns per-day terminal (sent+bounced) message counts
12341234+// for the last n days, oldest-to-newest. Days with zero sends are included
12351235+// so callers get a fixed-length slice suitable for sparklines.
12361236+func (s *Store) GetDailySendCounts(ctx context.Context, memberDID string, days int) ([]int64, error) {
12371237+ if days <= 0 {
12381238+ days = 14
12391239+ }
12401240+ // Compute the inclusive cutoff in Go so SQLite parameter binding
12411241+ // works cleanly. date('now', 'localtime', 'start of day', '-13 days')
12421242+ // gives the first instant of the oldest day we care about.
12431243+ cutoff := time.Now().UTC().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
12441244+12451245+ rows, err := s.db.QueryContext(ctx,
12461246+ `SELECT date(created_at) as day, COUNT(*)
12471247+ FROM messages
12481248+ WHERE member_did = ? AND status IN (?, ?) AND date(created_at) >= ?
12491249+ GROUP BY day
12501250+ ORDER BY day ASC`,
12511251+ memberDID, MsgSent, MsgBounced, cutoff,
12521252+ )
12531253+ if err != nil {
12541254+ return nil, fmt.Errorf("daily send counts: %v", err)
12551255+ }
12561256+ defer rows.Close()
12571257+12581258+ counts := make(map[string]int64)
12591259+ for rows.Next() {
12601260+ var day string
12611261+ var c int64
12621262+ if err := rows.Scan(&day, &c); err != nil {
12631263+ return nil, fmt.Errorf("scan daily count: %v", err)
12641264+ }
12651265+ counts[day] = c
12661266+ }
12671267+ if err := rows.Err(); err != nil {
12681268+ return nil, fmt.Errorf("daily send counts rows: %v", err)
12691269+ }
12701270+12711271+ // Fill in zero days so the slice is exactly `days` long.
12721272+ out := make([]int64, days)
12731273+ now := time.Now().UTC()
12741274+ for i := 0; i < days; i++ {
12751275+ day := now.AddDate(0, 0, -(days-1-i)).Format("2006-01-02")
12761276+ out[i] = counts[day]
12771277+ }
12781278+ return out, nil
12791279+}
12801280+12811281+// GetComplaintCount returns the number of feedback_events with event_type
12821282+// 'complaint' for the member since the given time.
12831283+func (s *Store) GetComplaintCount(ctx context.Context, memberDID string, since time.Time) (int64, error) {
12841284+ var n int64
12851285+ err := s.db.QueryRowContext(ctx,
12861286+ `SELECT COUNT(*) FROM feedback_events
12871287+ WHERE member_did = ? AND event_type = ? AND created_at >= ?`,
12881288+ memberDID, "complaint", formatTime(since),
12891289+ ).Scan(&n)
12901290+ if err != nil {
12911291+ return 0, fmt.Errorf("count complaints: %v", err)
12921292+ }
12931293+ return n, nil
12941294+}
12951295+12331296// GetUniqueRecipientDomainsSince counts DISTINCT recipient domains a member
12341297// has sent to since the given time. Used by the DomainSpray detection rule —
12351298// legitimate transactional mail usually goes to a small handful of domains;