Cooperative email for PDS operators
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Warmup deliverability, staggered scheduler, and ops alerting

+1635 -123
+1 -1
README.md
··· 21 21 - Warming tier caps protect the shared IP during the first 14 days 22 22 of a new member's lifetime. 23 23 - Pool-level FBL registrations: Gmail Postmaster verified, Microsoft 24 - SNDS + JMRP registered, Yahoo CFL pending. Operator-classified 24 + SNDS + JMRP registered, Yahoo CFL verified. Operator-classified 25 25 inbound (`postmaster@`, `abuse@`, `fbl@`, …) forwards to an 26 26 external inbox for provider authorization flows. See 27 27 [docs/operator-runbook.md](docs/operator-runbook.md) for the live
+23
cmd/relay/main.go
··· 942 942 for i := range seedList { 943 943 seedList[i] = strings.TrimSpace(seedList[i]) 944 944 } 945 + var fromParts []string 946 + if fp := os.Getenv("WARMUP_FROM_LOCAL_PARTS"); fp != "" { 947 + for _, p := range strings.Split(fp, ",") { 948 + fromParts = append(fromParts, strings.TrimSpace(p)) 949 + } 950 + } 945 951 ws := relay.NewWarmupSender(relay.WarmupConfig{ 946 952 SeedAddresses: seedList, 953 + FromLocalParts: fromParts, 947 954 MemberLookup: memberLookup, 948 955 Queue: queue, 949 956 OperatorKeys: operatorKeys, ··· 965 972 }) 966 973 adminAPI.SetWarmupSender(ws) 967 974 log.Printf("warmup.enabled: seed_count=%d", len(seedList)) 975 + 976 + if warmupDIDsEnv := os.Getenv("WARMUP_DIDS"); warmupDIDsEnv != "" { 977 + var warmupDIDs []string 978 + for _, d := range strings.Split(warmupDIDsEnv, ",") { 979 + warmupDIDs = append(warmupDIDs, strings.TrimSpace(d)) 980 + } 981 + warmupSched := relay.NewWarmupScheduler(relay.WarmupSchedulerConfig{ 982 + Sender: ws, 983 + ListDIDs: func(ctx context.Context) ([]string, error) { 984 + return warmupDIDs, nil 985 + }, 986 + }) 987 + warmupSched.Start(ctx) 988 + defer warmupSched.Stop() 989 + log.Printf("warmup.scheduler: dids=%v", warmupDIDs) 990 + } 968 991 } 969 992 970 993 // Durable notification queue worker (audit #158). Drains
+3 -4
docs/blog-alpha-launch.md
··· 88 88 members, inbound log, shadow-verdicts, review queue for 89 89 auto-suspensions. 90 90 - **FBL integrations**: Gmail Postmaster Tools verified, Microsoft 91 - SNDS + JMRP registered, Yahoo CFL pending. Pool-level registration 91 + SNDS + JMRP registered, Yahoo CFL verified. All three major US 92 + mailbox-provider feedback loops are live. Pool-level registration 92 93 via `d=atmos.email` signing means one registration per provider 93 94 covers every member. 94 95 - **Atproto OAuth** (PAR + DPoP + PKCE + `private_key_jwt`) for ··· 132 133 dashboard. Rules will be frozen at their current behavior by a 133 134 harness that publishes fixtures to a test Kafka and asserts on 134 135 verdicts. 135 - 4. **Yahoo CFL registration.** The last externally-gated FBL 136 - program. Manual form, 1–5 day turnaround. 137 - 5. **Content policies that aren't just abuse.** Transactional-only is 136 + 4. **Content policies that aren't just abuse.** Transactional-only is 138 137 a deliberate v1 constraint; the path to "Postmark for atproto" 139 138 runs through richer template support and eventually a managed 140 139 API alongside SMTP.
+1 -1
docs/operator-runbook.md
··· 213 213 | 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. | 214 214 | 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. | 215 215 | Microsoft JMRP | Registered | FBL recipient `fbl@atmospheremail.com` accepted. First complaint probe will confirm the delivery path. | 216 - | 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. | 216 + | 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. | 217 217 218 218 Adding a new provider later: publish the FBL recipient as 219 219 `fbl@atmospheremail.com` if they accept an external address, otherwise
+2 -2
go.mod
··· 6 6 7 7 require ( 8 8 github.com/a-h/templ v0.3.1001 9 - github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d 9 + github.com/bluesky-social/indigo v0.0.0-20260422192121-9bad73ca4cad 10 10 github.com/emersion/go-msgauth v0.7.0 11 11 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 12 12 github.com/emersion/go-smtp v0.24.0 13 13 github.com/fxamacker/cbor/v2 v2.9.1 14 14 github.com/gorilla/websocket v1.5.3 15 + github.com/jackc/pgx/v5 v5.9.2 15 16 github.com/mr-tron/base58 v1.3.0 16 17 github.com/prometheus/client_golang v1.23.2 17 18 github.com/segmentio/kafka-go v0.4.50 ··· 33 34 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 34 35 github.com/jackc/pgpassfile v1.0.0 // indirect 35 36 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 36 - github.com/jackc/pgx/v5 v5.9.2 // indirect 37 37 github.com/jackc/puddle/v2 v2.2.2 // indirect 38 38 github.com/klauspost/compress v1.18.0 // indirect 39 39 github.com/kylelemons/godebug v1.1.0 // indirect
+2
go.sum
··· 4 4 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 5 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM= 6 6 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= 7 + github.com/bluesky-social/indigo v0.0.0-20260422192121-9bad73ca4cad h1:OWhqcY8bjkTYLSd3lnd2orx8sKaiNGzUH+kdV+JQdkw= 8 + github.com/bluesky-social/indigo v0.0.0-20260422192121-9bad73ca4cad/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= 7 9 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 10 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 11 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+62
infra/main.tf
··· 109 109 port = "41641" 110 110 source_ips = ["0.0.0.0/0", "::/0"] 111 111 } 112 + 113 + # Firewall rules are load-bearing for email deliverability and service 114 + # availability. Accidental deletion would knock out SMTP + HTTPS. 115 + lifecycle { 116 + prevent_destroy = true 117 + } 112 118 } 113 119 114 120 # --------------------------------------------------------------------------- ··· 225 231 port = "41641" 226 232 source_ips = ["0.0.0.0/0", "::/0"] 227 233 } 234 + 235 + lifecycle { 236 + prevent_destroy = true 237 + } 228 238 } 229 239 230 240 # --------------------------------------------------------------------------- ··· 258 268 } 259 269 } 260 270 } 271 + 272 + # --------------------------------------------------------------------------- 273 + # Backup volumes — encrypted block storage for Restic repositories. 274 + # Separate from the boot disk so backups survive server rebuilds. 275 + # NixOS formats these with ext4 + label on first mount; do NOT set 276 + # the `format` argument here (it uses Hetzner's unattended formatter 277 + # which conflicts with NixOS disk management). 278 + # --------------------------------------------------------------------------- 279 + 280 + resource "hcloud_volume" "ops_backup" { 281 + name = "atmos-ops-backup" 282 + size = 20 283 + location = "ash" 284 + 285 + labels = { 286 + managed_by = "opentofu" 287 + role = "backup" 288 + project = "atmosphere-mail" 289 + } 290 + 291 + lifecycle { 292 + prevent_destroy = true 293 + } 294 + } 295 + 296 + resource "hcloud_volume_attachment" "ops_backup" { 297 + volume_id = hcloud_volume.ops_backup.id 298 + server_id = hcloud_server.atmos_ops.id 299 + automount = false 300 + } 301 + 302 + resource "hcloud_volume" "relay_backup" { 303 + name = "atmos-relay-backup" 304 + size = 10 305 + location = "ash" 306 + 307 + labels = { 308 + managed_by = "opentofu" 309 + role = "backup" 310 + project = "atmosphere-mail" 311 + } 312 + 313 + lifecycle { 314 + prevent_destroy = true 315 + } 316 + } 317 + 318 + resource "hcloud_volume_attachment" "relay_backup" { 319 + volume_id = hcloud_volume.relay_backup.id 320 + server_id = hcloud_server.atmos_relay.id 321 + automount = false 322 + }
+140 -8
infra/nixos/atmos-ops.nix
··· 322 322 type: sqlite 323 323 path: /data/gatus.db 324 324 325 + alerting: 326 + ntfy: 327 + url: "https://ntfy.sh" 328 + topic: "atmos-ops-fd875d26d4ebd30c" 329 + priority: 4 330 + default-alert: 331 + enabled: true 332 + failure-threshold: 3 333 + success-threshold: 2 334 + send-on-resolved: true 335 + 325 336 ui: 326 337 title: Atmosphere Mail Status 327 338 description: Public service health for the Atmosphere Mail cooperative relay ··· 329 340 endpoints: 330 341 - name: Web 331 342 group: core 332 - url: "https://atmos.email/healthz" 333 - interval: 60s 334 - conditions: 335 - - "[STATUS] == 200" 336 - - "[RESPONSE_TIME] < 5000" 337 - 338 - - name: Marketing Site 339 - group: core 340 343 url: "https://atmospheremail.com" 341 344 interval: 60s 342 345 conditions: 343 346 - "[STATUS] == 200" 344 347 - "[RESPONSE_TIME] < 5000" 348 + alerts: 349 + - type: ntfy 345 350 346 351 - name: Labeler 347 352 group: core ··· 350 355 conditions: 351 356 - "[STATUS] == 200" 352 357 - "[RESPONSE_TIME] < 5000" 358 + alerts: 359 + - type: ntfy 353 360 354 361 - name: SMTP Inbound (Port 25) 355 362 group: core ··· 357 364 interval: 60s 358 365 conditions: 359 366 - "[CONNECTED] == true" 367 + alerts: 368 + - type: ntfy 360 369 361 370 - name: SMTP Submission (Port 587) 362 371 group: core ··· 364 373 interval: 60s 365 374 conditions: 366 375 - "[CONNECTED] == true" 376 + alerts: 377 + - type: ntfy 367 378 368 379 - name: MX Record 369 380 group: dns ··· 595 606 curl 596 607 htop 597 608 jq 609 + sqlite 598 610 ]; 599 611 600 612 # ------------------------------------------------------------------- ··· 604 616 SystemMaxUse=2G 605 617 MaxRetentionSec=30day 606 618 ''; 619 + 620 + # ------------------------------------------------------------------- 621 + # Backup — encrypted Restic backups to Hetzner Cloud Volume. 622 + # 623 + # Flow: format-backup-volume (first boot) → mount by label (fstab) 624 + # → restic-password-init → restic timer (every 6h). 625 + # 626 + # Restic repo and password both live on the volume so data survives 627 + # a full server rebuild. Password also on boot disk for access. 628 + # ------------------------------------------------------------------- 629 + systemd.services.format-backup-volume = { 630 + description = "Format Hetzner backup volume if unformatted"; 631 + wantedBy = [ "multi-user.target" ]; 632 + serviceConfig = { 633 + Type = "oneshot"; 634 + RemainAfterExit = true; 635 + }; 636 + path = [ pkgs.util-linux pkgs.e2fsprogs pkgs.systemd ]; 637 + script = '' 638 + DEV="" 639 + for d in /dev/disk/by-id/scsi-0HC_Volume_*; do 640 + [ -b "$d" ] && DEV="$d" && break 641 + done 642 + if [ -z "$DEV" ]; then 643 + echo "No Hetzner Cloud Volume found, skipping" 644 + exit 0 645 + fi 646 + RESOLVED=$(readlink -f "$DEV") 647 + if blkid -o value -s TYPE "$DEV" 2>/dev/null | grep -q .; then 648 + echo "$DEV ($RESOLVED) already formatted" 649 + else 650 + echo "Formatting $DEV ($RESOLVED) as ext4 with label atmos-ops-backup" 651 + mkfs.ext4 -L atmos-ops-backup "$DEV" 652 + fi 653 + # Trigger mount if not yet active (handles hot-attached volumes) 654 + if ! mountpoint -q /var/lib/atmos-backup 2>/dev/null; then 655 + systemctl start var-lib-atmos\\x2dbackup.mount 2>/dev/null || true 656 + fi 657 + ''; 658 + }; 659 + 660 + fileSystems."/var/lib/atmos-backup" = { 661 + device = "/dev/disk/by-label/atmos-ops-backup"; 662 + fsType = "ext4"; 663 + options = [ "nofail" "x-systemd.device-timeout=30" ]; 664 + }; 665 + 666 + systemd.services.restic-password-init = { 667 + description = "Generate restic encryption password if missing"; 668 + after = [ "local-fs.target" ]; 669 + wantedBy = [ "multi-user.target" ]; 670 + serviceConfig = { 671 + Type = "oneshot"; 672 + RemainAfterExit = true; 673 + }; 674 + script = '' 675 + if [ ! -f /root/.restic-password ]; then 676 + ${pkgs.coreutils}/bin/head -c 32 /dev/urandom | ${pkgs.coreutils}/bin/base64 > /root/.restic-password 677 + chmod 0400 /root/.restic-password 678 + fi 679 + if ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup && [ ! -f /var/lib/atmos-backup/.restic-password ]; then 680 + cp /root/.restic-password /var/lib/atmos-backup/.restic-password 681 + chmod 0400 /var/lib/atmos-backup/.restic-password 682 + fi 683 + ''; 684 + }; 685 + 686 + services.restic.backups.atmos-ops = { 687 + initialize = true; 688 + repository = "/var/lib/atmos-backup/restic-repo"; 689 + passwordFile = "/root/.restic-password"; 690 + paths = [ 691 + "/var/lib/atmos-backup/dumps" 692 + ]; 693 + backupPrepareCommand = '' 694 + if ! ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup; then 695 + echo "ERROR: backup volume not mounted" 696 + exit 1 697 + fi 698 + mkdir -p /var/lib/atmos-backup/dumps 699 + 700 + # PostgreSQL — consistent dump from running container 701 + PGCONTAINER=$(${pkgs.docker}/bin/docker ps -qf name=osprey-postgres 2>/dev/null || true) 702 + if [ -n "$PGCONTAINER" ]; then 703 + ${pkgs.docker}/bin/docker exec "$PGCONTAINER" \ 704 + pg_dump -U osprey -d osprey -Fc \ 705 + > /var/lib/atmos-backup/dumps/osprey.dump.tmp \ 706 + && mv /var/lib/atmos-backup/dumps/osprey.dump.tmp /var/lib/atmos-backup/dumps/osprey.dump 707 + else 708 + echo "WARN: osprey-postgres not running, skipping pg_dump" 709 + fi 710 + 711 + # Labeler SQLite — hot backup via .backup command 712 + if [ -f /var/lib/atmos-labeler/state/labeler.db ]; then 713 + ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-labeler/state/labeler.db \ 714 + ".backup '/var/lib/atmos-backup/dumps/labeler.db'" 715 + fi 716 + 717 + # Gatus SQLite 718 + if [ -f /var/lib/atmos-status/gatus.db ]; then 719 + ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-status/gatus.db \ 720 + ".backup '/var/lib/atmos-backup/dumps/gatus.db'" 721 + fi 722 + 723 + # Labeler signing key (also in sops, but belt-and-suspenders) 724 + if [ -f /var/lib/atmos-labeler/state/signing.key ]; then 725 + cp /var/lib/atmos-labeler/state/signing.key /var/lib/atmos-backup/dumps/labeler-signing.key 726 + fi 727 + ''; 728 + timerConfig = { 729 + OnCalendar = "*-*-* 00/6:00:00"; 730 + Persistent = true; 731 + RandomizedDelaySec = "30m"; 732 + }; 733 + pruneOpts = [ 734 + "--keep-daily 7" 735 + "--keep-weekly 4" 736 + "--keep-monthly 3" 737 + ]; 738 + }; 607 739 608 740 # ------------------------------------------------------------------- 609 741 # Nix
+109
infra/nixos/default.nix
··· 151 151 ADMIN_TOKEN=${config.sops.placeholder.admin_token} 152 152 LABELER_URL=${config.sops.placeholder.labeler_url} 153 153 WARMUP_SEED_ADDRESSES=${config.sops.placeholder.warmup_seed_addresses} 154 + WARMUP_FROM_LOCAL_PARTS=scott,hello 155 + WARMUP_DIDS=did:plc:dy67wyyakm7u4v2lthy5zwbn,did:plc:x2japbukbrfrwt5wty423m2y 154 156 ''; 155 157 }; 156 158 ··· 344 346 curl 345 347 htop 346 348 jq 349 + sqlite 347 350 ]; 348 351 349 352 # ------------------------------------------------------------------- ··· 353 356 SystemMaxUse=2G 354 357 MaxRetentionSec=30day 355 358 ''; 359 + 360 + # ------------------------------------------------------------------- 361 + # Backup — encrypted Restic backups to Hetzner Cloud Volume. 362 + # 363 + # Same pattern as atmos-ops: auto-format on first boot, mount by 364 + # label, auto-generate restic password, timer every 6h. 365 + # 366 + # Critical data: relay.sqlite, DKIM signing keys, OAuth key. 367 + # ------------------------------------------------------------------- 368 + systemd.services.format-backup-volume = { 369 + description = "Format Hetzner backup volume if unformatted"; 370 + wantedBy = [ "multi-user.target" ]; 371 + serviceConfig = { 372 + Type = "oneshot"; 373 + RemainAfterExit = true; 374 + }; 375 + path = [ pkgs.util-linux pkgs.e2fsprogs pkgs.systemd ]; 376 + script = '' 377 + DEV="" 378 + for d in /dev/disk/by-id/scsi-0HC_Volume_*; do 379 + [ -b "$d" ] && DEV="$d" && break 380 + done 381 + if [ -z "$DEV" ]; then 382 + echo "No Hetzner Cloud Volume found, skipping" 383 + exit 0 384 + fi 385 + RESOLVED=$(readlink -f "$DEV") 386 + if blkid -o value -s TYPE "$DEV" 2>/dev/null | grep -q .; then 387 + echo "$DEV ($RESOLVED) already formatted" 388 + else 389 + echo "Formatting $DEV ($RESOLVED) as ext4 with label atmos-relay-backup" 390 + mkfs.ext4 -L atmos-relay-backup "$DEV" 391 + fi 392 + if ! mountpoint -q /var/lib/atmos-backup 2>/dev/null; then 393 + systemctl start var-lib-atmos\\x2dbackup.mount 2>/dev/null || true 394 + fi 395 + ''; 396 + }; 397 + 398 + fileSystems."/var/lib/atmos-backup" = { 399 + device = "/dev/disk/by-label/atmos-relay-backup"; 400 + fsType = "ext4"; 401 + options = [ "nofail" "x-systemd.device-timeout=30" ]; 402 + }; 403 + 404 + systemd.services.restic-password-init = { 405 + description = "Generate restic encryption password if missing"; 406 + after = [ "local-fs.target" ]; 407 + wantedBy = [ "multi-user.target" ]; 408 + serviceConfig = { 409 + Type = "oneshot"; 410 + RemainAfterExit = true; 411 + }; 412 + script = '' 413 + if [ ! -f /root/.restic-password ]; then 414 + ${pkgs.coreutils}/bin/head -c 32 /dev/urandom | ${pkgs.coreutils}/bin/base64 > /root/.restic-password 415 + chmod 0400 /root/.restic-password 416 + fi 417 + if ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup && [ ! -f /var/lib/atmos-backup/.restic-password ]; then 418 + cp /root/.restic-password /var/lib/atmos-backup/.restic-password 419 + chmod 0400 /var/lib/atmos-backup/.restic-password 420 + fi 421 + ''; 422 + }; 423 + 424 + services.restic.backups.atmos-relay = { 425 + initialize = true; 426 + repository = "/var/lib/atmos-backup/restic-repo"; 427 + passwordFile = "/root/.restic-password"; 428 + paths = [ 429 + "/var/lib/atmos-backup/dumps" 430 + ]; 431 + backupPrepareCommand = '' 432 + if ! ${pkgs.util-linux}/bin/mountpoint -q /var/lib/atmos-backup; then 433 + echo "ERROR: backup volume not mounted" 434 + exit 1 435 + fi 436 + mkdir -p /var/lib/atmos-backup/dumps 437 + 438 + # Relay SQLite — hot backup 439 + if [ -f /var/lib/atmos-relay/relay.sqlite ]; then 440 + ${pkgs.sqlite}/bin/sqlite3 /var/lib/atmos-relay/relay.sqlite \ 441 + ".backup '/var/lib/atmos-backup/dumps/relay.sqlite'" 442 + fi 443 + 444 + # DKIM signing keys (generated at first boot, no other copy exists) 445 + if [ -f /var/lib/atmos-relay/operator-dkim-keys.json ]; then 446 + cp /var/lib/atmos-relay/operator-dkim-keys.json /var/lib/atmos-backup/dumps/ 447 + fi 448 + 449 + # OAuth signing key 450 + if [ -f /var/lib/atmos-relay/oauth-signing-key.pem ]; then 451 + cp /var/lib/atmos-relay/oauth-signing-key.pem /var/lib/atmos-backup/dumps/ 452 + fi 453 + ''; 454 + timerConfig = { 455 + OnCalendar = "*-*-* 00/6:00:00"; 456 + Persistent = true; 457 + RandomizedDelaySec = "30m"; 458 + }; 459 + pruneOpts = [ 460 + "--keep-daily 7" 461 + "--keep-weekly 4" 462 + "--keep-monthly 3" 463 + ]; 464 + }; 356 465 357 466 # ------------------------------------------------------------------- 358 467 # Nix — enable flakes for nixos-rebuild
+14
infra/outputs.tf
··· 85 85 After that, all updates go through git push → CI → deploy. 86 86 EOT 87 87 } 88 + 89 + # --------------------------------------------------------------------------- 90 + # Backup volume outputs 91 + # --------------------------------------------------------------------------- 92 + 93 + output "ops_backup_volume_id" { 94 + description = "Hetzner volume ID of the ops backup volume" 95 + value = hcloud_volume.ops_backup.id 96 + } 97 + 98 + output "relay_backup_volume_id" { 99 + description = "Hetzner volume ID of the relay backup volume" 100 + value = hcloud_volume.relay_backup.id 101 + }
+1 -1
infra/providers.tf
··· 8 8 } 9 9 bunnynet = { 10 10 source = "BunnyWay/bunnynet" 11 - version = ">= 0.4" 11 + version = "~> 0.4" 12 12 } 13 13 } 14 14
+118
internal/admin/api.go
··· 236 236 // Public (API-key-authenticated) endpoint for members to check their own status. 237 237 // No admin token required — authenticated by the member's SMTP API key. 238 238 a.mux.HandleFunc("/member/status", a.handleMemberSelfStatus) 239 + // Self-service deliverability metrics — API-key-authenticated like /member/status. 240 + a.mux.HandleFunc("/member/deliverability", a.handleMemberSelfDeliverability) 239 241 // Self-service forward_to update — API-key-authenticated like /member/status. 240 242 a.mux.HandleFunc("/member/forward-to", a.handleMemberSelfForwardTo) 241 243 return a ··· 1502 1504 </head> 1503 1505 <body><h1>%s</h1><p>%s</p></body> 1504 1506 </html>`, title, title, message) 1507 + } 1508 + 1509 + // handleMemberSelfDeliverability returns a member's own deliverability 1510 + // metrics: sends, bounces, complaints, daily sparkline, and reputation 1511 + // labels. API-key-authenticated, same model as /member/status. 1512 + func (a *API) handleMemberSelfDeliverability(w http.ResponseWriter, r *http.Request) { 1513 + if r.Method != http.MethodGet { 1514 + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) 1515 + return 1516 + } 1517 + 1518 + // Authenticate: DID in query + API key in Authorization header. 1519 + did := r.URL.Query().Get("did") 1520 + if did == "" { 1521 + http.Error(w, `{"error":"did query parameter required"}`, http.StatusBadRequest) 1522 + return 1523 + } 1524 + if !validDID.MatchString(did) { 1525 + http.Error(w, `{"error":"invalid DID format"}`, http.StatusBadRequest) 1526 + return 1527 + } 1528 + 1529 + apiKey := "" 1530 + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { 1531 + apiKey = strings.TrimPrefix(auth, "Bearer ") 1532 + } 1533 + if apiKey == "" { 1534 + http.Error(w, `{"error":"Authorization: Bearer <api_key> header required"}`, http.StatusUnauthorized) 1535 + return 1536 + } 1537 + 1538 + member, domains, err := a.store.GetMemberWithDomains(r.Context(), did) 1539 + if err != nil { 1540 + log.Printf("member.deliverability: did=%s error=%v", did, err) 1541 + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) 1542 + return 1543 + } 1544 + if member == nil { 1545 + equalizeBcryptTiming(apiKey) 1546 + http.Error(w, `{"error":"authentication failed"}`, http.StatusUnauthorized) 1547 + return 1548 + } 1549 + 1550 + authenticated := false 1551 + for _, d := range domains { 1552 + if relay.VerifyAPIKey(apiKey, d.APIKeyHash) { 1553 + authenticated = true 1554 + break 1555 + } 1556 + } 1557 + if !authenticated { 1558 + http.Error(w, `{"error":"authentication failed"}`, http.StatusUnauthorized) 1559 + return 1560 + } 1561 + 1562 + ctx := r.Context() 1563 + since14d := time.Now().UTC().AddDate(0, 0, -14) 1564 + 1565 + total, bounced, err := a.store.GetMessageCounts(ctx, did, since14d) 1566 + if err != nil { 1567 + log.Printf("member.deliverability: GetMessageCounts did=%s error=%v", did, err) 1568 + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) 1569 + return 1570 + } 1571 + 1572 + complaints, err := a.store.GetComplaintCount(ctx, did, since14d) 1573 + if err != nil { 1574 + log.Printf("member.deliverability: GetComplaintCount did=%s error=%v", did, err) 1575 + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) 1576 + return 1577 + } 1578 + 1579 + daily, err := a.store.GetDailySendCounts(ctx, did, 14) 1580 + if err != nil { 1581 + log.Printf("member.deliverability: GetDailySendCounts did=%s error=%v", did, err) 1582 + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) 1583 + return 1584 + } 1585 + 1586 + // Fetch labels (best-effort) 1587 + var labels []string 1588 + if a.labelChecker != nil { 1589 + labels, _ = a.labelChecker.QueryLabels(ctx, did) 1590 + } 1591 + 1592 + w.Header().Set("Content-Type", "application/json") 1593 + json.NewEncoder(w).Encode(struct { 1594 + DID string `json:"did"` 1595 + Status string `json:"status"` 1596 + Sent14d int64 `json:"sent_14d"` 1597 + Bounced14d int64 `json:"bounced_14d"` 1598 + Complaints14d int64 `json:"complaints_14d"` 1599 + BounceRate float64 `json:"bounce_rate"` 1600 + DailySends []int64 `json:"daily_sends"` 1601 + HourlyLimit int `json:"hourly_limit"` 1602 + DailyLimit int `json:"daily_limit"` 1603 + Labels []string `json:"labels"` 1604 + }{ 1605 + DID: did, 1606 + Status: member.Status, 1607 + Sent14d: total, 1608 + Bounced14d: bounced, 1609 + Complaints14d: complaints, 1610 + BounceRate: safeBounceRate(total, bounced), 1611 + DailySends: daily, 1612 + HourlyLimit: member.HourlyLimit, 1613 + DailyLimit: member.DailyLimit, 1614 + Labels: labels, 1615 + }) 1616 + } 1617 + 1618 + func safeBounceRate(total, bounced int64) float64 { 1619 + if total == 0 { 1620 + return 0.0 1621 + } 1622 + return float64(bounced) / float64(total) 1505 1623 } 1506 1624 1507 1625 // handleMemberSendVerification is the admin endpoint POST
+88
internal/admin/api_test.go
··· 1119 1119 } 1120 1120 } 1121 1121 1122 + func deliverabilityReq(did, apiKey string) *http.Request { 1123 + req := httptest.NewRequest("GET", "/member/deliverability?did="+did, nil) 1124 + if apiKey != "" { 1125 + req.Header.Set("Authorization", "Bearer "+apiKey) 1126 + } 1127 + return req 1128 + } 1129 + 1130 + func TestSelfDeliverabilitySuccess(t *testing.T) { 1131 + api, store := testAdminAPI(t) 1132 + did := "did:plc:deliveraaaaaaaaaaaaaaaaa" 1133 + apiKey := enrollWithAPIKey(t, store, did, "example.com") 1134 + 1135 + // Seed some messages 1136 + ctx := context.Background() 1137 + now := time.Now().UTC() 1138 + for i := 0; i < 5; i++ { 1139 + _, _ = store.InsertMessage(ctx, &relaystore.Message{ 1140 + MemberDID: did, FromAddr: "x@example.com", ToAddr: "y@z.com", 1141 + MessageID: fmt.Sprintf("<m%d>", i), Status: relaystore.MsgSent, CreatedAt: now, 1142 + }) 1143 + } 1144 + _, _ = store.InsertMessage(ctx, &relaystore.Message{ 1145 + MemberDID: did, FromAddr: "x@example.com", ToAddr: "y@z.com", 1146 + MessageID: "<b1>", Status: relaystore.MsgBounced, CreatedAt: now, 1147 + }) 1148 + _, _ = store.InsertFeedbackEvent(ctx, &relaystore.FeedbackEvent{ 1149 + MemberDID: did, EventType: "complaint", CreatedAt: now, 1150 + }) 1151 + 1152 + w := httptest.NewRecorder() 1153 + api.ServeHTTP(w, deliverabilityReq(did, apiKey)) 1154 + 1155 + if w.Code != http.StatusOK { 1156 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 1157 + } 1158 + 1159 + var resp struct { 1160 + Sent14d int64 `json:"sent_14d"` 1161 + Bounced14d int64 `json:"bounced_14d"` 1162 + Complaints14d int64 `json:"complaints_14d"` 1163 + BounceRate float64 `json:"bounce_rate"` 1164 + DailySends []int64 `json:"daily_sends"` 1165 + } 1166 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 1167 + t.Fatal(err) 1168 + } 1169 + if resp.Sent14d != 6 { 1170 + t.Errorf("Sent14d = %d, want 6 (5 sent + 1 bounced)", resp.Sent14d) 1171 + } 1172 + if resp.Bounced14d != 1 { 1173 + t.Errorf("Bounced14d = %d, want 1", resp.Bounced14d) 1174 + } 1175 + if resp.Complaints14d != 1 { 1176 + t.Errorf("Complaints14d = %d, want 1", resp.Complaints14d) 1177 + } 1178 + if resp.BounceRate != 1.0/6.0 { 1179 + t.Errorf("BounceRate = %f, want %f", resp.BounceRate, 1.0/6.0) 1180 + } 1181 + if len(resp.DailySends) != 14 { 1182 + t.Errorf("DailySends len = %d, want 14", len(resp.DailySends)) 1183 + } 1184 + } 1185 + 1186 + func TestSelfDeliverabilityBadAPIKey(t *testing.T) { 1187 + api, store := testAdminAPI(t) 1188 + did := "did:plc:deliverbbbbbbbbbbbbbbbbb" 1189 + enrollWithAPIKey(t, store, did, "example.com") 1190 + 1191 + w := httptest.NewRecorder() 1192 + api.ServeHTTP(w, deliverabilityReq(did, "wrong-key")) 1193 + 1194 + if w.Code != http.StatusUnauthorized { 1195 + t.Errorf("status = %d, want 401", w.Code) 1196 + } 1197 + } 1198 + 1199 + func TestSelfDeliverabilityMissingAuth(t *testing.T) { 1200 + api, _ := testAdminAPI(t) 1201 + 1202 + req := httptest.NewRequest("GET", "/member/deliverability?did=did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", nil) 1203 + w := httptest.NewRecorder() 1204 + api.ServeHTTP(w, req) 1205 + if w.Code != http.StatusUnauthorized { 1206 + t.Errorf("status = %d, want 401", w.Code) 1207 + } 1208 + } 1209 + 1122 1210 // --- forward_to admin endpoints --- 1123 1211 1124 1212 func TestAdminDomainForwardToSet_RequiresAdminAuth(t *testing.T) {
+7
internal/admin/ui/enroll.go
··· 122 122 h.mux.HandleFunc("/privacy", h.handlePrivacy) 123 123 h.mux.HandleFunc("/aup", h.handleAUP) 124 124 h.mux.HandleFunc("/about", h.handleAbout) 125 + h.mux.HandleFunc("/faq", h.handleFAQ) 125 126 return h 126 127 } 127 128 ··· 258 259 func (h *EnrollHandler) handleAbout(w http.ResponseWriter, r *http.Request) { 259 260 h.staticPage(w, r, func(w http.ResponseWriter, r *http.Request) { 260 261 _ = templates.AboutPage().Render(r.Context(), w) 262 + }) 263 + } 264 + 265 + func (h *EnrollHandler) handleFAQ(w http.ResponseWriter, r *http.Request) { 266 + h.staticPage(w, r, func(w http.ResponseWriter, r *http.Request) { 267 + _ = templates.FAQPage().Render(r.Context(), w) 261 268 }) 262 269 } 263 270
+26 -2
internal/admin/ui/enroll_test.go
··· 561 561 562 562 func TestStaticPage_HEADReturns200(t *testing.T) { 563 563 h := NewEnrollHandler(&fakeAdminAPI{}, nil) 564 - for _, p := range []string{"/", "/terms", "/privacy", "/aup", "/about"} { 564 + for _, p := range []string{"/", "/terms", "/privacy", "/aup", "/about", "/faq"} { 565 565 req := httptest.NewRequest(http.MethodHead, p, nil) 566 566 w := httptest.NewRecorder() 567 567 h.ServeHTTP(w, req) ··· 675 675 } 676 676 } 677 677 678 + func TestFAQPage_ServesHTML(t *testing.T) { 679 + h := NewEnrollHandler(&fakeAdminAPI{}, nil) 680 + req := httptest.NewRequest(http.MethodGet, "/faq", nil) 681 + w := httptest.NewRecorder() 682 + h.ServeHTTP(w, req) 683 + 684 + if w.Code != http.StatusOK { 685 + t.Fatalf("status = %d, want 200", w.Code) 686 + } 687 + body := w.Body.String() 688 + if !strings.Contains(body, "FAQ") { 689 + t.Error("faq page should contain 'FAQ'") 690 + } 691 + if !strings.Contains(body, "Atmosphere Mail LLC") { 692 + t.Error("faq page must identify the legal entity") 693 + } 694 + // The FAQ must answer the three questions prospective members ask most. 695 + for _, required := range []string{"free", "trust", "commercial relay"} { 696 + if !strings.Contains(strings.ToLower(body), required) { 697 + t.Errorf("faq page must mention %q", required) 698 + } 699 + } 700 + } 701 + 678 702 // TestDropCapOnlyOnLanding pins the Round 2 design decision that the 679 703 // drop-cap brand mark is a landing-page-only element. Putting it on every 680 704 // page dilutes the signature; this test guards against regressing. ··· 691 715 692 716 // Legal + about pages must not carry a drop-cap — they are reference 693 717 // documents, not the brand moment. 694 - for _, p := range []string{"/terms", "/privacy", "/aup", "/about"} { 718 + for _, p := range []string{"/terms", "/privacy", "/aup", "/about", "/faq"} { 695 719 req := httptest.NewRequest(http.MethodGet, p, nil) 696 720 w := httptest.NewRecorder() 697 721 h.ServeHTTP(w, req)
+83
internal/admin/ui/recover.go
··· 224 224 mux.Handle("/account", wrap(h.handleLanding)) 225 225 mux.Handle("/account/start", wrap(h.handleStart)) 226 226 mux.Handle("/account/manage", wrap(h.handleManage)) 227 + mux.Handle("/account/deliverability", wrap(h.handleDeliverability)) 227 228 mux.Handle("/account/select-domain", wrap(h.handleSelectDomain)) 228 229 mux.Handle("/account/regenerate", wrap(h.handleRegenerate)) 229 230 mux.Handle("/account/contact-email", wrap(h.handleContactEmail)) ··· 484 485 ContactEmail: memberDomain.ContactEmail, 485 486 EmailVerified: memberDomain.EmailVerified, 486 487 ExpiresAt: ticket.expiry.Format(time.RFC3339), 488 + }).Render(r.Context(), w) 489 + } 490 + 491 + func (h *RecoverHandler) handleDeliverability(w http.ResponseWriter, r *http.Request) { 492 + if r.Method != http.MethodGet { 493 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 494 + return 495 + } 496 + id, ok := recoveryTicketFromCookie(r) 497 + if !ok { 498 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 499 + return 500 + } 501 + ticket, ok := h.lookupTicket(id, r.UserAgent()) 502 + if !ok { 503 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 504 + return 505 + } 506 + if ticket.domain == "" { 507 + http.Redirect(w, r, "/account/manage", http.StatusFound) 508 + return 509 + } 510 + 511 + ctx := r.Context() 512 + member, err := h.store.GetMember(ctx, ticket.did) 513 + if err != nil || member == nil { 514 + log.Printf("account.deliverability: did_hash=%s error=%v", HashForLog(ticket.did), err) 515 + http.Error(w, "internal error", http.StatusInternalServerError) 516 + return 517 + } 518 + 519 + since14d := time.Now().UTC().AddDate(0, 0, -14) 520 + total, bounced, err := h.store.GetMessageCounts(ctx, ticket.did, since14d) 521 + if err != nil { 522 + log.Printf("account.deliverability: GetMessageCounts did=%s error=%v", HashForLog(ticket.did), err) 523 + http.Error(w, "internal error", http.StatusInternalServerError) 524 + return 525 + } 526 + 527 + complaints, err := h.store.GetComplaintCount(ctx, ticket.did, since14d) 528 + if err != nil { 529 + log.Printf("account.deliverability: GetComplaintCount did=%s error=%v", HashForLog(ticket.did), err) 530 + http.Error(w, "internal error", http.StatusInternalServerError) 531 + return 532 + } 533 + 534 + daily, err := h.store.GetDailySendCounts(ctx, ticket.did, 14) 535 + if err != nil { 536 + log.Printf("account.deliverability: GetDailySendCounts did=%s error=%v", HashForLog(ticket.did), err) 537 + http.Error(w, "internal error", http.StatusInternalServerError) 538 + return 539 + } 540 + 541 + warmingTier := relay.MemberTier(relay.DefaultWarmingConfig(), member.CreatedAt, time.Now()) 542 + warmingLabel := "" 543 + switch warmingTier { 544 + case relay.TierWarming: 545 + warmingLabel = "Warming (0–7 days)" 546 + case relay.TierRamping: 547 + warmingLabel = "Ramping (7–14 days)" 548 + } 549 + 550 + bounceRate := 0.0 551 + if total > 0 { 552 + bounceRate = float64(bounced) / float64(total) 553 + } 554 + 555 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 556 + _ = templates.DeliverabilityPage(templates.DeliverabilityData{ 557 + DID: ticket.did, 558 + Domain: ticket.domain, 559 + Status: member.Status, 560 + SuspendReason: member.SuspendReason, 561 + Sent14d: total, 562 + Bounced14d: bounced, 563 + Complaints14d: complaints, 564 + BounceRate: bounceRate, 565 + DailySends: daily, 566 + HourlyLimit: member.HourlyLimit, 567 + DailyLimit: member.DailyLimit, 568 + WarmingTier: warmingTier, 569 + WarmingLabel: warmingLabel, 487 570 }).Render(r.Context(), w) 488 571 } 489 572
+68
internal/admin/ui/recover_test.go
··· 5 5 import ( 6 6 "context" 7 7 "errors" 8 + "fmt" 8 9 "net/http" 9 10 "net/http/httptest" 10 11 "net/url" ··· 296 297 } 297 298 if strings.Contains(body, `action="/account/select-domain"`) { 298 299 t.Error("single-domain account should not show the domain picker") 300 + } 301 + } 302 + 303 + func TestRecover_DeliverabilityRendersForValidTicket(t *testing.T) { 304 + store := newRecoverTestStore(t) 305 + did := "did:plc:deliver2222222222222" 306 + domain := "deliver.example.com" 307 + seedRecoverMember(t, store, did, domain) 308 + 309 + ctx := context.Background() 310 + now := time.Now().UTC() 311 + for i := 0; i < 3; i++ { 312 + _, _ = store.InsertMessage(ctx, &relaystore.Message{ 313 + MemberDID: did, FromAddr: "x@deliver.example.com", ToAddr: "y@z.com", 314 + MessageID: fmt.Sprintf("<m%d>", i), Status: relaystore.MsgSent, CreatedAt: now, 315 + }) 316 + } 317 + 318 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 319 + target := h.IssueRecoveryTicket(did, domain) 320 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 321 + 322 + mux := http.NewServeMux() 323 + h.RegisterRoutes(mux) 324 + 325 + req := httptest.NewRequest(http.MethodGet, "/account/deliverability", nil) 326 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 327 + rec := httptest.NewRecorder() 328 + mux.ServeHTTP(rec, req) 329 + 330 + if rec.Code != http.StatusOK { 331 + t.Fatalf("status = %d, want 200", rec.Code) 332 + } 333 + body := rec.Body.String() 334 + if !strings.Contains(body, "Deliverability") { 335 + t.Error("deliverability page missing heading") 336 + } 337 + if !strings.Contains(body, domain) { 338 + t.Error("deliverability page missing domain") 339 + } 340 + if !strings.Contains(body, "Sent (14d)") { 341 + t.Error("deliverability page missing sent stat") 342 + } 343 + } 344 + 345 + func TestRecover_DeliverabilityRedirectsWithoutDomain(t *testing.T) { 346 + store := newRecoverTestStore(t) 347 + did := "did:plc:deliver3333333333333" 348 + seedRecoverMember(t, store, did, "deliver2.example.com") 349 + 350 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 351 + target := h.IssueRecoveryTicket(did, "") 352 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 353 + 354 + mux := http.NewServeMux() 355 + h.RegisterRoutes(mux) 356 + 357 + req := httptest.NewRequest(http.MethodGet, "/account/deliverability", nil) 358 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 359 + rec := httptest.NewRecorder() 360 + mux.ServeHTTP(rec, req) 361 + 362 + if rec.Code != http.StatusFound { 363 + t.Fatalf("status = %d, want 302", rec.Code) 364 + } 365 + if loc := rec.Header().Get("Location"); loc != "/account/manage" { 366 + t.Errorf("redirect = %q, want /account/manage", loc) 299 367 } 300 368 } 301 369
+127
internal/admin/ui/templates/deliverability.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package templates 4 + 5 + // DeliverabilityPage is the member-facing view of their own sending 6 + // reputation: bounces, complaints, warming tier, and daily volume trend. 7 + 8 + import ( 9 + "context" 10 + "fmt" 11 + "html" 12 + "io" 13 + "strings" 14 + 15 + "github.com/a-h/templ" 16 + ) 17 + 18 + // DeliverabilityData carries all metrics for the /account/deliverability page. 19 + type DeliverabilityData struct { 20 + DID string 21 + Domain string 22 + Status string 23 + SuspendReason string 24 + 25 + Sent14d int64 26 + Bounced14d int64 27 + Complaints14d int64 28 + BounceRate float64 // 0.0–1.0 29 + 30 + DailySends []int64 // 14 buckets, oldest-to-newest 31 + 32 + HourlyLimit int 33 + DailyLimit int 34 + 35 + WarmingTier string // "warming" | "ramping" | "warmed" | "" 36 + WarmingLabel string // human-readable, e.g. "warming (3/7 days)" 37 + 38 + Labels []string // Osprey + labeler labels 39 + } 40 + 41 + func DeliverabilityPage(d DeliverabilityData) templ.Component { 42 + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { 43 + inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error { 44 + var b strings.Builder 45 + 46 + b.WriteString(`<nav class="topnav" aria-label="breadcrumb"><a href="/account" class="topnav-home">← Account</a></nav>`) 47 + b.WriteString(`<h1 class="masthead masthead-sub">Deliverability</h1>`) 48 + fmt.Fprintf(&b, `<p class="lede">Sending reputation for <code>%s</code>.</p>`, html.EscapeString(d.Domain)) 49 + 50 + // Status banner 51 + if d.Status == "suspended" { 52 + b.WriteString(`<div class="error-note" role="alert"><p style="margin: 0;"><strong>Account suspended.</strong>`) 53 + if d.SuspendReason != "" { 54 + fmt.Fprintf(&b, ` Reason: %s`, html.EscapeString(d.SuspendReason)) 55 + } 56 + b.WriteString(` SMTP submission is currently rejected. Contact the operator to appeal.</p></div>`) 57 + } 58 + 59 + b.WriteString(`<div class="stat-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; margin: 1.5rem 0;">`) 60 + b.WriteString(statCard("Sent (14d)", fmt.Sprintf("%d", d.Sent14d))) 61 + b.WriteString(statCard("Bounced", fmt.Sprintf("%d", d.Bounced14d))) 62 + b.WriteString(statCard("Complaints", fmt.Sprintf("%d", d.Complaints14d))) 63 + b.WriteString(statCard("Bounce rate", fmt.Sprintf("%.1f%%", d.BounceRate*100))) 64 + b.WriteString(`</div>`) 65 + 66 + // Sparkline 67 + if len(d.DailySends) > 0 { 68 + b.WriteString(`<section class="section">`) 69 + b.WriteString(`<h2>Sends per day</h2>`) 70 + b.WriteString(sparklineSVG(d.DailySends)) 71 + b.WriteString(`</section>`) 72 + } 73 + 74 + // Warming tier 75 + if d.WarmingLabel != "" { 76 + b.WriteString(`<section class="section">`) 77 + b.WriteString(`<h2>Warming progress</h2>`) 78 + fmt.Fprintf(&b, `<p class="section-lede">%s</p>`, html.EscapeString(d.WarmingLabel)) 79 + b.WriteString(warningNote(d.WarmingTier)) 80 + b.WriteString(`</section>`) 81 + } 82 + 83 + // Limits 84 + b.WriteString(`<section class="section">`) 85 + b.WriteString(`<h2>Current limits</h2>`) 86 + fmt.Fprintf(&b, `<dl class="bullets"><dt>Hourly limit</dt><dd>%d</dd><dt>Daily limit</dt><dd>%d</dd></dl>`, d.HourlyLimit, d.DailyLimit) 87 + b.WriteString(`</section>`) 88 + 89 + // Labels 90 + if len(d.Labels) > 0 { 91 + b.WriteString(`<section class="section">`) 92 + b.WriteString(`<h2>Reputation labels</h2>`) 93 + b.WriteString(`<p class="section-lede">Labels published by the atproto labeler. Other services can query these to decide whether to trust mail from your domain.</p>`) 94 + for _, l := range d.Labels { 95 + fmt.Fprintf(&b, `<span class="badge badge-label">%s</span> `, html.EscapeString(l)) 96 + } 97 + b.WriteString(`</section>`) 98 + } 99 + 100 + b.WriteString(`<section class="section">`) 101 + b.WriteString(`<p class="section-lede">These numbers update in real time. Bounce rate above 5%% or complaint rate above 0.1%% can trigger automatic throttling or suspension. The fix is always the same: send only to engaged recipients who asked for your mail.</p>`) 102 + b.WriteString(`</section>`) 103 + 104 + _, err := io.WriteString(w, b.String()) 105 + return err 106 + }) 107 + return publicLayout("Deliverability — "+d.Domain, false).Render(templ.WithChildren(ctx, inner), w) 108 + }) 109 + } 110 + 111 + func statCard(title, value string) string { 112 + return fmt.Sprintf(`<article style="background: var(--surface); border: 1px solid var(--line); padding: 1rem; border-radius: 2px;"> 113 + <div style="font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin-bottom: 0.5rem;">%s</div> 114 + <div style="font-size: var(--t-2xl); font-family: var(--font-display); color: var(--ink);">%s</div> 115 + </article>`, html.EscapeString(title), html.EscapeString(value)) 116 + } 117 + 118 + func warningNote(tier string) string { 119 + switch tier { 120 + case "warming": 121 + return `<p class="section-lede" style="color: var(--accent-ink);">Your domain is in the warming tier: 5 emails per hour, 20 per day. This protects the shared IP while Gmail learns your sending pattern. The cap lifts automatically after 7 days of clean sending.</p>` 122 + case "ramping": 123 + return `<p class="section-lede" style="color: var(--accent-ink);">Your domain is ramping: 20 emails per hour, 100 per day. Keep engagement high and complaints low. Full limits unlock after 14 days total.</p>` 124 + default: 125 + return "" 126 + } 127 + }
+7 -6
internal/admin/ui/templates/enroll.templ
··· 529 529 <footer> 530 530 Atmosphere Mail LLC · cooperative email infrastructure for atproto 531 531 <br/> 532 - <a href="/terms">Terms</a> · 533 - <a href="/privacy">Privacy</a> · 534 - <a href="/aup">Acceptable use</a> · 535 - <a href="/about">About</a> · 536 - <a href="https://status.atmos.email">Status</a> · 537 - <a href="https://tangled.org/scottlanoue.com/atmosphere-mail">Source</a> 532 + <a href="/terms">Terms</a> · 533 + <a href="/privacy">Privacy</a> · 534 + <a href="/aup">Acceptable use</a> · 535 + <a href="/faq">FAQ</a> · 536 + <a href="/about">About</a> · 537 + <a href="https://status.atmos.email">Status</a> · 538 + <a href="https://tangled.org/scottlanoue.com/atmosphere-mail">Source</a> 538 539 </footer> 539 540 </main> 540 541 </body>
+1 -1
internal/admin/ui/templates/enroll_templ.go
··· 98 98 if templ_7745c5c3_Err != nil { 99 99 return templ_7745c5c3_Err 100 100 } 101 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<footer>Atmosphere Mail LLC · cooperative email infrastructure for atproto<br><a href=\"/terms\">Terms</a> · <a href=\"/privacy\">Privacy</a> · <a href=\"/aup\">Acceptable use</a> · <a href=\"/about\">About</a> · <a href=\"https://status.atmos.email\">Status</a> · <a href=\"https://tangled.org/scottlanoue.com/atmosphere-mail\">Source</a></footer></main></body></html>") 101 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<footer>Atmosphere Mail LLC · cooperative email infrastructure for atproto<br><a href=\"/terms\">Terms</a> · <a href=\"/privacy\">Privacy</a> · <a href=\"/aup\">Acceptable use</a> · <a href=\"/faq\">FAQ</a> · <a href=\"/about\">About</a> · <a href=\"https://status.atmos.email\">Status</a> · <a href=\"https://tangled.org/scottlanoue.com/atmosphere-mail\">Source</a></footer></main></body></html>") 102 102 if templ_7745c5c3_Err != nil { 103 103 return templ_7745c5c3_Err 104 104 }
+73
internal/admin/ui/templates/faq.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package templates 4 + 5 + // FAQPage answers the questions prospective members ask before they enroll. 6 + // Honest, concise, and written to defuse the obvious objections. 7 + 8 + import ( 9 + "context" 10 + "io" 11 + "strings" 12 + 13 + "github.com/a-h/templ" 14 + ) 15 + 16 + func FAQPage() templ.Component { 17 + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { 18 + inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error { 19 + var b strings.Builder 20 + 21 + b.WriteString(`<h1 class="masthead masthead-sub">FAQ</h1>`) 22 + b.WriteString(`<p class="lede">Questions we expect — answered honestly.</p>`) 23 + 24 + b.WriteString(`<section class="section">`) 25 + b.WriteString(`<span class="step-marker">Pricing</span>`) 26 + b.WriteString(`<h2>Why is this free? Will it stay free?</h2>`) 27 + 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>`) 28 + 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>`) 29 + b.WriteString(`</section>`) 30 + 31 + b.WriteString(`<section class="section">`) 32 + b.WriteString(`<span class="step-marker">Trust</span>`) 33 + b.WriteString(`<h2>How can I trust this?</h2>`) 34 + 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>`) 35 + 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>`) 36 + b.WriteString(`</section>`) 37 + 38 + b.WriteString(`<section class="section">`) 39 + b.WriteString(`<span class="step-marker">Alternatives</span>`) 40 + b.WriteString(`<h2>Why not use a trusted commercial relay?</h2>`) 41 + 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>`) 42 + 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>`) 43 + b.WriteString(`</section>`) 44 + 45 + b.WriteString(`<section class="section">`) 46 + b.WriteString(`<span class="step-marker">Deliverability</span>`) 47 + b.WriteString(`<h2>Will my mail reach the inbox?</h2>`) 48 + 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>`) 49 + 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>`) 50 + b.WriteString(`</section>`) 51 + 52 + b.WriteString(`<section class="section">`) 53 + b.WriteString(`<span class="step-marker">Portability</span>`) 54 + b.WriteString(`<h2>What if I want to leave?</h2>`) 55 + 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>`) 56 + b.WriteString(`</section>`) 57 + 58 + b.WriteString(`<section class="section">`) 59 + b.WriteString(`<span class="step-marker">Scope</span>`) 60 + b.WriteString(`<h2>What can I send through this relay?</h2>`) 61 + 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>`) 62 + b.WriteString(`</section>`) 63 + 64 + b.WriteString(`<section class="section">`) 65 + 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>`) 66 + b.WriteString(`</section>`) 67 + 68 + _, err := io.WriteString(w, b.String()) 69 + return err 70 + }) 71 + return publicLayout("FAQ — Atmosphere Mail", false).Render(templ.WithChildren(ctx, inner), w) 72 + }) 73 + }
+7
internal/admin/ui/templates/recover.go
··· 372 372 html.EscapeString(d.DKIMSelector), html.EscapeString(d.Domain)) 373 373 b.WriteString(`</section>`) 374 374 375 + // Deliverability dashboard 376 + b.WriteString(`<section class="section">`) 377 + b.WriteString(`<h2>Deliverability</h2>`) 378 + b.WriteString(`<p class="section-lede">View your sending reputation: bounce rate, complaints, daily volume, and warming progress.</p>`) 379 + b.WriteString(`<a href="/account/deliverability" class="btn">View deliverability →</a>`) 380 + b.WriteString(`</section>`) 381 + 375 382 // API key rotation 376 383 b.WriteString(`<section class="section">`) 377 384 b.WriteString(`<h2>API key</h2>`)
+4 -4
internal/relay/warming.go
··· 41 41 func DefaultWarmingConfig() WarmingConfig { 42 42 return WarmingConfig{ 43 43 WarmingPeriod: 7 * 24 * time.Hour, 44 - WarmingHourly: 5, 45 - WarmingDaily: 20, 44 + WarmingHourly: 2, 45 + WarmingDaily: 10, 46 46 47 47 RampingPeriod: 14 * 24 * time.Hour, 48 - RampingHourly: 20, 49 - RampingDaily: 100, 48 + RampingHourly: 10, 49 + RampingDaily: 50, 50 50 } 51 51 } 52 52
+16 -16
internal/relay/warming_test.go
··· 12 12 if cfg.WarmingPeriod != 7*24*time.Hour { 13 13 t.Errorf("WarmingPeriod = %v, want 7 days", cfg.WarmingPeriod) 14 14 } 15 - if cfg.WarmingHourly != 5 { 16 - t.Errorf("WarmingHourly = %d, want 5", cfg.WarmingHourly) 15 + if cfg.WarmingHourly != 2 { 16 + t.Errorf("WarmingHourly = %d, want 2", cfg.WarmingHourly) 17 17 } 18 - if cfg.WarmingDaily != 20 { 19 - t.Errorf("WarmingDaily = %d, want 20", cfg.WarmingDaily) 18 + if cfg.WarmingDaily != 10 { 19 + t.Errorf("WarmingDaily = %d, want 10", cfg.WarmingDaily) 20 20 } 21 21 if cfg.RampingPeriod != 14*24*time.Hour { 22 22 t.Errorf("RampingPeriod = %v, want 14 days", cfg.RampingPeriod) 23 23 } 24 - if cfg.RampingHourly != 20 { 25 - t.Errorf("RampingHourly = %d, want 20", cfg.RampingHourly) 24 + if cfg.RampingHourly != 10 { 25 + t.Errorf("RampingHourly = %d, want 10", cfg.RampingHourly) 26 26 } 27 - if cfg.RampingDaily != 100 { 28 - t.Errorf("RampingDaily = %d, want 100", cfg.RampingDaily) 27 + if cfg.RampingDaily != 50 { 28 + t.Errorf("RampingDaily = %d, want 50", cfg.RampingDaily) 29 29 } 30 30 } 31 31 ··· 91 91 func TestTierCaps_Warming(t *testing.T) { 92 92 cfg := DefaultWarmingConfig() 93 93 h, d := TierCaps(cfg, TierWarming) 94 - if h != 5 || d != 20 { 95 - t.Errorf("TierCaps(warming) = (%d,%d), want (5,20)", h, d) 94 + if h != 2 || d != 10 { 95 + t.Errorf("TierCaps(warming) = (%d,%d), want (2,10)", h, d) 96 96 } 97 97 } 98 98 99 99 func TestTierCaps_Ramping(t *testing.T) { 100 100 cfg := DefaultWarmingConfig() 101 101 h, d := TierCaps(cfg, TierRamping) 102 - if h != 20 || d != 100 { 103 - t.Errorf("TierCaps(ramping) = (%d,%d), want (20,100)", h, d) 102 + if h != 10 || d != 50 { 103 + t.Errorf("TierCaps(ramping) = (%d,%d), want (10,50)", h, d) 104 104 } 105 105 } 106 106 ··· 119 119 cfg := DefaultWarmingConfig() 120 120 created := time.Now().Add(-1 * time.Hour) 121 121 hourly, daily := WarmingLimits(cfg, created, 100, 1000) 122 - if hourly != 5 || daily != 20 { 123 - t.Errorf("new-member limits = (%d,%d), want (5,20)", hourly, daily) 122 + if hourly != 2 || daily != 10 { 123 + t.Errorf("new-member limits = (%d,%d), want (2,10)", hourly, daily) 124 124 } 125 125 } 126 126 ··· 128 128 cfg := DefaultWarmingConfig() 129 129 created := time.Now().Add(-10 * 24 * time.Hour) // mid-ramping 130 130 hourly, daily := WarmingLimits(cfg, created, 100, 1000) 131 - if hourly != 20 || daily != 100 { 132 - t.Errorf("ramping-member limits = (%d,%d), want (20,100)", hourly, daily) 131 + if hourly != 10 || daily != 50 { 132 + t.Errorf("ramping-member limits = (%d,%d), want (10,50)", hourly, daily) 133 133 } 134 134 } 135 135
+155 -75
internal/relay/warmup.go
··· 14 14 // Bypasses rate limiting and suppression since these are operator-initiated 15 15 // sends to known seed addresses. 16 16 type WarmupSender struct { 17 - seedAddresses []string 18 - memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error) 19 - queue *Queue 20 - operatorKeys *DKIMKeys 17 + seedAddresses []string 18 + fromLocalParts []string 19 + memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error) 20 + queue *Queue 21 + operatorKeys *DKIMKeys 21 22 operatorDKIMDomain string 22 - relayDomain string 23 + relayDomain string 23 24 24 25 insertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error) 25 26 incrSendCount func(ctx context.Context, did string) ··· 28 29 // WarmupConfig configures the warmup sender. 29 30 type WarmupConfig struct { 30 31 SeedAddresses []string 32 + FromLocalParts []string // local parts to rotate (default ["scott"]) 31 33 MemberLookup func(ctx context.Context, did string) (*MemberWithDomains, error) 32 34 Queue *Queue 33 35 OperatorKeys *DKIMKeys ··· 38 40 } 39 41 40 42 func NewWarmupSender(cfg WarmupConfig) *WarmupSender { 43 + fromParts := cfg.FromLocalParts 44 + if len(fromParts) == 0 { 45 + fromParts = []string{"scott"} 46 + } 41 47 return &WarmupSender{ 42 48 seedAddresses: cfg.SeedAddresses, 49 + fromLocalParts: fromParts, 43 50 memberLookup: cfg.MemberLookup, 44 51 queue: cfg.Queue, 45 52 operatorKeys: cfg.OperatorKeys, ··· 59 66 Errors []string `json:"errors,omitempty"` 60 67 } 61 68 69 + // SendOne sends a single warmup email to the given seed address on behalf of 70 + // the member DID. Template and From address are selected by recipientIdx to 71 + // ensure variety across recipients within a batch. 72 + func (w *WarmupSender) SendOne(ctx context.Context, did string, recipientIdx int) (*WarmupResult, error) { 73 + if recipientIdx < 0 || recipientIdx >= len(w.seedAddresses) { 74 + return nil, fmt.Errorf("recipient index %d out of range [0, %d)", recipientIdx, len(w.seedAddresses)) 75 + } 76 + 77 + member, err := w.memberLookup(ctx, did) 78 + if err != nil { 79 + return nil, fmt.Errorf("member lookup: %w", err) 80 + } 81 + if member == nil || len(member.Domains) == 0 { 82 + return nil, fmt.Errorf("member %s not found or has no domains", did) 83 + } 84 + 85 + domain := member.Domains[0] 86 + to := w.seedAddresses[recipientIdx] 87 + fromLocal := w.fromLocalParts[recipientIdx%len(w.fromLocalParts)] 88 + from := fromLocal + "@" + domain.Domain 89 + 90 + templates := warmupTemplates() 91 + tmpl := templates[recipientIdx%len(templates)] 92 + 93 + msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain) 94 + msg := buildWarmupMessage(from, to, msgID, tmpl) 95 + 96 + result := &WarmupResult{} 97 + if err := w.sendMessage(ctx, did, from, to, msgID, msg, domain); err != nil { 98 + result.Failed = 1 99 + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", to, err)) 100 + } else { 101 + result.Sent = 1 102 + } 103 + return result, nil 104 + } 105 + 62 106 // SendBatch sends one warmup email to each seed address on behalf of the 63 - // given member DID. Returns the number sent and any per-recipient errors. 107 + // given member DID. Template and From address vary per recipient. 108 + // Returns the number sent and any per-recipient errors. 64 109 func (w *WarmupSender) SendBatch(ctx context.Context, did string) (*WarmupResult, error) { 65 110 if len(w.seedAddresses) == 0 { 66 111 return nil, fmt.Errorf("no warmup seed addresses configured") ··· 75 120 } 76 121 77 122 domain := member.Domains[0] 78 - from := "postmaster@" + domain.Domain 79 - 123 + templates := warmupTemplates() 80 124 result := &WarmupResult{} 81 - for _, to := range w.seedAddresses { 82 - msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain) 83 - msg := buildWarmupMessage(from, to, msgID, domain.Domain) 84 125 85 - verpFrom := VERPReturnPath(did, to, w.relayDomain) 126 + for i, to := range w.seedAddresses { 127 + fromLocal := w.fromLocalParts[i%len(w.fromLocalParts)] 128 + from := fromLocal + "@" + domain.Domain 129 + tmpl := templates[i%len(templates)] 86 130 87 - raw := []byte(msg) 88 - stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...) 89 - stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain) 131 + msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain) 132 + msg := buildWarmupMessage(from, to, msgID, tmpl) 90 133 91 - signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain) 92 - signed, err := signer.Sign(strings.NewReader(string(stamped))) 93 - if err != nil { 134 + if err := w.sendMessage(ctx, did, from, to, msgID, msg, domain); err != nil { 94 135 result.Failed++ 95 - result.Errors = append(result.Errors, fmt.Sprintf("%s: DKIM sign: %v", to, err)) 96 - continue 136 + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", to, err)) 137 + } else { 138 + result.Sent++ 97 139 } 140 + } 98 141 99 - entryID := int64(0) 100 - if w.insertMessage != nil { 101 - id, err := w.insertMessage(ctx, did, from, to, msgID) 102 - if err != nil { 103 - log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err) 104 - } else { 105 - entryID = id 106 - } 107 - } 108 - if w.incrSendCount != nil { 109 - w.incrSendCount(ctx, did) 110 - } 142 + return result, nil 143 + } 144 + 145 + func (w *WarmupSender) sendMessage(ctx context.Context, did, from, to, msgID, msg string, domain DomainInfo) error { 146 + verpFrom := VERPReturnPath(did, to, w.relayDomain) 111 147 112 - if err := w.queue.Enqueue(&QueueEntry{ 113 - ID: entryID, 114 - From: verpFrom, 115 - To: to, 116 - Data: signed, 117 - MemberDID: did, 118 - }); err != nil { 119 - result.Failed++ 120 - result.Errors = append(result.Errors, fmt.Sprintf("%s: enqueue: %v", to, err)) 121 - continue 148 + raw := []byte(msg) 149 + stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...) 150 + stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain) 151 + 152 + signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain) 153 + signed, err := signer.Sign(strings.NewReader(string(stamped))) 154 + if err != nil { 155 + return fmt.Errorf("DKIM sign: %w", err) 156 + } 157 + 158 + entryID := int64(0) 159 + if w.insertMessage != nil { 160 + id, err := w.insertMessage(ctx, did, from, to, msgID) 161 + if err != nil { 162 + log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err) 163 + } else { 164 + entryID = id 122 165 } 166 + } 167 + if w.incrSendCount != nil { 168 + w.incrSendCount(ctx, did) 169 + } 123 170 124 - result.Sent++ 125 - log.Printf("warmup.queued: did=%s to=%s msg_id=%s", did, to, msgID) 171 + if err := w.queue.Enqueue(&QueueEntry{ 172 + ID: entryID, 173 + From: verpFrom, 174 + To: to, 175 + Data: signed, 176 + MemberDID: did, 177 + }); err != nil { 178 + return fmt.Errorf("enqueue: %w", err) 126 179 } 127 180 128 - return result, nil 181 + log.Printf("warmup.queued: did=%s from=%s to=%s msg_id=%s", did, from, to, msgID) 182 + return nil 129 183 } 130 184 131 185 type warmupTemplate struct { ··· 133 187 body string 134 188 } 135 189 136 - func warmupTemplates(domain string) []warmupTemplate { 190 + func warmupTemplates() []warmupTemplate { 137 191 return []warmupTemplate{ 138 192 { 139 - subject: "Re: setting up email for " + domain, 140 - body: "Hi,\r\n\r\n" + 141 - "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" + 142 - "Let me know if you run into any issues or have questions about the setup.\r\n\r\n" + 143 - "Best,\r\n" + 193 + subject: "Thursday lunch spot", 194 + body: "Hey,\r\n\r\n" + 195 + "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" + 196 + "Let me know — I can reserve a table if we go around noon.\r\n\r\n" + 197 + "Scott", 198 + }, 199 + { 200 + subject: "Re: that article you sent", 201 + body: "Just read through it — really interesting point about how federated systems handle trust differently than centralized ones. " + 202 + "The section on cooperative infrastructure reminded me of some things we've been thinking about.\r\n\r\n" + 203 + "Have you seen the follow-up post the author did? I'll dig up the link.\r\n\r\n" + 144 204 "Scott", 145 205 }, 146 206 { 147 - subject: "Quick note about " + domain, 207 + subject: "Weekend plans?", 148 208 body: "Hey,\r\n\r\n" + 149 - "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" + 150 - "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" + 151 - "Thanks,\r\n" + 209 + "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" + 210 + "Also — I finally finished that book you recommended. The ending was not what I expected. We should talk about it.\r\n\r\n" + 211 + "Scott", 212 + }, 213 + { 214 + subject: "quick favor", 215 + body: "Hey, can you send me that recipe you mentioned last time? " + 216 + "The one with the roasted peppers. I want to try making it this week.\r\n\r\n" + 217 + "Thanks!\r\n" + 218 + "Scott", 219 + }, 220 + { 221 + subject: "Re: meeting notes", 222 + 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" + 223 + "One thought — should we loop in the design team before we commit to the API contract? " + 224 + "Might save us a round of changes later.\r\n\r\n" + 225 + "Let me know what you think.\r\n\r\n" + 226 + "Scott", 227 + }, 228 + { 229 + subject: "coffee machine recs", 230 + body: "I'm finally replacing my old drip machine. Do you still like your Breville? " + 231 + "I've been going back and forth between that and just getting a simple pour-over setup.\r\n\r\n" + 232 + "Budget is flexible but I don't want something that takes 20 minutes to clean.\r\n\r\n" + 152 233 "Scott", 153 234 }, 154 235 { 155 - subject: domain + " is looking good", 156 - body: "Hi,\r\n\r\n" + 157 - "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" + 158 - "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" + 159 - "Cheers,\r\n" + 236 + subject: "Saw this and thought of you", 237 + 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. " + 238 + "Starts at 7pm. Free admission.\r\n\r\n" + 239 + "Want to go? I can drive.\r\n\r\n" + 240 + "Scott", 241 + }, 242 + { 243 + subject: "Re: printer issue", 244 + body: "Try power cycling it — unplug for 30 seconds, then plug back in. " + 245 + "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" + 246 + "If it's still stuck after that let me know and I'll come take a look.\r\n\r\n" + 160 247 "Scott", 161 248 }, 162 249 { 163 - subject: "Checking in — " + domain, 164 - body: "Hey,\r\n\r\n" + 165 - "Just checking in on " + domain + ". The mail pipeline is healthy and I don't see any issues on our side.\r\n\r\n" + 166 - "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" + 167 - "Best,\r\n" + 250 + subject: "Happy birthday!", 251 + body: "Hope you have a great one today! Any big plans?\r\n\r\n" + 252 + "We should get dinner sometime this week to celebrate. My treat.\r\n\r\n" + 168 253 "Scott", 169 254 }, 170 255 { 171 - subject: "All good with " + domain, 172 - body: "Hi,\r\n\r\n" + 173 - "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" + 174 - "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" + 175 - "Thanks,\r\n" + 256 + subject: "parking situation tomorrow", 257 + body: "Heads up — they're doing construction on the south lot tomorrow so we'll need to use the garage on 2nd. " + 258 + "I'd get there a bit early, it fills up fast.\r\n\r\n" + 259 + "See you there.\r\n\r\n" + 176 260 "Scott", 177 261 }, 178 262 } 179 263 } 180 264 181 - func buildWarmupMessage(from, to, msgID, domain string) string { 182 - templates := warmupTemplates(domain) 183 - idx := int(time.Now().Unix()/60) % len(templates) 184 - t := templates[idx] 185 - 265 + func buildWarmupMessage(from, to, msgID string, t warmupTemplate) string { 186 266 return strings.Join([]string{ 187 267 "From: " + from, 188 268 "To: " + to,
+128
internal/relay/warmup_scheduler.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package relay 4 + 5 + import ( 6 + "context" 7 + "log" 8 + "math/rand/v2" 9 + "sync" 10 + "time" 11 + ) 12 + 13 + // WarmupScheduler drips warmup sends across the day instead of firing 14 + // them all at once. Each tick sends one email to one seed address for 15 + // one member, then waits before the next. This produces the organic 16 + // send pattern that mailbox providers expect from real human senders. 17 + type WarmupScheduler struct { 18 + sender *WarmupSender 19 + listDIDs func(ctx context.Context) ([]string, error) 20 + interval time.Duration // base interval between sends 21 + jitter time.Duration // random jitter added to each interval 22 + mu sync.Mutex 23 + running bool 24 + cancelFunc context.CancelFunc 25 + } 26 + 27 + // WarmupSchedulerConfig configures the background warmup scheduler. 28 + type WarmupSchedulerConfig struct { 29 + Sender *WarmupSender 30 + ListDIDs func(ctx context.Context) ([]string, error) // returns active member DIDs 31 + Interval time.Duration // base time between sends (default 20min) 32 + Jitter time.Duration // max random jitter (default 10min) 33 + } 34 + 35 + func NewWarmupScheduler(cfg WarmupSchedulerConfig) *WarmupScheduler { 36 + interval := cfg.Interval 37 + if interval == 0 { 38 + interval = 20 * time.Minute 39 + } 40 + jitter := cfg.Jitter 41 + if jitter == 0 { 42 + jitter = 10 * time.Minute 43 + } 44 + return &WarmupScheduler{ 45 + sender: cfg.Sender, 46 + listDIDs: cfg.ListDIDs, 47 + interval: interval, 48 + jitter: jitter, 49 + } 50 + } 51 + 52 + // Start begins the background warmup loop. Safe to call multiple times; 53 + // subsequent calls are no-ops if already running. 54 + func (s *WarmupScheduler) Start(ctx context.Context) { 55 + s.mu.Lock() 56 + defer s.mu.Unlock() 57 + if s.running { 58 + return 59 + } 60 + s.running = true 61 + ctx, s.cancelFunc = context.WithCancel(ctx) 62 + go s.loop(ctx) 63 + log.Printf("warmup.scheduler: started interval=%s jitter=%s seeds=%d", 64 + s.interval, s.jitter, s.sender.SeedCount()) 65 + } 66 + 67 + // Stop halts the background warmup loop. 68 + func (s *WarmupScheduler) Stop() { 69 + s.mu.Lock() 70 + defer s.mu.Unlock() 71 + if !s.running { 72 + return 73 + } 74 + s.cancelFunc() 75 + s.running = false 76 + log.Printf("warmup.scheduler: stopped") 77 + } 78 + 79 + func (s *WarmupScheduler) loop(ctx context.Context) { 80 + defer func() { 81 + s.mu.Lock() 82 + s.running = false 83 + s.mu.Unlock() 84 + }() 85 + 86 + for { 87 + wait := s.interval + time.Duration(rand.Int64N(int64(s.jitter))) 88 + select { 89 + case <-ctx.Done(): 90 + return 91 + case <-time.After(wait): 92 + s.tick(ctx) 93 + } 94 + } 95 + } 96 + 97 + func (s *WarmupScheduler) tick(ctx context.Context) { 98 + dids, err := s.listDIDs(ctx) 99 + if err != nil { 100 + log.Printf("warmup.scheduler: list members: %v", err) 101 + return 102 + } 103 + if len(dids) == 0 { 104 + return 105 + } 106 + 107 + seedCount := s.sender.SeedCount() 108 + if seedCount == 0 { 109 + return 110 + } 111 + 112 + // Pick a random member and a random seed address for this tick. 113 + did := dids[rand.IntN(len(dids))] 114 + recipientIdx := rand.IntN(seedCount) 115 + 116 + result, err := s.sender.SendOne(ctx, did, recipientIdx) 117 + if err != nil { 118 + log.Printf("warmup.scheduler: did=%s error=%v", did, err) 119 + return 120 + } 121 + 122 + if result.Sent > 0 { 123 + log.Printf("warmup.scheduler: did=%s seed=%d sent=1", did, recipientIdx) 124 + } 125 + if result.Failed > 0 { 126 + log.Printf("warmup.scheduler: did=%s seed=%d failed=1 errors=%v", did, recipientIdx, result.Errors) 127 + } 128 + }
+192
internal/relay/warmup_scheduler_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package relay 4 + 5 + import ( 6 + "context" 7 + "sync/atomic" 8 + "testing" 9 + "time" 10 + ) 11 + 12 + func TestWarmupScheduler_TickSendsOneEmail(t *testing.T) { 13 + var sent atomic.Int32 14 + 15 + opKeys := testDKIMKeys(t) 16 + memberKeys := testDKIMKeys(t) 17 + ws := &WarmupSender{ 18 + seedAddresses: []string{"a@test.com", "b@test.com"}, 19 + fromLocalParts: []string{"scott", "hello"}, 20 + operatorDKIMDomain: "atmos.email", 21 + operatorKeys: opKeys, 22 + relayDomain: "smtp.atmos.email", 23 + memberLookup: func(ctx context.Context, did string) (*MemberWithDomains, error) { 24 + return &MemberWithDomains{ 25 + DID: did, 26 + Status: "active", 27 + Domains: []DomainInfo{ 28 + {Domain: "example.com", DKIMKeys: memberKeys}, 29 + }, 30 + }, nil 31 + }, 32 + queue: newTestQueue(), 33 + insertMessage: func(ctx context.Context, did, from, to, msgID string) (int64, error) { 34 + return 1, nil 35 + }, 36 + incrSendCount: func(ctx context.Context, did string) { 37 + sent.Add(1) 38 + }, 39 + } 40 + 41 + sched := NewWarmupScheduler(WarmupSchedulerConfig{ 42 + Sender: ws, 43 + ListDIDs: func(ctx context.Context) ([]string, error) { 44 + return []string{"did:plc:test"}, nil 45 + }, 46 + Interval: 100 * time.Millisecond, 47 + Jitter: 1 * time.Millisecond, 48 + }) 49 + 50 + ctx, cancel := context.WithCancel(context.Background()) 51 + sched.Start(ctx) 52 + t.Cleanup(func() { 53 + sched.Stop() 54 + cancel() 55 + }) 56 + 57 + time.Sleep(1 * time.Second) 58 + 59 + count := sent.Load() 60 + if count < 2 { 61 + t.Errorf("expected at least 2 sends, got %d", count) 62 + } 63 + } 64 + 65 + func TestWarmupScheduler_NoMembersNoSends(t *testing.T) { 66 + var sent atomic.Int32 67 + 68 + ws := &WarmupSender{ 69 + seedAddresses: []string{"a@test.com"}, 70 + fromLocalParts: []string{"scott"}, 71 + relayDomain: "smtp.atmos.email", 72 + memberLookup: func(ctx context.Context, did string) (*MemberWithDomains, error) { 73 + return nil, nil 74 + }, 75 + queue: newTestQueue(), 76 + incrSendCount: func(ctx context.Context, did string) { 77 + sent.Add(1) 78 + }, 79 + } 80 + 81 + sched := NewWarmupScheduler(WarmupSchedulerConfig{ 82 + Sender: ws, 83 + ListDIDs: func(ctx context.Context) ([]string, error) { 84 + return nil, nil 85 + }, 86 + Interval: 10 * time.Millisecond, 87 + Jitter: 1 * time.Millisecond, 88 + }) 89 + 90 + ctx, cancel := context.WithCancel(context.Background()) 91 + sched.Start(ctx) 92 + t.Cleanup(func() { 93 + sched.Stop() 94 + cancel() 95 + }) 96 + 97 + time.Sleep(100 * time.Millisecond) 98 + 99 + if sent.Load() != 0 { 100 + t.Errorf("expected 0 sends with no members, got %d", sent.Load()) 101 + } 102 + } 103 + 104 + func TestWarmupScheduler_StartStopIdempotent(t *testing.T) { 105 + ws := &WarmupSender{ 106 + seedAddresses: []string{"a@test.com"}, 107 + fromLocalParts: []string{"scott"}, 108 + } 109 + 110 + sched := NewWarmupScheduler(WarmupSchedulerConfig{ 111 + Sender: ws, 112 + ListDIDs: func(ctx context.Context) ([]string, error) { 113 + return nil, nil 114 + }, 115 + }) 116 + 117 + ctx := context.Background() 118 + 119 + sched.Start(ctx) 120 + sched.Start(ctx) 121 + 122 + sched.Stop() 123 + sched.Stop() 124 + } 125 + 126 + func TestSendOne_VariesTemplateAndFrom(t *testing.T) { 127 + type sendRecord struct { 128 + from string 129 + to string 130 + } 131 + var sends []sendRecord 132 + 133 + opKeys := testDKIMKeys(t) 134 + memberKeys := testDKIMKeys(t) 135 + ws := &WarmupSender{ 136 + seedAddresses: []string{"a@test.com", "b@test.com", "c@test.com"}, 137 + fromLocalParts: []string{"scott", "hello"}, 138 + operatorDKIMDomain: "atmos.email", 139 + operatorKeys: opKeys, 140 + relayDomain: "smtp.atmos.email", 141 + memberLookup: func(ctx context.Context, did string) (*MemberWithDomains, error) { 142 + return &MemberWithDomains{ 143 + DID: did, 144 + Status: "active", 145 + Domains: []DomainInfo{ 146 + {Domain: "example.com", DKIMKeys: memberKeys}, 147 + }, 148 + }, nil 149 + }, 150 + queue: newTestQueue(), 151 + insertMessage: func(ctx context.Context, did, from, to, msgID string) (int64, error) { 152 + sends = append(sends, sendRecord{from: from, to: to}) 153 + return int64(len(sends)), nil 154 + }, 155 + incrSendCount: func(ctx context.Context, did string) {}, 156 + } 157 + 158 + ctx := context.Background() 159 + for i := 0; i < 3; i++ { 160 + _, err := ws.SendOne(ctx, "did:plc:test", i) 161 + if err != nil { 162 + t.Fatalf("SendOne(%d): %v", i, err) 163 + } 164 + } 165 + 166 + if len(sends) != 3 { 167 + t.Fatalf("expected 3 sends, got %d", len(sends)) 168 + } 169 + 170 + if sends[0].from != "scott@example.com" { 171 + t.Errorf("send 0 from = %s, want scott@example.com", sends[0].from) 172 + } 173 + if sends[1].from != "hello@example.com" { 174 + t.Errorf("send 1 from = %s, want hello@example.com", sends[1].from) 175 + } 176 + if sends[2].from != "scott@example.com" { 177 + t.Errorf("send 2 from = %s, want scott@example.com", sends[2].from) 178 + } 179 + } 180 + 181 + func newTestQueue() *Queue { 182 + return NewQueue(func(r DeliveryResult) {}, DefaultQueueConfig()) 183 + } 184 + 185 + func testDKIMKeys(t *testing.T) *DKIMKeys { 186 + t.Helper() 187 + keys, err := GenerateDKIMKeys("test20260101") 188 + if err != nil { 189 + t.Fatalf("GenerateDKIMKeys: %v", err) 190 + } 191 + return keys 192 + }
+63
internal/relaystore/store.go
··· 1230 1230 return total, bounced, nil 1231 1231 } 1232 1232 1233 + // GetDailySendCounts returns per-day terminal (sent+bounced) message counts 1234 + // for the last n days, oldest-to-newest. Days with zero sends are included 1235 + // so callers get a fixed-length slice suitable for sparklines. 1236 + func (s *Store) GetDailySendCounts(ctx context.Context, memberDID string, days int) ([]int64, error) { 1237 + if days <= 0 { 1238 + days = 14 1239 + } 1240 + // Compute the inclusive cutoff in Go so SQLite parameter binding 1241 + // works cleanly. date('now', 'localtime', 'start of day', '-13 days') 1242 + // gives the first instant of the oldest day we care about. 1243 + cutoff := time.Now().UTC().AddDate(0, 0, -(days - 1)).Format("2006-01-02") 1244 + 1245 + rows, err := s.db.QueryContext(ctx, 1246 + `SELECT date(created_at) as day, COUNT(*) 1247 + FROM messages 1248 + WHERE member_did = ? AND status IN (?, ?) AND date(created_at) >= ? 1249 + GROUP BY day 1250 + ORDER BY day ASC`, 1251 + memberDID, MsgSent, MsgBounced, cutoff, 1252 + ) 1253 + if err != nil { 1254 + return nil, fmt.Errorf("daily send counts: %v", err) 1255 + } 1256 + defer rows.Close() 1257 + 1258 + counts := make(map[string]int64) 1259 + for rows.Next() { 1260 + var day string 1261 + var c int64 1262 + if err := rows.Scan(&day, &c); err != nil { 1263 + return nil, fmt.Errorf("scan daily count: %v", err) 1264 + } 1265 + counts[day] = c 1266 + } 1267 + if err := rows.Err(); err != nil { 1268 + return nil, fmt.Errorf("daily send counts rows: %v", err) 1269 + } 1270 + 1271 + // Fill in zero days so the slice is exactly `days` long. 1272 + out := make([]int64, days) 1273 + now := time.Now().UTC() 1274 + for i := 0; i < days; i++ { 1275 + day := now.AddDate(0, 0, -(days-1-i)).Format("2006-01-02") 1276 + out[i] = counts[day] 1277 + } 1278 + return out, nil 1279 + } 1280 + 1281 + // GetComplaintCount returns the number of feedback_events with event_type 1282 + // 'complaint' for the member since the given time. 1283 + func (s *Store) GetComplaintCount(ctx context.Context, memberDID string, since time.Time) (int64, error) { 1284 + var n int64 1285 + err := s.db.QueryRowContext(ctx, 1286 + `SELECT COUNT(*) FROM feedback_events 1287 + WHERE member_did = ? AND event_type = ? AND created_at >= ?`, 1288 + memberDID, "complaint", formatTime(since), 1289 + ).Scan(&n) 1290 + if err != nil { 1291 + return 0, fmt.Errorf("count complaints: %v", err) 1292 + } 1293 + return n, nil 1294 + } 1295 + 1233 1296 // GetUniqueRecipientDomainsSince counts DISTINCT recipient domains a member 1234 1297 // has sent to since the given time. Used by the DomainSpray detection rule — 1235 1298 // legitimate transactional mail usually goes to a small handful of domains;
+112
internal/relaystore/store_test.go
··· 4 4 5 5 import ( 6 6 "context" 7 + "fmt" 7 8 "testing" 8 9 "time" 9 10 ) ··· 1670 1671 } 1671 1672 if n != 2 { 1672 1673 t.Errorf("CountRelayRejectedSince = %d, want 2", n) 1674 + } 1675 + } 1676 + 1677 + // --- Daily send counts --- 1678 + 1679 + func TestGetDailySendCounts_ReturnsFixedLengthSlice(t *testing.T) { 1680 + s := testStore(t) 1681 + ctx := context.Background() 1682 + did := "did:plc:daily1111111111111111111" 1683 + insertTestMemberWithDomain(t, s, did, "daily.example.com") 1684 + 1685 + now := time.Now().UTC() 1686 + // Insert 3 messages on different days 1687 + for i := 0; i < 3; i++ { 1688 + _, err := s.InsertMessage(ctx, &Message{ 1689 + MemberDID: did, FromAddr: "x@daily.example.com", ToAddr: "y@z.com", 1690 + MessageID: fmt.Sprintf("<m%d>", i), Status: MsgSent, 1691 + CreatedAt: now.AddDate(0, 0, -(2 - i)), 1692 + }) 1693 + if err != nil { 1694 + t.Fatalf("insert: %v", err) 1695 + } 1696 + } 1697 + 1698 + daily, err := s.GetDailySendCounts(ctx, did, 14) 1699 + if err != nil { 1700 + t.Fatalf("GetDailySendCounts: %v", err) 1701 + } 1702 + if len(daily) != 14 { 1703 + t.Fatalf("expected 14 days, got %d", len(daily)) 1704 + } 1705 + // Last 3 days should have 1 each, earlier days 0 1706 + if daily[11] != 1 || daily[12] != 1 || daily[13] != 1 { 1707 + t.Errorf("unexpected distribution: %v", daily) 1708 + } 1709 + } 1710 + 1711 + func TestGetDailySendCounts_MemberIsolation(t *testing.T) { 1712 + s := testStore(t) 1713 + ctx := context.Background() 1714 + insertTestMemberWithDomain(t, s, "did:plc:a", "a.example.com") 1715 + insertTestMemberWithDomain(t, s, "did:plc:b", "b.example.com") 1716 + 1717 + now := time.Now().UTC() 1718 + _, _ = s.InsertMessage(ctx, &Message{MemberDID: "did:plc:a", FromAddr: "x@a.com", ToAddr: "y@z.com", MessageID: "<m>", Status: MsgSent, CreatedAt: now}) 1719 + 1720 + aDaily, _ := s.GetDailySendCounts(ctx, "did:plc:a", 14) 1721 + bDaily, _ := s.GetDailySendCounts(ctx, "did:plc:b", 14) 1722 + 1723 + if aDaily[13] != 1 { 1724 + t.Errorf("a expected 1 send today, got %d", aDaily[13]) 1725 + } 1726 + if bDaily[13] != 0 { 1727 + t.Errorf("b expected 0 sends today, got %d", bDaily[13]) 1728 + } 1729 + } 1730 + 1731 + // --- Complaint counts --- 1732 + 1733 + func TestGetComplaintCount(t *testing.T) { 1734 + s := testStore(t) 1735 + ctx := context.Background() 1736 + did := "did:plc:complaint1111111111111" 1737 + insertTestMemberWithDomain(t, s, did, "complaint.example.com") 1738 + 1739 + now := time.Now().UTC() 1740 + // Insert 2 complaints recently, 1 old 1741 + for i := 0; i < 2; i++ { 1742 + _, err := s.InsertFeedbackEvent(ctx, &FeedbackEvent{ 1743 + MemberDID: did, EventType: "complaint", 1744 + CreatedAt: now.Add(-time.Hour), 1745 + }) 1746 + if err != nil { 1747 + t.Fatalf("insert: %v", err) 1748 + } 1749 + } 1750 + _, _ = s.InsertFeedbackEvent(ctx, &FeedbackEvent{ 1751 + MemberDID: did, EventType: "complaint", 1752 + CreatedAt: now.Add(-48 * time.Hour), 1753 + }) 1754 + // A bounce should not count 1755 + _, _ = s.InsertFeedbackEvent(ctx, &FeedbackEvent{ 1756 + MemberDID: did, EventType: "bounce_hard", 1757 + CreatedAt: now.Add(-time.Hour), 1758 + }) 1759 + 1760 + n, err := s.GetComplaintCount(ctx, did, now.Add(-24*time.Hour)) 1761 + if err != nil { 1762 + t.Fatalf("GetComplaintCount: %v", err) 1763 + } 1764 + if n != 2 { 1765 + t.Errorf("expected 2 complaints, got %d", n) 1766 + } 1767 + } 1768 + 1769 + func TestGetComplaintCount_MemberIsolation(t *testing.T) { 1770 + s := testStore(t) 1771 + ctx := context.Background() 1772 + insertTestMemberWithDomain(t, s, "did:plc:a", "a.example.com") 1773 + insertTestMemberWithDomain(t, s, "did:plc:b", "b.example.com") 1774 + 1775 + now := time.Now().UTC() 1776 + _, _ = s.InsertFeedbackEvent(ctx, &FeedbackEvent{MemberDID: "did:plc:a", EventType: "complaint", CreatedAt: now}) 1777 + 1778 + aCount, _ := s.GetComplaintCount(ctx, "did:plc:a", now.Add(-time.Hour)) 1779 + bCount, _ := s.GetComplaintCount(ctx, "did:plc:b", now.Add(-time.Hour)) 1780 + if aCount != 1 { 1781 + t.Errorf("a expected 1, got %d", aCount) 1782 + } 1783 + if bCount != 0 { 1784 + t.Errorf("b expected 0, got %d", bCount) 1673 1785 } 1674 1786 } 1675 1787
+1 -1
osprey/tests/requirements.txt
··· 1 1 kafka-python==2.0.2 2 - PyYAML==6.0.1 2 + PyYAML==6.0.3
+1 -1
vendor/modules.txt
··· 6 6 # github.com/beorn7/perks v1.0.1 7 7 ## explicit; go 1.11 8 8 github.com/beorn7/perks/quantile 9 - # github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d 9 + # github.com/bluesky-social/indigo v0.0.0-20260422192121-9bad73ca4cad 10 10 ## explicit; go 1.26 11 11 github.com/bluesky-social/indigo/atproto/atclient 12 12 github.com/bluesky-social/indigo/atproto/atcrypto