Cooperative email for PDS operators
7
fork

Configure Feed

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

Enrollment fixes, content spray detection, AGPL-3.0 license, and operational metrics

+1741 -80
+208
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 + 7 + ## [Unreleased] 8 + 9 + ### Changed 10 + - License changed from MIT to AGPL-3.0-or-later 11 + - Add SPDX-License-Identifier headers to all Go source files 12 + 13 + ### Security 14 + - Add DID validation to admin handleMember endpoint (#16) 15 + - Narrow OAuth scope from transition:generic to repo:email.atmos.attestation (#189) 16 + - sec(account): SameSite=Strict blocks cookie after OAuth cross-site redirect — switch to Lax (#180) 17 + - sec(account): Referrer-Policy breaks form POST CSRF — switch to strict-origin-when-cross-origin (#178) 18 + - sec(smtp): protocol-level review — smuggling, CRLF injection, boundary handling (#171) 19 + - sec(ci): show unreachable vulns verbosely in weekly govulncheck cron (#172) 20 + - sec(ci): add govulncheck dependency scan workflow (#170) 21 + - sec(startup): call ValidateWebhookURL + redact URL in notify.enabled log (#169) 22 + - sec(rotation): durable notification queue for API-key rotation emails (#158) 23 + - sec(account): validate domain+contact_email before log/store (#156) 24 + - sec(account): cap ticket map size + replace ad-hoc prune with single ticker (#157) 25 + - sec(account): drop query-string ticket fallback on /account/regenerate (#159) 26 + - sec(account): redact OAuth start errors in /account/start (match attest.go) (#162) 27 + - sec(oauth): downgrade DID-mismatch log to hash or debug level (#165) 28 + - sec(regress): pin SMTP domain-fallback + DID-mismatch behavior with tests (#166) 29 + - sec(ci): lint for html.EscapeString on template fmt.Fprintf sites (#167) 30 + - sec(webhook): reject non-https operatorWebhookURL unless loopback-dev (#164) 31 + - sec(webhook): ship notify.VerifySignature helper for receivers (#155) 32 + - sec(webhook): HMAC replay protection via signed timestamp header (#154) 33 + - sec(webhook): reject non-https operatorWebhookURL unless loopback-dev (#164) 34 + - sec(webhook): ship notify.VerifySignature helper for receivers (#155) 35 + - sec(webhook): HMAC replay protection via signed timestamp header (#154) 36 + - sec(admin): rate-limit /admin/enroll-start by IP (#163) 37 + - sec(admin): verify fireKeyRegenerated email body does not carry plaintext key (#161) 38 + - sec(admin): timing-equalize bcrypt on /admin/self-status not-found path (#160) 39 + - sec(logs): redact OAuth state + recovery ticket IDs in logs (#153) 40 + - sec(account): migrate recovery ticket to HttpOnly cookie + Referrer-Policy (#152) 41 + - sec(ui): CSRF protection on /ui admin + /account member UIs (#151) 42 + - Manual approval gate for new enrollments (#96) 43 + - Add DID validation to admin handleMember endpoint (#16) 44 + - Fix API key in query string → Authorization header (#70) 45 + - validDID regex divergence between admin and labeler (#35) 46 + - SMTP AUTH logs unsanitized username — log injection (#34) 47 + - Unbounded did:web length in DID validation (#30) 48 + - Suspend reason log injection via query param (#29) 49 + - did:web DID allows newline via percent-encoding — log injection risk (#24) 50 + - Spool file not removed on queue-full rejection → duplicate delivery (#20) 51 + - Add DID validation to admin handleMember endpoint (#16) 52 + - Add UNIQUE constraint on members.domain column (#15) 53 + - Fix From-header parser multi-address bypass — use mail.ParseAddress (#14) 54 + - Fix spool write outside queue lock — crash can lose accepted messages (#12) 55 + - Fix rate limit TOCTOU between concurrent SMTP sessions (#11) 56 + - Fix From-header validation to prevent phishing via forged headers (#6) 57 + - Fix multi-recipient rate limit bypass (#5) 58 + 59 + ### Added 60 + - Add Prometheus metrics for opmail system-mail sends (#195) 61 + - Persist label bypass list across restarts (#17) 62 + - feat: admin warmup batch send button (#188) 63 + - UX: seamless manage↔enroll navigation + consistent @ prefix on handle inputs (#187) 64 + - Add sign-out + consistent site nav (masthead → /) (#149) 65 + - Marketing landing page at / + focused /enroll + fix missing ContactEmail on /account/manage (#148) 66 + - Compact landing + rename /recover to /account + match chrome + contact_email edit (#147) 67 + - Include retry-after hint in 451 rate-limit SMTP rejection message (#140) 68 + - Admin UI button for API-key regeneration (wraps /admin/member/{did}/regenerate-key) (#142) 69 + - Add operator notifications (pending members, suspensions, FBL complaints, incidents) (#135) 70 + - Register Yahoo CFL + any future provider FBL programs once volume warrants (#134) 71 + - Enroll wizard: labeler-lag polling on success page until label lands (#139) 72 + - Approval notification email to new member when operator approves (#137) 73 + - Self-service member credential recovery (API key + DKIM via OAuth re-auth) (#136) 74 + - Human review queue in Osprey UI for auto-suspension overrides (#94) 75 + - Shadow-mode rule deployment (observe vs. enforce) (#93) 76 + - Content fingerprinting: relay emits body/subject hash for cross-sender correlation (#90) 77 + - Phase 2: atproto OAuth to verify DID ownership on enrollments (#107) 78 + - Warn user API key won't be re-shown after clicking Publish Attestation (#125) 79 + - Inbound message log + admin UI visibility (#79) 80 + - Forward operator-classified inbound mail externally (unlocks SNDS verification + Yahoo CFL) (#133) 81 + - Add operator runbook covering DKIM rotation, FBL, warming, incident response (#132) 82 + - Admin: per-member detail page + inbound log UI (#131) 83 + - Add dual DKIM (d=atmos.email) + Feedback-ID header for pool-level FBL (#127) 84 + - Replace Druid event store with direct Postgres reads from atmosphere-mail admin (#126) 85 + - Onboarding gap: wizard missing step to publish email.atmos.attestation record (#124) 86 + - Gmail/Yahoo/AOL Feedback Loop (FBL) integration (#91) 87 + - Check tryfamilia.com NS propagation and verify DNS records (#1) 88 + - Replace sign-the-nonce enrollment with atproto OAuth + DNS TXT (#106) 89 + - Add admin_force bypass for DID challenge on enrollment (#73) 90 + - Handle→DID resolver in enrollment + polish (#100) 91 + - Self-service enrollment UI at atmos.email (#95) 92 + - Unique recipient domain counter + domain spray detection rule (#89) 93 + - Sub-hour velocity counters: sends_last_minute, sends_last_5_minutes (#88) 94 + - Consume Osprey labels in relay enforcement (highly_trusted, burst_*, gmail_*) (#87) 95 + - Leverage Osprey: velocity counters in events + expanded rule set (#85) 96 + - Inbound reply forwarding with SRS (#77) 97 + - Implement List-Unsubscribe with full suppression system (#76) 98 + - Improve SMTP error pass-through from onAccept to client (#19) 99 + - VictoriaMetrics scrape target for atmos-relay (#65) 100 + - Member suspension notification: SMTP error + self-serve status endpoint (#69) 101 + - Add VictoriaMetrics scrape target for atmos-relay Prometheus metrics (#48) 102 + - Operator dashboard: relay stats overview (messages, bounces, queue depth) (#52) 103 + - Operator dashboard: member detail view with suspend/reactivate actions (#51) 104 + - Operator dashboard: member list view with status, domain, send count (#50) 105 + - Dashboard: add bounces and queue depth stats (#64) 106 + - Optimize N+1 queries in member list endpoints (#61) 107 + - Add member labels to dashboard detail page (#58) 108 + - Add Tailscale Serve to expose dashboard at https://atmos-relay without port (#57) 109 + - Build operator dashboard with templ + htmx + Pico CSS (#54) 110 + - Operator dashboard: tech spike — choose embedded UI framework (templ vs htmx vs SPA) (#53) 111 + - Add GET /admin/members list endpoint (#49) 112 + - Add DID ownership challenge-response for enrollment verification (#45) 113 + - Add inbound bounce processing via port 25 SMTP listener (#40) 114 + - Improve SMTP error pass-through from onAccept to client (#19) 115 + - Add per-connection deadline and worker pool for MX delivery (#18) 116 + - Persist label bypass list across restarts (#17) 117 + 118 + ### Fixed 119 + - Fix Tailscale admin URL to serve enrollment pages (#198) 120 + - Fix enroll landing: contact email layout + dynamic placeholder + recover link (#146) 121 + - Fix empty API key and DKIM on enroll success page (JSON tag mismatch) (#123) 122 + - Fix enroll swaks quickstart to use --auth PLAIN + warmup schedule copy (#130) 123 + - Enroll verify failure should preserve step 2 with inline error (#122) 124 + - osprey-kafka OOM killed — bump memory from 1GB to 3GB (#105) 125 + - Osprey UI empty again + Tangled stale branch cache (#112) 126 + - Fix legal docs: LLC is Washington, not Delaware (#102) 127 + - Review round 3 fixes: async Kafka observability + singleflight test + suppressed pre-init (#101) 128 + - Background health probes for labeler + osprey reachability gauges (#78) 129 + - Fix thundering herd + shutdown ordering in Osprey integration (#68) 130 + - Fix review round 2: omitempty, malformed cache, dead metric labels (#67) 131 + - Fix review findings: metric init + non-200 error handling (#66) 132 + - Fix enrollment atomicity — wrap member + domain insert in transaction (#60) 133 + - Fix dashboard SSL error by serving UI on standard HTTP port (#55) 134 + - Update atmos-relay scrape target port from 8443 to 8080 (#56) 135 + - Fix review findings from bounce+challenge PRs (#46) 136 + - Fix retry counter reset on restart — persist attempts in spool (#13) 137 + 138 + ### Changed 139 + - Fix multi-domain enrollment: skip contact email + terms for existing members (#197) 140 + - Add handle label to OAuth callback metrics (#193) 141 + - Add HTTP request metrics to relay and wire Grafana visibility (#190) 142 + - Add enrollment funnel + office OAuth visit counters for Grafana (#191) 143 + - Enrollment handle typeahead: Bluesky actor search dropdown with avatars (#186) 144 + - Enrollment UX: simplify copy for non-email-technical PDS operators (#185) 145 + - Write Go label API for Osprey enforcer (cmd/label-api) (#184) 146 + - Honest copy pass: distinguish member self-hosting from operator federation (#150) 147 + - ci(deploy): add missing internal/** paths to relay-deploy trigger (#168) 148 + - Update operator-runbook FBL status: Yahoo CFL verified 2026-04-20 (#145) 149 + - Drop yahoo-verification-key TXT from atmos.email (verification succeeded, record is no-op now) (#144) 150 + - SML rule test framework for atmosphere-mail Osprey rules (#92) 151 + - Write blog post / status update about Atmosphere Mail (#84) 152 + - Delete stale feat/enroll-dns-txt-verification branch from Tangled (#129) 153 + - Rename Atmosphere Docs → Atmosphere Office in spec + memory (#128) 154 + - Queue has no max size — could OOM under sustained load (#8) 155 + - Recover atmosphere-mail Terraform state via resource imports (#80) 156 + - Recover atmosphere-mail Terraform state via resource imports (#80) 157 + - Pre-Dave polish audit: enrollment flow, error handling, admin approval UX (#115) 158 + - Admin dashboard: surface pending-review members (#116) 159 + - Post-enrollment: tell user they're pending operator approval, can't send yet (#117) 160 + - First-send quickstart: swaks/curl example on enrollment success page + docs (#118) 161 + - Enrollment: server-side handle-to-DID fallback when JS doesn't resolve (#119) 162 + - HEAD requests return 405 at the apex — allow HEAD on public routes (#120) 163 + - Error page: show specific error text above the generic message (#121) 164 + - Dashboard panels for inbound classification + reply-forwarding metrics (#81) 165 + - Investigate: can osprey-ui-api serve /api/events/* from Postgres, drop Druid? (#113) 166 + - Harden Osprey: druid-init periodic state-aware + Kafka retention + rule validation + Grafana + e2e probe (#114) 167 + - Add apex ALIAS records for URL forwards (atmosphere-mail.org + atmosphereemail.org) (#110) 168 + - Remove admin_force enrollment override (#111) 169 + - Swap atmosphereemail.org → atmospheremail.com (#109) 170 + - Multi-domain relay: atmosphereemail.org marketing + atmos.email redirect (#108) 171 + - fix(ui): mobile masthead override + tighten About tests (#104) 172 + - Who we are section + design critique round 2 (#103) 173 + - Merge pending PR #86 delete-dns-record workflow (#82) 174 + - LLC attribution in footer + about page (#98) 175 + - Boilerplate Terms of Service + Privacy Policy + AUP (#97) 176 + - UI polish pass on enrollment + landing pages (#99) 177 + - Fix Osprey rules deploy: pull from atmosphere-mail repo instead of inline heredocs (#86) 178 + - Migrate labeler to cloud-nix with public access at labeler.atmos.email (#75) 179 + - E2E test: DNS + attestation + labeler + send without bypass (#74) 180 + - Open-source cleanup: remove homelab-specific references (#72) 181 + - Observability fixes: Kafka errors, EHLO injection, emitter metrics (#71) 182 + - Relay enforcement: read Osprey labels at SMTP auth/send time (#63) 183 + - Add Kafka emitter integration tests (#62) 184 + - Multi-domain member model: split members into DID-level + domain-level tables (#59) 185 + - Add Prometheus metrics endpoint to relay (#47) 186 + - Integrate inbound bounces with existing bounce processor (#44) 187 + - Add VERP address decoder to match bounces to members and recipients (#43) 188 + - Add DSN message parser with bounce classification (#42) 189 + - Add inbound SMTP listener for bounce DSN messages on port 25 (#41) 190 + - VERP hash only 32 bits — collision risk at scale (#25) 191 + - Message data retention purge (30 days) (#39) 192 + - Open-source cleanup: gitignore, remove hardcoded Tailscale URL (#38) 193 + - Zero test coverage for delivery pipeline (#28) 194 + - End-to-end relay test with atmosphere-mail.org (#3) 195 + - Publish DKIM DNS records for atmosphere-mail.org (#37) 196 + - Missing validation tests for DID format, domain, duplicate domain (#36) 197 + - Reset() does not clear labelChecked flag (#33) 198 + - time.After leak in queue Run loop (#32) 199 + - EHLO hostname sends destination MX instead of relay domain — RFC 5321 violation (#31) 200 + - processReady drops remaining ready entries on shutdown (#27) 201 + - Partial delivery in onAccept causes duplicates when queue fills mid-batch (#26) 202 + - Label check called per-RCPT, should be once per session (#23) 203 + - Empty recipient in RecordBounce call — bounce correlation lost (#22) 204 + - CheckAndIncrementRate uses DEFERRED tx instead of IMMEDIATE (#21) 205 + - Delivery retry backoff should be configurable (#10) 206 + - Queue entries lost on crash — reload from SQLite on startup (#9) 207 + - Queue has no max size — could OOM under sustained load (#8) 208 + - Label cache grows unboundedly — add eviction (#7)
+657 -17
LICENSE
··· 1 - MIT License 1 + GNU AFFERO GENERAL PUBLIC LICENSE 2 + Version 3, 19 November 2007 3 + 4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 5 + Everyone is permitted to copy and distribute verbatim copies 6 + of this license document, but changing it is not allowed. 7 + 8 + Preamble 9 + 10 + The GNU Affero General Public License is a free, copyleft license for 11 + software and other kinds of works, specifically designed to ensure 12 + cooperation with the community in the case of network server software. 13 + 14 + The licenses for most software and other practical works are designed 15 + to take away your freedom to share and change the works. By contrast, 16 + our General Public Licenses are intended to guarantee your freedom to 17 + share and change all versions of a program--to make sure it remains free 18 + software for all its users. 19 + 20 + When we speak of free software, we are referring to freedom, not 21 + price. Our General Public Licenses are designed to make sure that you 22 + have the freedom to distribute copies of free software (and charge for 23 + them if you wish), that you receive source code or can get it if you 24 + want it, that you can change the software or use pieces of it in new 25 + free programs, and that you know you can do these things. 26 + 27 + Developers that use our General Public Licenses protect your rights 28 + with two steps: (1) assert copyright on the software, and (2) offer 29 + you this License which gives you legal permission to copy, distribute 30 + and/or modify the software. 31 + 32 + A secondary benefit of defending all users' freedom is that 33 + improvements made in alternate versions of the program, if they 34 + receive widespread use, become available for other developers to 35 + incorporate. Many developers of free software are heartened and 36 + encouraged by the resulting cooperation. However, in the case of 37 + software used on network servers, this result may fail to come about. 38 + The GNU General Public License permits making a modified version and 39 + letting the public access it on a server without ever releasing its 40 + source code to the public. 41 + 42 + The GNU Affero General Public License is designed specifically to 43 + ensure that, in such cases, the modified source code becomes available 44 + to the community. It requires the operator of a network server to 45 + provide the source code of the modified version running there to the 46 + users of that server. Therefore, public use of a modified version, on 47 + a publicly accessible server, gives the public access to the source 48 + code of the modified version. 49 + 50 + An older license, called the Affero General Public License and 51 + published by Affero, was designed to accomplish similar goals. This is 52 + a different license, not a version of the Affero GPL, but Affero has 53 + released a new version of the Affero GPL which permits relicensing under 54 + this license. 55 + 56 + The precise terms and conditions for copying, distribution and 57 + modification follow. 58 + 59 + TERMS AND CONDITIONS 60 + 61 + 0. Definitions. 62 + 63 + "This License" refers to version 3 of the GNU Affero General Public License. 64 + 65 + "Copyright" also means copyright-like laws that apply to other kinds of 66 + works, such as semiconductor masks. 67 + 68 + "The Program" refers to any copyrightable work licensed under this 69 + License. Each licensee is addressed as "you". "Licensees" and 70 + "recipients" may be individuals or organizations. 71 + 72 + To "modify" a work means to copy from or adapt all or part of the work 73 + in a fashion requiring copyright permission, other than the making of an 74 + exact copy. The resulting work is called a "modified version" of the 75 + earlier work or a work "based on" the earlier work. 76 + 77 + A "covered work" means either the unmodified Program or a work based 78 + on the Program. 79 + 80 + To "propagate" a work means to do anything with it that, without 81 + permission, would make you directly or secondarily liable for 82 + infringement under applicable copyright law, except executing it on a 83 + computer or modifying a private copy. Propagation includes copying, 84 + distribution (with or without modification), making available to the 85 + public, and in some countries other activities as well. 86 + 87 + To "convey" a work means any kind of propagation that enables other 88 + parties to make or receive copies. Mere interaction with a user through 89 + a computer network, with no transfer of a copy, is not conveying. 90 + 91 + An interactive user interface displays "Appropriate Legal Notices" 92 + to the extent that it includes a convenient and prominently visible 93 + feature that (1) displays an appropriate copyright notice, and (2) 94 + tells the user that there is no warranty for the work (except to the 95 + extent that warranties are provided), that licensees may convey the 96 + work under this License, and how to view a copy of this License. If 97 + the interface presents a list of user commands or options, such as a 98 + menu, a prominent item in the list meets this criterion. 99 + 100 + 1. Source Code. 101 + 102 + The "source code" for a work means the preferred form of the work 103 + for making modifications to it. "Object code" means any non-source 104 + form of a work. 105 + 106 + A "Standard Interface" means an interface that either is an official 107 + standard defined by a recognized standards body, or, in the case of 108 + interfaces specified for a particular programming language, one that 109 + is widely used among developers working in that language. 110 + 111 + The "System Libraries" of an executable work include anything, other 112 + than the work as a whole, that (a) is included in the normal form of 113 + packaging a Major Component, but which is not part of that Major 114 + Component, and (b) serves only to enable use of the work with that 115 + Major Component, or to implement a Standard Interface for which an 116 + implementation is available to the public in source code form. A 117 + "Major Component", in this context, means a major essential component 118 + (kernel, window system, and so on) of the specific operating system 119 + (if any) on which the executable work runs, or a compiler used to 120 + produce the work, or an object code interpreter used to run it. 121 + 122 + The "Corresponding Source" for a work in object code form means all 123 + the source code needed to generate, install, and (for an executable 124 + work) run the object code and to modify the work, including scripts to 125 + control those activities. However, it does not include the work's 126 + System Libraries, or general-purpose tools or generally available free 127 + programs which are used unmodified in performing those activities but 128 + which are not part of the work. For example, Corresponding Source 129 + includes interface definition files associated with source files for 130 + the work, and the source code for shared libraries and dynamically 131 + linked subprograms that the work is specifically designed to require, 132 + such as by intimate data communication or control flow between those 133 + subprograms and other parts of the work. 134 + 135 + The Corresponding Source need not include anything that users 136 + can regenerate automatically from other parts of the Corresponding 137 + Source. 138 + 139 + The Corresponding Source for a work in source code form is that 140 + same work. 141 + 142 + 2. Basic Permissions. 143 + 144 + All rights granted under this License are granted for the term of 145 + copyright on the Program, and are irrevocable provided the stated 146 + conditions are met. This License explicitly affirms your unlimited 147 + permission to run the unmodified Program. The output from running a 148 + covered work is covered by this License only if the output, given its 149 + content, constitutes a covered work. This License acknowledges your 150 + rights of fair use or other equivalent, as provided by copyright law. 151 + 152 + You may make, run and propagate covered works that you do not 153 + convey, without conditions so long as your license otherwise remains 154 + in force. You may convey covered works to others for the sole purpose 155 + of having them make modifications exclusively for you, or provide you 156 + with facilities for running those works, provided that you comply with 157 + the terms of this License in conveying all material for which you do 158 + not control copyright. Those thus making or running the covered works 159 + for you must do so exclusively on your behalf, under your direction 160 + and control, on terms that prohibit them from making any copies of 161 + your copyrighted material outside their relationship with you. 162 + 163 + Conveying under any other circumstances is permitted solely under 164 + the conditions stated below. Sublicensing is not allowed; section 10 165 + makes it unnecessary. 166 + 167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 + 169 + No covered work shall be deemed part of an effective technological 170 + measure under any applicable law fulfilling obligations under article 171 + 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 + similar laws prohibiting or restricting circumvention of such 173 + measures. 2 174 3 - Copyright (c) 2026 Scott Lanoue 175 + When you convey a covered work, you waive any legal power to forbid 176 + circumvention of technological measures to the extent such circumvention 177 + is effected by exercising rights under this License with respect to 178 + the covered work, and you disclaim any intention to limit operation or 179 + modification of the work as a means of enforcing, against the work's 180 + users, your or third parties' legal rights to forbid circumvention of 181 + technological measures. 4 182 5 - Permission is hereby granted, free of charge, to any person obtaining a copy 6 - of this software and associated documentation files (the "Software"), to deal 7 - in the Software without restriction, including without limitation the rights 8 - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 - copies of the Software, and to permit persons to whom the Software is 10 - furnished to do so, subject to the following conditions: 183 + 4. Conveying Verbatim Copies. 11 184 12 - The above copyright notice and this permission notice shall be included in all 13 - copies or substantial portions of the Software. 185 + You may convey verbatim copies of the Program's source code as you 186 + receive it, in any medium, provided that you conspicuously and 187 + appropriately publish on each copy an appropriate copyright notice; 188 + keep intact all notices stating that this License and any 189 + non-permissive terms added in accord with section 7 apply to the code; 190 + keep intact all notices of the absence of any warranty; and give all 191 + recipients a copy of this License along with the Program. 192 + 193 + You may charge any price or no price for each copy that you convey, 194 + and you may offer support or warranty protection for a fee. 195 + 196 + 5. Conveying Modified Source Versions. 14 197 15 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 - SOFTWARE. 198 + You may convey a work based on the Program, or the modifications to 199 + produce it from the Program, in the form of source code under the 200 + terms of section 4, provided that you also meet all of these conditions: 201 + 202 + a) The work must carry prominent notices stating that you modified 203 + it, and giving a relevant date. 204 + 205 + b) The work must carry prominent notices stating that it is 206 + released under this License and any conditions added under section 207 + 7. This requirement modifies the requirement in section 4 to 208 + "keep intact all notices". 209 + 210 + c) You must license the entire work, as a whole, under this 211 + License to anyone who comes into possession of a copy. This 212 + License will therefore apply, along with any applicable section 7 213 + additional terms, to the whole of the work, and all its parts, 214 + regardless of how they are packaged. This License gives no 215 + permission to license the work in any other way, but it does not 216 + invalidate such permission if you have separately received it. 217 + 218 + d) If the work has interactive user interfaces, each must display 219 + Appropriate Legal Notices; however, if the Program has interactive 220 + interfaces that do not display Appropriate Legal Notices, your 221 + work need not make them do so. 222 + 223 + A compilation of a covered work with other separate and independent 224 + works, which are not by their nature extensions of the covered work, 225 + and which are not combined with it such as to form a larger program, 226 + in or on a volume of a storage or distribution medium, is called an 227 + "aggregate" if the compilation and its resulting copyright are not 228 + used to limit the access or legal rights of the compilation's users 229 + beyond what the individual works permit. Inclusion of a covered work 230 + in an aggregate does not cause this License to apply to the other 231 + parts of the aggregate. 232 + 233 + 6. Conveying Non-Source Forms. 234 + 235 + You may convey a covered work in object code form under the terms 236 + of sections 4 and 5, provided that you also convey the 237 + machine-readable Corresponding Source under the terms of this License, 238 + in one of these ways: 239 + 240 + a) Convey the object code in, or embodied in, a physical product 241 + (including a physical distribution medium), accompanied by the 242 + Corresponding Source fixed on a durable physical medium 243 + customarily used for software interchange. 244 + 245 + b) Convey the object code in, or embodied in, a physical product 246 + (including a physical distribution medium), accompanied by a 247 + written offer, valid for at least three years and valid for as 248 + long as you offer spare parts or customer support for that product 249 + model, to give anyone who possesses the object code either (1) a 250 + copy of the Corresponding Source for all the software in the 251 + product that is covered by this License, on a durable physical 252 + medium customarily used for software interchange, for a price no 253 + more than your reasonable cost of physically performing this 254 + conveying of source, or (2) access to copy the 255 + Corresponding Source from a network server at no charge. 256 + 257 + c) Convey individual copies of the object code with a copy of the 258 + written offer to provide the Corresponding Source. This 259 + alternative is allowed only occasionally and noncommercially, and 260 + only if you received the object code with such an offer, in accord 261 + with subsection 6b. 262 + 263 + d) Convey the object code by offering access from a designated 264 + place (gratis or for a charge), and offer equivalent access to the 265 + Corresponding Source in the same way through the same place at no 266 + further charge. You need not require recipients to copy the 267 + Corresponding Source along with the object code. If the place to 268 + copy the object code is a network server, the Corresponding Source 269 + may be on a different server (operated by you or a third party) 270 + that supports equivalent copying facilities, provided you maintain 271 + clear directions next to the object code saying where to find the 272 + Corresponding Source. Regardless of what server hosts the 273 + Corresponding Source, you remain obligated to ensure that it is 274 + available for as long as needed to satisfy these requirements. 275 + 276 + e) Convey the object code using peer-to-peer transmission, provided 277 + you inform other peers where the object code and Corresponding 278 + Source of the work are being offered to the general public at no 279 + charge under subsection 6d. 280 + 281 + A separable portion of the object code, whose source code is excluded 282 + from the Corresponding Source as a System Library, need not be 283 + included in conveying the object code work. 284 + 285 + A "User Product" is either (1) a "consumer product", which means any 286 + tangible personal property which is normally used for personal, family, 287 + or household purposes, or (2) anything designed or sold for incorporation 288 + into a dwelling. In determining whether a product is a consumer product, 289 + doubtful cases shall be resolved in favor of coverage. For a particular 290 + product received by a particular user, "normally used" refers to a 291 + typical or common use of that class of product, regardless of the status 292 + of the particular user or of the way in which the particular user 293 + actually uses, or expects or is expected to use, the product. A product 294 + is a consumer product regardless of whether the product has substantial 295 + commercial, industrial or non-consumer uses, unless such uses represent 296 + the only significant mode of use of the product. 297 + 298 + "Installation Information" for a User Product means any methods, 299 + procedures, authorization keys, or other information required to install 300 + and execute modified versions of a covered work in that User Product from 301 + a modified version of its Corresponding Source. The information must 302 + suffice to ensure that the continued functioning of the modified object 303 + code is in no case prevented or interfered with solely because 304 + modification has been made. 305 + 306 + If you convey an object code work under this section in, or with, or 307 + specifically for use in, a User Product, and the conveying occurs as 308 + part of a transaction in which the right of possession and use of the 309 + User Product is transferred to the recipient in perpetuity or for a 310 + fixed term (regardless of how the transaction is characterized), the 311 + Corresponding Source conveyed under this section must be accompanied 312 + by the Installation Information. But this requirement does not apply 313 + if neither you nor any third party retains the ability to install 314 + modified object code on the User Product (for example, the work has 315 + been installed in ROM). 316 + 317 + The requirement to provide Installation Information does not include a 318 + requirement to continue to provide support service, warranty, or updates 319 + for a work that has been modified or installed by the recipient, or for 320 + the User Product in which it has been modified or installed. Access to a 321 + network may be denied when the modification itself materially and 322 + adversely affects the operation of the network or violates the rules and 323 + protocols for communication across the network. 324 + 325 + Corresponding Source conveyed, and Installation Information provided, 326 + in accord with this section must be in a format that is publicly 327 + documented (and with an implementation available to the public in 328 + source code form), and must require no special password or key for 329 + unpacking, reading or copying. 330 + 331 + 7. Additional Terms. 332 + 333 + "Additional permissions" are terms that supplement the terms of this 334 + License by making exceptions from one or more of its conditions. 335 + Additional permissions that are applicable to the entire Program shall 336 + be treated as though they were included in this License, to the extent 337 + that they are valid under applicable law. If additional permissions 338 + apply only to part of the Program, that part may be used separately 339 + under those permissions, but the entire Program remains governed by 340 + this License without regard to the additional permissions. 341 + 342 + When you convey a copy of a covered work, you may at your option 343 + remove any additional permissions from that copy, or from any part of 344 + it. (Additional permissions may be written to require their own 345 + removal in certain cases when you modify the work.) You may place 346 + additional permissions on material, added by you to a covered work, 347 + for which you have or can give appropriate copyright permission. 348 + 349 + Notwithstanding any other provision of this License, for material you 350 + add to a covered work, you may (if authorized by the copyright holders of 351 + that material) supplement the terms of this License with terms: 352 + 353 + a) Disclaiming warranty or limiting liability differently from the 354 + terms of sections 15 and 16 of this License; or 355 + 356 + b) Requiring preservation of specified reasonable legal notices or 357 + author attributions in that material or in the Appropriate Legal 358 + Notices displayed by works containing it; or 359 + 360 + c) Prohibiting misrepresentation of the origin of that material, or 361 + requiring that modified versions of such material be marked in 362 + reasonable ways as different from the original version; or 363 + 364 + d) Limiting the use for publicity purposes of names of licensors or 365 + authors of the material; or 366 + 367 + e) Declining to grant rights under trademark law for use of some 368 + trade names, trademarks, or service marks; or 369 + 370 + f) Requiring indemnification of licensors and authors of that 371 + material by anyone who conveys the material (or modified versions of 372 + it) with contractual assumptions of liability to the recipient, for 373 + any liability that these contractual assumptions directly impose on 374 + those licensors and authors. 375 + 376 + All other non-permissive additional terms are considered "further 377 + restrictions" within the meaning of section 10. If the Program as you 378 + received it, or any part of it, contains a notice stating that it is 379 + governed by this License along with a term that is a further 380 + restriction, you may remove that term. If a license document contains 381 + a further restriction but permits relicensing or conveying under this 382 + License, you may add to a covered work material governed by the terms 383 + of that license document, provided that the further restriction does 384 + not survive such relicensing or conveying. 385 + 386 + If you add terms to a covered work in accord with this section, you 387 + must place, in the relevant source files, a statement of the 388 + additional terms that apply to those files, or a notice indicating 389 + where to find the applicable terms. 390 + 391 + Additional terms, permissive or non-permissive, may be stated in the 392 + form of a separately written license, or stated as exceptions; 393 + the above requirements apply either way. 394 + 395 + 8. Termination. 396 + 397 + You may not propagate or modify a covered work except as expressly 398 + provided under this License. Any attempt otherwise to propagate or 399 + modify it is void, and will automatically terminate your rights under 400 + this License (including any patent licenses granted under the third 401 + paragraph of section 11). 402 + 403 + However, if you cease all violation of this License, then your 404 + license from a particular copyright holder is reinstated (a) 405 + provisionally, unless and until the copyright holder explicitly and 406 + finally terminates your license, and (b) permanently, if the copyright 407 + holder fails to notify you of the violation by some reasonable means 408 + prior to 60 days after the cessation. 409 + 410 + Moreover, your license from a particular copyright holder is 411 + reinstated permanently if the copyright holder notifies you of the 412 + violation by some reasonable means, this is the first time you have 413 + received notice of violation of this License (for any work) from that 414 + copyright holder, and you cure the violation prior to 30 days after 415 + your receipt of the notice. 416 + 417 + Termination of your rights under this section does not terminate the 418 + licenses of parties who have received copies or rights from you under 419 + this License. If your rights have been terminated and not permanently 420 + reinstated, you do not qualify to receive new licenses for the same 421 + material under section 10. 422 + 423 + 9. Acceptance Not Required for Having Copies. 424 + 425 + You are not required to accept this License in order to receive or 426 + run a copy of the Program. Ancillary propagation of a covered work 427 + occurring solely as a consequence of using peer-to-peer transmission 428 + to receive a copy likewise does not require acceptance. However, 429 + nothing other than this License grants you permission to propagate or 430 + modify any covered work. These actions infringe copyright if you do 431 + not accept this License. Therefore, by modifying or propagating a 432 + covered work, you indicate your acceptance of this License to do so. 433 + 434 + 10. Automatic Licensing of Downstream Recipients. 435 + 436 + Each time you convey a covered work, the recipient automatically 437 + receives a license from the original licensors, to run, modify and 438 + propagate that work, subject to this License. You are not responsible 439 + for enforcing compliance by third parties with this License. 440 + 441 + An "entity transaction" is a transaction transferring control of an 442 + organization, or substantially all assets of one, or subdividing an 443 + organization, or merging organizations. If propagation of a covered 444 + work results from an entity transaction, each party to that 445 + transaction who receives a copy of the work also receives whatever 446 + licenses to the work the party's predecessor in interest had or could 447 + give under the previous paragraph, plus a right to possession of the 448 + Corresponding Source of the work from the predecessor in interest, if 449 + the predecessor has it or can get it with reasonable efforts. 450 + 451 + You may not impose any further restrictions on the exercise of the 452 + rights granted or affirmed under this License. For example, you may 453 + not impose a license fee, royalty, or other charge for exercise of 454 + rights granted under this License, and you may not initiate litigation 455 + (including a cross-claim or counterclaim in a lawsuit) alleging that 456 + any patent claim is infringed by making, using, selling, offering for 457 + sale, or importing the Program or any portion of it. 458 + 459 + 11. Patents. 460 + 461 + A "contributor" is a copyright holder who authorizes use under this 462 + License of the Program or a work on which the Program is based. The 463 + work thus licensed is called the contributor's "contributor version". 464 + 465 + A contributor's "essential patent claims" are all patent claims 466 + owned or controlled by the contributor, whether already acquired or 467 + hereafter acquired, that would be infringed by some manner, permitted 468 + by this License, of making, using, or selling its contributor version, 469 + but do not include claims that would be infringed only as a 470 + consequence of further modification of the contributor version. For 471 + purposes of this definition, "control" includes the right to grant 472 + patent sublicenses in a manner consistent with the requirements of 473 + this License. 474 + 475 + Each contributor grants you a non-exclusive, worldwide, royalty-free 476 + patent license under the contributor's essential patent claims, to 477 + make, use, sell, offer for sale, import and otherwise run, modify and 478 + propagate the contents of its contributor version. 479 + 480 + In the following three paragraphs, a "patent license" is any express 481 + agreement or commitment, however denominated, not to enforce a patent 482 + (such as an express permission to practice a patent or covenant not to 483 + sue for patent infringement). To "grant" such a patent license to a 484 + party means to make such an agreement or commitment not to enforce a 485 + patent against the party. 486 + 487 + If you convey a covered work, knowingly relying on a patent license, 488 + and the Corresponding Source of the work is not available for anyone 489 + to copy, free of charge and under the terms of this License, through a 490 + publicly available network server or other readily accessible means, 491 + then you must either (1) cause the Corresponding Source to be so 492 + available, or (2) arrange to deprive yourself of the benefit of the 493 + patent license for this particular work, or (3) arrange, in a manner 494 + consistent with the requirements of this License, to extend the patent 495 + license to downstream recipients. "Knowingly relying" means you have 496 + actual knowledge that, but for the patent license, your conveying the 497 + covered work in a country, or your recipient's use of the covered work 498 + in a country, would infringe one or more identifiable patents in that 499 + country that you have reason to believe are valid. 500 + 501 + If, pursuant to or in connection with a single transaction or 502 + arrangement, you convey, or propagate by procuring conveyance of, a 503 + covered work, and grant a patent license to some of the parties 504 + receiving the covered work authorizing them to use, propagate, modify 505 + or convey a specific copy of the covered work, then the patent license 506 + you grant is automatically extended to all recipients of the covered 507 + work and works based on it. 508 + 509 + A patent license is "discriminatory" if it does not include within 510 + the scope of its coverage, prohibits the exercise of, or is 511 + conditioned on the non-exercise of one or more of the rights that are 512 + specifically granted under this License. You may not convey a covered 513 + work if you are a party to an arrangement with a third party that is 514 + in the business of distributing software, under which you make payment 515 + to the third party based on the extent of your activity of conveying 516 + the work, and under which the third party grants, to any of the 517 + parties who would receive the covered work from you, a discriminatory 518 + patent license (a) in connection with copies of the covered work 519 + conveyed by you (or copies made from those copies), or (b) primarily 520 + for and in connection with specific products or compilations that 521 + contain the covered work, unless you entered into that arrangement, 522 + or that patent license was granted, prior to 28 March 2007. 523 + 524 + Nothing in this License shall be construed as excluding or limiting 525 + any implied license or other defenses to infringement that may 526 + otherwise be available to you under applicable patent law. 527 + 528 + 12. No Surrender of Others' Freedom. 529 + 530 + If conditions are imposed on you (whether by court order, agreement or 531 + otherwise) that contradict the conditions of this License, they do not 532 + excuse you from the conditions of this License. If you cannot convey a 533 + covered work so as to satisfy simultaneously your obligations under this 534 + License and any other pertinent obligations, then as a consequence you may 535 + not convey it at all. For example, if you agree to terms that obligate you 536 + to collect a royalty for further conveying from those to whom you convey 537 + the Program, the only way you could satisfy both those terms and this 538 + License would be to refrain entirely from conveying the Program. 539 + 540 + 13. Remote Network Interaction; Use with the GNU General Public License. 541 + 542 + Notwithstanding any other provision of this License, if you modify the 543 + Program, your modified version must prominently offer all users 544 + interacting with it remotely through a computer network (if your version 545 + supports such interaction) an opportunity to receive the Corresponding 546 + Source of your version by providing access to the Corresponding Source 547 + from a network server at no charge, through some standard or customary 548 + means of facilitating copying of software. This Corresponding Source 549 + shall include the Corresponding Source for any work covered by version 3 550 + of the GNU General Public License that is incorporated pursuant to the 551 + following paragraph. 552 + 553 + Notwithstanding any other provision of this License, you have 554 + permission to link or combine any covered work with a work licensed 555 + under version 3 of the GNU General Public License into a single 556 + combined work, and to convey the resulting work. The terms of this 557 + License will continue to apply to the part which is the covered work, 558 + but the work with which it is combined will remain governed by version 559 + 3 of the GNU General Public License. 560 + 561 + 14. Revised Versions of this License. 562 + 563 + The Free Software Foundation may publish revised and/or new versions of 564 + the GNU Affero General Public License from time to time. Such new versions 565 + will be similar in spirit to the present version, but may differ in detail to 566 + address new problems or concerns. 567 + 568 + Each version is given a distinguishing version number. If the 569 + Program specifies that a certain numbered version of the GNU Affero General 570 + Public License "or any later version" applies to it, you have the 571 + option of following the terms and conditions either of that numbered 572 + version or of any later version published by the Free Software 573 + Foundation. If the Program does not specify a version number of the 574 + GNU Affero General Public License, you may choose any version ever published 575 + by the Free Software Foundation. 576 + 577 + If the Program specifies that a proxy can decide which future 578 + versions of the GNU Affero General Public License can be used, that proxy's 579 + public statement of acceptance of a version permanently authorizes you 580 + to choose that version for the Program. 581 + 582 + Later license versions may give you additional or different 583 + permissions. However, no additional obligations are imposed on any 584 + author or copyright holder as a result of your choosing to follow a 585 + later version. 586 + 587 + 15. Disclaimer of Warranty. 588 + 589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 + 598 + 16. Limitation of Liability. 599 + 600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 + SUCH DAMAGES. 609 + 610 + 17. Interpretation of Sections 15 and 16. 611 + 612 + If the disclaimer of warranty and limitation of liability provided 613 + above cannot be given local legal effect according to their terms, 614 + reviewing courts shall apply local law that most closely approximates 615 + an absolute waiver of all civil liability in connection with the 616 + Program, unless a warranty or assumption of liability accompanies a 617 + copy of the Program in return for a fee. 618 + 619 + END OF TERMS AND CONDITIONS 620 + 621 + How to Apply These Terms to Your New Programs 622 + 623 + If you develop a new program, and you want it to be of the greatest 624 + possible use to the public, the best way to achieve this is to make it 625 + free software which everyone can redistribute and change under these terms. 626 + 627 + To do so, attach the following notices to the program. It is safest 628 + to attach them to the start of each source file to most effectively 629 + state the exclusion of warranty; and each file should have at least 630 + the "copyright" line and a pointer to where the full notice is found. 631 + 632 + <one line to give the program's name and a brief idea of what it does.> 633 + Copyright (C) <year> <name of author> 634 + 635 + This program is free software: you can redistribute it and/or modify 636 + it under the terms of the GNU Affero General Public License as published by 637 + the Free Software Foundation, either version 3 of the License, or 638 + (at your option) any later version. 639 + 640 + This program is distributed in the hope that it will be useful, 641 + but WITHOUT ANY WARRANTY; without even the implied warranty of 642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 + GNU Affero General Public License for more details. 644 + 645 + You should have received a copy of the GNU Affero General Public License 646 + along with this program. If not, see <https://www.gnu.org/licenses/>. 647 + 648 + Also add information on how to contact you by electronic and paper mail. 649 + 650 + If your software can interact with users remotely through a computer 651 + network, you should also make sure that it provides a way for users to 652 + get its source. For example, if your program is a web application, its 653 + interface could display a "Source" link that leads users to an archive 654 + of the code. There are many ways you could offer source, and different 655 + solutions will be better for different programs; see section 13 for the 656 + specific requirements. 657 + 658 + You should also get your employer (if you work as a programmer) or school, 659 + if any, to sign a "copyright disclaimer" for the program, if necessary. 660 + For more information on this, and how to apply and follow the GNU AGPL, see 661 + <https://www.gnu.org/licenses/>.
+1 -1
README.md
··· 116 116 117 117 ## License 118 118 119 - MIT 119 + AGPL-3.0-or-later — see [LICENSE](LICENSE) 120 120 121 121 ## Author 122 122
+2
cmd/label-api/main.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/label-api/main_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/labeler/bootstrap.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/labeler/bootstrap_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/labeler/main.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/labeler/main_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+2
cmd/relay/inbound_logger.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 // Adapter that bridges relay.InboundLogger onto relaystore's persistent
+23 -16
cmd/relay/main.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import ( ··· 596 598 return err 597 599 } 598 600 601 + // Content fingerprint computed once from the original data (before 602 + // per-recipient headers are prepended). Used for both the messages 603 + // table (content-spray detection) and the Osprey event. 604 + subject, body := extractSubjectAndBody(data) 605 + contentFP := relay.ContentFingerprint(subject, body) 606 + 599 607 for _, recipient := range deliverable { 600 608 verpFrom := relay.VERPReturnPath(member.DID, recipient, cfg.Domain) 601 609 ··· 637 645 638 646 // Log message to store 639 647 msgID, err := store.InsertMessage(context.Background(), &relaystore.Message{ 640 - MemberDID: member.DID, 641 - FromAddr: from, 642 - ToAddr: recipient, 643 - MessageID: extractMessageID(string(data)), 644 - Status: relaystore.MsgQueued, 645 - CreatedAt: time.Now().UTC(), 648 + MemberDID: member.DID, 649 + FromAddr: from, 650 + ToAddr: recipient, 651 + MessageID: extractMessageID(string(data)), 652 + Status: relaystore.MsgQueued, 653 + CreatedAt: time.Now().UTC(), 654 + ContentFingerprint: contentFP, 646 655 }) 647 656 if err != nil { 648 657 return fmt.Errorf("log message: %v", err) ··· 676 685 sendsLast5Min, _ := store.GetSendCountSince(context.Background(), member.DID, now.Add(-5*time.Minute)) 677 686 uniqueDomains, _ := store.GetUniqueRecipientDomainsSince(context.Background(), member.DID, now.Add(-time.Hour)) 678 687 _, bounced24h, _ := store.GetMessageCounts(context.Background(), member.DID, now.Add(-24*time.Hour)) 679 - // Content fingerprint lets Osprey / admin queries correlate the same 680 - // message going out under multiple sender DIDs (coordinated-campaign 681 - // signature). Computed from the original `data` — not per-recipient 682 - // variants, which differ by List-Unsubscribe token and would defeat 683 - // the whole point. Best-effort: a parse error emits an empty subject/ 684 - // body fingerprint rather than blocking the send. 685 - subject, body := extractSubjectAndBody(data) 686 - fingerprint := relay.ContentFingerprint(subject, body) 688 + sameContentRecipients, _ := store.GetSameContentRecipientsSince(context.Background(), member.DID, contentFP, now.Add(-time.Hour)) 687 689 ospreyEmitter.Emit(context.Background(), osprey.EventData{ 688 690 EventType: osprey.EventRelayAttempt, 689 691 SenderDID: member.DID, ··· 696 698 SendsLastHour: sendsLastHour, 697 699 HardBouncesLast24h: int(bounced24h), 698 700 UniqueRecipientDomainsLastHour: uniqueDomains, 699 - ContentFingerprint: fingerprint, 701 + ContentFingerprint: contentFP, 702 + SameContentRecipientsLastHour: sameContentRecipients, 700 703 }) 701 704 702 705 return nil ··· 919 922 relay.OpMailContext{RelayDomain: cfg.OperatorDKIMDomain}, 920 923 opSigner, 921 924 relay.DefaultOpMailSender(), 925 + relay.WithOpMailMetrics(metrics), 922 926 ) 923 927 adminAPI.SetOpMailer(opMailer, cfg.OperatorForwardTo, cfg.PublicBaseURL) 924 928 } ··· 1011 1015 inboundUI := adminui.NewInboundHandler(store) 1012 1016 reviewQueueUI := adminui.NewReviewQueueHandler(store) 1013 1017 adminMux := http.NewServeMux() 1018 + adminMux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { 1019 + http.Redirect(w, r, "/ui/", http.StatusFound) 1020 + }) 1014 1021 adminMux.Handle("/ui/", dashboardUI) 1015 1022 // Relay-local event mirror pages — /admin/events, /admin/members/{did}/events, 1016 1023 // /admin/rules. These replace the old Druid-backed Osprey UI and run on ··· 1252 1259 // Osprey emitter above). 1253 1260 var eventsConsumer *relay.OspreyEventConsumer 1254 1261 if cfg.KafkaBroker != "" { 1255 - eventsConsumer = relay.NewOspreyEventConsumer(cfg.KafkaBroker, store) 1262 + eventsConsumer = relay.NewOspreyEventConsumer(cfg.KafkaBroker, store, relay.WithConsumerMetrics(metrics)) 1256 1263 go func() { 1257 1264 if err := eventsConsumer.Run(ctx); err != nil && ctx.Err() == nil { 1258 1265 log.Printf("relay_events.consumer: %v", err)
+2
cmd/sendtest/main.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package main 2 4 3 5 import (
+11 -5
internal/admin/api.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import ( ··· 349 351 http.Error(w, "contactEmail must be a valid email address", http.StatusBadRequest) 350 352 return 351 353 } 352 - if !req.TermsAccepted { 353 - http.Error(w, "terms acceptance required", http.StatusBadRequest) 354 - return 355 - } 356 354 357 355 // Reject if the domain is already owned by any member. Return the 358 356 // conflict early so the user doesn't waste time publishing a TXT ··· 367 365 if existingDomain.DID == did { 368 366 http.Error(w, "You've already enrolled this domain. Sign in at /account to manage it.", http.StatusConflict) 369 367 } else { 370 - // Don't reveal who owns the domain — that leaks membership info. 371 368 http.Error(w, "This domain is registered to another account.", http.StatusConflict) 372 369 } 373 370 return ··· 385 382 if len(existingDomains) >= maxDomainsPerMember { 386 383 http.Error(w, fmt.Sprintf("domain limit reached — your account currently supports up to %d sending domains", maxDomainsPerMember), http.StatusConflict) 387 384 return 385 + } 386 + 387 + isExistingMember := len(existingDomains) > 0 388 + if !isExistingMember && !req.TermsAccepted { 389 + http.Error(w, "terms acceptance required", http.StatusBadRequest) 390 + return 391 + } 392 + if isExistingMember && contactEmail == "" { 393 + contactEmail = existingDomains[0].ContactEmail 388 394 } 389 395 390 396 token, err := enroll.NewToken()
+56
internal/admin/api_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import ( ··· 1294 1296 } 1295 1297 if !strings.Contains(w.Body.String(), "domain limit") { 1296 1298 t.Errorf("body should mention domain limit, got: %s", w.Body.String()) 1299 + } 1300 + } 1301 + 1302 + func TestEnrollStart_ExistingMemberSkipsTerms(t *testing.T) { 1303 + api, store, _ := testEnrollAPI(t) 1304 + ctx := context.Background() 1305 + now := time.Now().UTC() 1306 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1307 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now}) 1308 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 1309 + Domain: "first.example.com", DID: did, 1310 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 1311 + DKIMSelector: "s", CreatedAt: now, 1312 + }) 1313 + 1314 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: "second.example.com", TermsAccepted: false}) 1315 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 1316 + w := httptest.NewRecorder() 1317 + api.ServeHTTP(w, req) 1318 + if w.Code != http.StatusOK { 1319 + t.Errorf("status = %d, want 200; existing members should not need to re-accept terms: %s", w.Code, w.Body.String()) 1320 + } 1321 + } 1322 + 1323 + func TestEnrollStart_ExistingMemberInheritsContactEmail(t *testing.T) { 1324 + api, store, _ := testEnrollAPI(t) 1325 + ctx := context.Background() 1326 + now := time.Now().UTC() 1327 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1328 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now}) 1329 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 1330 + Domain: "first.example.com", DID: did, ContactEmail: "dave@example.com", 1331 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 1332 + DKIMSelector: "s", CreatedAt: now, 1333 + }) 1334 + 1335 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: "second.example.com"}) 1336 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 1337 + w := httptest.NewRecorder() 1338 + api.ServeHTTP(w, req) 1339 + if w.Code != http.StatusOK { 1340 + t.Errorf("status = %d, want 200; existing member with empty contactEmail should inherit from first domain: %s", w.Code, w.Body.String()) 1341 + } 1342 + 1343 + var resp struct{ Token string } 1344 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 1345 + t.Fatalf("decode response: %v", err) 1346 + } 1347 + pending, err := store.GetPendingEnrollment(ctx, resp.Token) 1348 + if err != nil || pending == nil { 1349 + t.Fatalf("expected pending enrollment, got err=%v", err) 1350 + } 1351 + if pending.ContactEmail != "dave@example.com" { 1352 + t.Errorf("contactEmail = %q, want %q (inherited from first domain)", pending.ContactEmail, "dave@example.com") 1297 1353 } 1298 1354 } 1299 1355
+2
internal/admin/email_verify_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import (
+2
internal/admin/notifications_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 // Tests for the durable notification queue wiring (audit #158):
+2
internal/admin/operator_dkim.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import (
+2
internal/admin/operator_dkim_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import (
+2
internal/admin/opmail.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import (
+2
internal/admin/ratelimit.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 import (
+2
internal/admin/security_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package admin 2 4 3 5 // Security-focused tests covering:
+2
internal/admin/ui/attest.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/csrf.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // CSRF protection for state-changing UI endpoints.
+2
internal/admin/ui/csrf_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // CSRF middleware tests. Exercises the matrix from the audit spec for
+2
internal/admin/ui/embed.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import "embed"
+2
internal/admin/ui/enroll.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/enroll_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/events.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Admin UI pages for the relay-local Osprey event mirror:
+2
internal/admin/ui/events_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/handlers.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/handlers_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/hashlog.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Log-safe hashing for credential-shaped values (OAuth state tokens,
+2
internal/admin/ui/hashlog_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Tests for the log-hashing helper. CRIT #153 — we must never write raw
+2
internal/admin/ui/inbound.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Admin UI for the persistent inbound audit log.
+2
internal/admin/ui/metadata.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/recover.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Self-service credential recovery.
+2
internal/admin/ui/recover_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/review_queue.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Admin UI: human review queue for auto-suspension overrides.
+2
internal/admin/ui/review_queue_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import (
+2
internal/admin/ui/sanitize.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 // Helpers for validating + defanging user-supplied values before they
+2
internal/admin/ui/sanitize_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package ui 2 4 3 5 import "testing"
+2
internal/admin/ui/templates/dashboard_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+15 -13
internal/admin/ui/templates/enroll.templ
··· 881 881 autocapitalize="off" 882 882 /> 883 883 884 - <label for="contact_email">Contact email</label> 885 - <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> 886 - <input 887 - type="email" 888 - id="contact_email" 889 - name="contact_email" 890 - placeholder="you@example.com" 891 - required 892 - autocomplete="email" 893 - spellcheck="false" 894 - autocapitalize="off" 895 - /> 896 - 897 884 <button type="submit">Add domain →</button> 898 885 </form> 899 886 } else { ··· 928 915 spellcheck="false" 929 916 autocapitalize="off" 930 917 /> 918 + 919 + <label style="display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;"> 920 + <input 921 + type="checkbox" 922 + name="terms_accepted" 923 + id="terms_accepted" 924 + required 925 + style="margin-top: 0.25rem; accent-color: var(--accent);" 926 + /> 927 + <span style="font-size: var(--t-s);"> 928 + I agree to the <a href="/terms" target="_blank">Terms of Service</a> and 929 + <a href="/aup" target="_blank">Acceptable Use Policy</a>, 930 + which may be updated with reasonable notice. 931 + </span> 932 + </label> 931 933 932 934 <button type="submit">Start enrollment →</button> 933 935 </form>
+4 -2
internal/admin/ui/templates/enroll_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001 ··· 284 286 return templ_7745c5c3_Err 285 287 } 286 288 } else if len(existingDomains) == 1 { 287 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">You can add one more sending domain during this alpha.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\">Add domain →</button></form>") 289 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">You can add one more sending domain during this alpha.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\">Add domain →</button></form>") 288 290 if templ_7745c5c3_Err != nil { 289 291 return templ_7745c5c3_Err 290 292 } 291 293 } else { 292 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">Identity verified. Now tell us about the domain you want to send from.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\">Start enrollment →</button></form>") 294 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">Identity verified. Now tell us about the domain you want to send from.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <label style=\"display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;\"><input type=\"checkbox\" name=\"terms_accepted\" id=\"terms_accepted\" required style=\"margin-top: 0.25rem; accent-color: var(--accent);\"> <span style=\"font-size: var(--t-s);\">I agree to the <a href=\"/terms\" target=\"_blank\">Terms of Service</a> and <a href=\"/aup\" target=\"_blank\">Acceptable Use Policy</a>, which may be updated with reasonable notice.</span></label> <button type=\"submit\">Start enrollment →</button></form>") 293 295 if templ_7745c5c3_Err != nil { 294 296 return templ_7745c5c3_Err 295 297 }
+2
internal/admin/ui/templates/events_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+2
internal/admin/ui/templates/inbound.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // Admin UI templates for the inbound audit log.
+2
internal/admin/ui/templates/label_status.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // Small polling block for the enroll success page. Renders a status
+2
internal/admin/ui/templates/layout_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+2
internal/admin/ui/templates/marketing.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // Marketing landing at /. Top of funnel: tells the cooperative-
+2
internal/admin/ui/templates/member_detail_rich.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // Hand-written templates for the rich per-member detail page and the
+2
internal/admin/ui/templates/member_detail_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+2
internal/admin/ui/templates/members_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+2
internal/admin/ui/templates/recover.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // Member-facing self-service /account flow templates. Uses the same
+2
internal/admin/ui/templates/regenerate_key.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package templates 2 4 3 5 // One-time reveal page for the admin-UI regenerate-key button. Same
+2
internal/admin/ui/templates/review_queue_templ.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Code generated by templ - DO NOT EDIT. 2 4 3 5 // templ: version: v0.3.1001
+2
internal/atpoauth/authstore.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package atpoauth 2 4 3 5 import (
+2
internal/atpoauth/client.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package atpoauth wraps indigo's atproto OAuth client for the 2 4 // atmosphere-mail enrollment wizard. It exposes a narrow, purpose-built API 3 5 // — StartAuthFlow, CompleteCallback, PutRecord — and hides indigo's type
+2
internal/atpoauth/client_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package atpoauth 2 4 3 5 import (
+2
internal/atpoauth/did_mismatch_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package atpoauth 2 4 3 5 // Regression tests pinning the DID-mismatch contract in CompleteCallback
+2
internal/config/config.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package config 2 4 3 5 import (
+2
internal/config/config_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package config 2 4 3 5 import (
+2
internal/config/webhook_url.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package config 2 4 3 5 import (
+2
internal/dns/verifier.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package dns 2 4 3 5 import (
+2
internal/dns/verifier_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package dns 2 4 3 5 import (
+2
internal/domain/control.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package domain 2 4 3 5 import (
+2
internal/domain/control_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package domain 2 4 3 5 import (
+2
internal/enroll/verifier.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package enroll provides the domain-ownership-proof primitives used by the 2 4 // self-service enrollment wizard. 3 5 //
+2
internal/enroll/verifier_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package enroll 2 4 3 5 import (
+2
internal/jetstream/consumer.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package jetstream 2 4 3 5 import (
+2
internal/jetstream/consumer_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package jetstream 2 4 3 5 import (
+2
internal/jetstream/types.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package jetstream 2 4 3 5 import "encoding/json"
+2
internal/label/manager.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import (
+2
internal/label/manager_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import (
+2
internal/label/signer.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import (
+2
internal/label/signer_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import (
+2
internal/label/validate.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import (
+2
internal/label/validate_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package label 2 4 3 5 import "testing"
+2
internal/notify/queue.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package notify 2 4 3 5 import (
+2
internal/notify/queue_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package notify 2 4 3 5 import (
+2
internal/notify/verify.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package notify delivers signed operator webhook notifications for 2 4 // atmosphere-mail events (key rotations, security alerts, etc.) and ships a 3 5 // reference verifier for receivers.
+2
internal/notify/verify_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package notify 2 4 3 5 import (
+2
internal/notify/webhook.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package notify dispatches operator-facing notifications to a 2 4 // configured HTTP webhook. Events are structured JSON; operators wire 3 5 // the URL to whatever sink they prefer — Slack incoming-webhook, a
+2
internal/notify/webhook_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package notify 2 4 3 5 import (
+2
internal/osprey/emitter.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package osprey 2 4 3 5 import (
+2
internal/osprey/emitter_integration_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package osprey 2 4 3 5 import (
+2
internal/osprey/emitter_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package osprey 2 4 3 5 import (
+3
internal/osprey/events.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package osprey emits relay events to an Osprey instance via Kafka. 2 4 // 3 5 // Events are emitted at key decision points in the relay pipeline: ··· 63 65 SendsLastHour int `json:"sends_last_hour"` 64 66 HardBouncesLast24h int `json:"hard_bounces_last_24h"` 65 67 UniqueRecipientDomainsLastHour int `json:"unique_recipient_domains_last_hour"` 68 + SameContentRecipientsLastHour int `json:"same_content_recipients_last_hour"` 66 69 67 70 // relay_rejected 68 71 RejectReason string `json:"reject_reason,omitempty"`
+2
internal/relay/arf/parser.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package arf parses Abuse Reporting Format (ARF) messages per RFC 5965. 2 4 // 3 5 // ARF is how major mailbox providers (Gmail, Yahoo, Microsoft, AOL) deliver
+2
internal/relay/arf/parser_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package arf 2 4 3 5 import (
+2
internal/relay/auth.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/auth_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/bounce.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/bounce_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/crlf.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — CRLF-safe primitives for SMTP protocol surfaces. 2 4 // 3 5 // These helpers address two classes of issue:
+2
internal/relay/crlf_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/delivery_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/didresolver.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/didresolver_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/dkim.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/dkim_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/dsn.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/dsn_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+32 -6
internal/relay/events_consumer.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 // Kafka consumer that pulls Osprey's rule-evaluation output ··· 55 57 LastKafkaOffset(ctx context.Context) (int64, error) 56 58 } 57 59 60 + // ConsumerMetrics is the subset of Metrics the event consumer needs. 61 + // Satisfied by *Metrics; tests can substitute a stub or nil. 62 + type ConsumerMetrics interface { 63 + RecordEventConsumed(offset int64) 64 + } 65 + 58 66 // OspreyEventConsumer pulls Osprey execution results from Kafka and 59 67 // writes them to the relay's local event store. 60 68 type OspreyEventConsumer struct { 61 - reader kafkaReader 62 - store OspreyEventStore 69 + reader kafkaReader 70 + store OspreyEventStore 71 + metrics ConsumerMetrics 63 72 } 64 73 65 74 // NewOspreyEventConsumer constructs a consumer reading from the given 66 75 // broker. The caller is expected to run Run(ctx) in a goroutine and 67 76 // Close on shutdown. 68 - func NewOspreyEventConsumer(broker string, store OspreyEventStore) *OspreyEventConsumer { 77 + func NewOspreyEventConsumer(broker string, store OspreyEventStore, opts ...func(*OspreyEventConsumer)) *OspreyEventConsumer { 69 78 r := kafka.NewReader(kafka.ReaderConfig{ 70 79 Brokers: []string{broker}, 71 80 GroupID: ospreyConsumerGroupID, ··· 75 84 MaxWait: 500 * time.Millisecond, 76 85 StartOffset: kafka.FirstOffset, // only used when no committed offset exists 77 86 }) 78 - return &OspreyEventConsumer{reader: r, store: store} 87 + c := &OspreyEventConsumer{reader: r, store: store} 88 + for _, o := range opts { 89 + o(c) 90 + } 91 + return c 92 + } 93 + 94 + // WithConsumerMetrics sets the metrics recorder for the event consumer. 95 + func WithConsumerMetrics(metrics ConsumerMetrics) func(*OspreyEventConsumer) { 96 + return func(c *OspreyEventConsumer) { c.metrics = metrics } 79 97 } 80 98 81 99 // newOspreyEventConsumerWithReader is the test constructor. 82 - func newOspreyEventConsumerWithReader(r kafkaReader, store OspreyEventStore) *OspreyEventConsumer { 83 - return &OspreyEventConsumer{reader: r, store: store} 100 + func newOspreyEventConsumerWithReader(r kafkaReader, store OspreyEventStore, opts ...func(*OspreyEventConsumer)) *OspreyEventConsumer { 101 + c := &OspreyEventConsumer{reader: r, store: store} 102 + for _, o := range opts { 103 + o(c) 104 + } 105 + return c 84 106 } 85 107 86 108 // Run polls Kafka until ctx is cancelled. Decoding or DB errors are ··· 134 156 // until the NEXT successful ReadMessage auto-commit, 135 157 // so we naturally re-read the message. 136 158 continue 159 + } 160 + 161 + if c.metrics != nil { 162 + c.metrics.RecordEventConsumed(msg.Offset) 137 163 } 138 164 139 165 log.Printf("relay_events.consumer.insert: offset=%d action=%s did=%s",
+62
internal/relay/events_consumer_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import ( 4 6 "context" 5 7 "errors" 6 8 "io" 9 + "math" 7 10 "testing" 8 11 "time" 9 12 10 13 "atmosphere-mail/internal/relaystore" 11 14 15 + "github.com/prometheus/client_golang/prometheus" 16 + "github.com/prometheus/client_golang/prometheus/testutil" 12 17 "github.com/segmentio/kafka-go" 13 18 ) 14 19 ··· 312 317 } 313 318 314 319 func (r *errThenEOFReader) Close() error { return nil } 320 + 321 + func TestConsumerMetricsUpdatedOnIngest(t *testing.T) { 322 + store := newMemStore(t) 323 + metrics := NewMetrics(prometheus.NewRegistry()) 324 + 325 + reader := &fakeReader{messages: []kafka.Message{ 326 + {Value: []byte(sampleRelayAttempt), Offset: 42}, 327 + {Value: []byte(sampleDeliveryResult), Offset: 99}, 328 + }} 329 + 330 + before := float64(time.Now().Unix()) 331 + 332 + c := newOspreyEventConsumerWithReader(reader, store, WithConsumerMetrics(metrics)) 333 + if err := c.Run(context.Background()); err != nil { 334 + t.Fatalf("Run: %v", err) 335 + } 336 + 337 + after := float64(time.Now().Unix()) 338 + 339 + // The offset gauge should reflect the last consumed message (offset 99). 340 + gotOffset := testutil.ToFloat64(metrics.EventsConsumerOffset) 341 + if gotOffset != 99 { 342 + t.Errorf("EventsConsumerOffset = %v, want 99", gotOffset) 343 + } 344 + 345 + // The timestamp gauge should be approximately now (within the test window). 346 + gotTS := testutil.ToFloat64(metrics.EventsConsumerLastIngestTimestamp) 347 + if gotTS < before || gotTS > after+1 { 348 + t.Errorf("EventsConsumerLastIngestTimestamp = %v, want between %v and %v", gotTS, before, after+1) 349 + } 350 + } 351 + 352 + func TestConsumerMetricsNotUpdatedOnDecodeError(t *testing.T) { 353 + store := newMemStore(t) 354 + metrics := NewMetrics(prometheus.NewRegistry()) 355 + 356 + reader := &fakeReader{messages: []kafka.Message{ 357 + {Value: []byte(`not json`), Offset: 5}, 358 + }} 359 + 360 + c := newOspreyEventConsumerWithReader(reader, store, WithConsumerMetrics(metrics)) 361 + if err := c.Run(context.Background()); err != nil { 362 + t.Fatalf("Run: %v", err) 363 + } 364 + 365 + // No successful ingest happened, so offset should remain at zero. 366 + gotOffset := testutil.ToFloat64(metrics.EventsConsumerOffset) 367 + if gotOffset != 0 { 368 + t.Errorf("EventsConsumerOffset = %v, want 0 (no successful ingest)", gotOffset) 369 + } 370 + 371 + // Timestamp should also remain at zero. 372 + gotTS := testutil.ToFloat64(metrics.EventsConsumerLastIngestTimestamp) 373 + if math.Abs(gotTS) > 0.001 { 374 + t.Errorf("EventsConsumerLastIngestTimestamp = %v, want 0 (no successful ingest)", gotTS) 375 + } 376 + }
+2
internal/relay/fingerprint.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 // ContentFingerprint hashes a normalized view of (subject, body) so the
+2
internal/relay/fingerprint_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/forwarder.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — inbound reply forwarding delivery path. 2 4 // 3 5 // When the inbound SMTP server classifies a message as a reply (not a
+2
internal/relay/forwarder_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/inbound.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/inbound_fbl_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/inbound_log_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/inbound_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/labelcheck.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/labelcheck_evict_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/labelcheck_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+51
internal/relay/metrics.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import ( ··· 46 48 // OAuth callback results 47 49 OAuthCallbacks *prometheus.CounterVec // type: enroll_auth, recovery, attestation, error 48 50 51 + // System-mail (opmail) delivery 52 + OpMailSent *prometheus.CounterVec // kind: operator_ping, member_welcome, key_regenerated, fbl_complaint, email_verification 53 + OpMailFailed *prometheus.CounterVec // kind 54 + 49 55 // Inbound mail classification + forwarding (Phase 1b) 50 56 InboundMessages *prometheus.CounterVec // classification: verp_bounce, srs_bounce, reply, postmaster 51 57 RepliesForwarded *prometheus.CounterVec // status: sent, failed 58 + 59 + // Osprey events consumer health 60 + EventsConsumerLastIngestTimestamp prometheus.Gauge // Unix timestamp of last successful consume 61 + EventsConsumerOffset prometheus.Gauge // Last consumed Kafka offset 52 62 } 53 63 54 64 // NewMetrics creates and registers all relay metrics with the given registerer. ··· 127 137 Name: "atmosphere_relay_complaints_total", 128 138 Help: "Total FBL/ARF complaints received.", 129 139 }, []string{"feedback_type", "provider"}), 140 + OpMailSent: prometheus.NewCounterVec(prometheus.CounterOpts{ 141 + Name: "atmosphere_relay_opmail_sent_total", 142 + Help: "System-mail messages successfully delivered, by kind.", 143 + }, []string{"kind"}), 144 + OpMailFailed: prometheus.NewCounterVec(prometheus.CounterOpts{ 145 + Name: "atmosphere_relay_opmail_failed_total", 146 + Help: "System-mail messages that failed delivery, by kind.", 147 + }, []string{"kind"}), 130 148 InboundMessages: prometheus.NewCounterVec(prometheus.CounterOpts{ 131 149 Name: "atmosphere_relay_inbound_messages_total", 132 150 Help: "Inbound messages received on :25, by classification.", ··· 135 153 Name: "atmosphere_relay_replies_forwarded_total", 136 154 Help: "Outcome of reply-forwarding attempts, by status.", 137 155 }, []string{"status"}), 156 + EventsConsumerLastIngestTimestamp: prometheus.NewGauge(prometheus.GaugeOpts{ 157 + Name: "atmosphere_relay_events_consumer_last_ingest_timestamp_seconds", 158 + Help: "Unix timestamp of the last successfully consumed Osprey event.", 159 + }), 160 + EventsConsumerOffset: prometheus.NewGauge(prometheus.GaugeOpts{ 161 + Name: "atmosphere_relay_events_consumer_offset", 162 + Help: "Kafka offset of the last successfully consumed Osprey event.", 163 + }), 138 164 OAuthCallbacks: prometheus.NewCounterVec(prometheus.CounterOpts{ 139 165 Name: "atmos_enroll_oauth_callbacks_total", 140 166 Help: "OAuth callback completions, by result type and handle.", ··· 163 189 m.HTTPRequestDuration, 164 190 m.EnrollFunnel, 165 191 m.OAuthCallbacks, 192 + m.OpMailSent, 193 + m.OpMailFailed, 194 + m.EventsConsumerLastIngestTimestamp, 195 + m.EventsConsumerOffset, 166 196 ) 167 197 168 198 // Initialize label values so they appear in /metrics output even at zero ··· 214 244 for _, step := range []string{"marketing", "landing", "auth_start", "enroll_start", "enroll_verify", "enroll_success", "attest_start", "attest_callback"} { 215 245 m.EnrollFunnel.WithLabelValues(step) 216 246 } 247 + for _, kind := range []string{"operator_ping", "member_welcome", "key_regenerated", "fbl_complaint", "email_verification"} { 248 + m.OpMailSent.WithLabelValues(kind) 249 + m.OpMailFailed.WithLabelValues(kind) 250 + } 217 251 // OAuthCallbacks: handle label is dynamic, skip pre-initialization. 218 252 219 253 return m ··· 227 261 // RecordOAuthCallback increments the OAuth callback counter for the given type and handle. 228 262 func (m *Metrics) RecordOAuthCallback(callbackType, handle string) { 229 263 m.OAuthCallbacks.WithLabelValues(callbackType, handle).Inc() 264 + } 265 + 266 + // RecordOpMailSent increments the system-mail sent counter for the given kind. 267 + func (m *Metrics) RecordOpMailSent(kind string) { 268 + m.OpMailSent.WithLabelValues(kind).Inc() 269 + } 270 + 271 + // RecordOpMailFailed increments the system-mail failure counter for the given kind. 272 + func (m *Metrics) RecordOpMailFailed(kind string) { 273 + m.OpMailFailed.WithLabelValues(kind).Inc() 274 + } 275 + 276 + // RecordEventConsumed updates the consumer-lag gauges after a successful 277 + // Osprey event ingest. Called from OspreyEventConsumer.Run on each insert. 278 + func (m *Metrics) RecordEventConsumed(offset int64) { 279 + m.EventsConsumerLastIngestTimestamp.SetToCurrentTime() 280 + m.EventsConsumerOffset.Set(float64(offset)) 230 281 } 231 282 232 283 // RecordInbound implements relay.InboundMetrics.
+2
internal/relay/metrics_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/operator_dkim.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/operator_dkim_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+29 -5
internal/relay/opmail.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — system-mail helper. 2 4 // 3 5 // opmail is the relay's own outbound transactional path for mail it sends ··· 123 125 VerifyURL string 124 126 } 125 127 128 + // OpMailMetrics is the subset of Metrics that opmail needs. 129 + type OpMailMetrics interface { 130 + RecordOpMailSent(kind string) 131 + RecordOpMailFailed(kind string) 132 + } 133 + 126 134 // OpMailer renders and dispatches system mail. Built once at startup and 127 135 // shared across call sites (admin API enroll path, UI approve action, 128 136 // regenerate-key endpoint). Safe for concurrent use — the signer and 129 137 // sendFunc are the only fields, and both are assumed reentrant. 130 138 type OpMailer struct { 131 - ctx OpMailContext 132 - signer *DKIMSigner 133 - send OpMailSendFunc 139 + ctx OpMailContext 140 + signer *DKIMSigner 141 + send OpMailSendFunc 142 + metrics OpMailMetrics 134 143 } 135 144 136 145 // NewOpMailer constructs a mailer. Pass a nil signer to disable DKIM for ··· 139 148 // 140 149 // unsubscribeAddr is included for forward-compatibility but currently 141 150 // unused — the List-Unsubscribe value is derived from ctx.RelayDomain. 142 - func NewOpMailer(ctx OpMailContext, signer *DKIMSigner, send OpMailSendFunc) *OpMailer { 143 - return &OpMailer{ctx: ctx, signer: signer, send: send} 151 + func NewOpMailer(ctx OpMailContext, signer *DKIMSigner, send OpMailSendFunc, opts ...func(*OpMailer)) *OpMailer { 152 + m := &OpMailer{ctx: ctx, signer: signer, send: send} 153 + for _, o := range opts { 154 + o(m) 155 + } 156 + return m 157 + } 158 + 159 + // WithOpMailMetrics sets the metrics recorder for system-mail sends. 160 + func WithOpMailMetrics(metrics OpMailMetrics) func(*OpMailer) { 161 + return func(m *OpMailer) { m.metrics = metrics } 144 162 } 145 163 146 164 // SendOperatorPing notifies the operator that `data` represents a new ··· 263 281 264 282 envelope := OpMailEnvelope{From: fromAddr, To: to} 265 283 if err := m.send(ctx, envelope, signed); err != nil { 284 + if m.metrics != nil { 285 + m.metrics.RecordOpMailFailed(string(kind)) 286 + } 266 287 return "", fmt.Errorf("opmail send: %w", err) 288 + } 289 + if m.metrics != nil { 290 + m.metrics.RecordOpMailSent(string(kind)) 267 291 } 268 292 log.Printf("opmail.sent: kind=%s to=%s message_id=%s", kind, to, msgID) 269 293 return msgID, nil
+47
internal/relay/opmail_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import ( ··· 5 7 "errors" 6 8 "strings" 7 9 "testing" 10 + 11 + "github.com/prometheus/client_golang/prometheus" 12 + "github.com/prometheus/client_golang/prometheus/testutil" 8 13 ) 9 14 10 15 // captureOpMail is a test sink that records the last send attempt so ··· 226 231 t.Fatal("expected error when relay domain is empty") 227 232 } 228 233 } 234 + 235 + func TestOpMailer_MetricsRecordedOnSend(t *testing.T) { 236 + metrics := NewMetrics(prometheus.NewRegistry()) 237 + cap := &captureOpMail{} 238 + ctx := OpMailContext{RelayDomain: "atmos.email"} 239 + m := NewOpMailer(ctx, nil, cap.send, WithOpMailMetrics(metrics)) 240 + 241 + _, err := m.SendMemberWelcome(ctx, "alice@example.com", MemberWelcomeData{Domain: "example.com"}) 242 + if err != nil { 243 + t.Fatalf("unexpected error: %v", err) 244 + } 245 + 246 + val := testutil.ToFloat64(metrics.OpMailSent.WithLabelValues("member_welcome")) 247 + if val != 1 { 248 + t.Errorf("OpMailSent(member_welcome) = %v, want 1", val) 249 + } 250 + val = testutil.ToFloat64(metrics.OpMailFailed.WithLabelValues("member_welcome")) 251 + if val != 0 { 252 + t.Errorf("OpMailFailed(member_welcome) = %v, want 0", val) 253 + } 254 + } 255 + 256 + func TestOpMailer_MetricsRecordedOnFailure(t *testing.T) { 257 + metrics := NewMetrics(prometheus.NewRegistry()) 258 + cap := &captureOpMail{returnErr: errors.New("mx down")} 259 + ctx := OpMailContext{RelayDomain: "atmos.email"} 260 + m := NewOpMailer(ctx, nil, cap.send, WithOpMailMetrics(metrics)) 261 + 262 + _, err := m.SendMemberWelcome(ctx, "alice@example.com", MemberWelcomeData{Domain: "example.com"}) 263 + if err == nil { 264 + t.Fatal("expected error") 265 + } 266 + 267 + val := testutil.ToFloat64(metrics.OpMailFailed.WithLabelValues("member_welcome")) 268 + if val != 1 { 269 + t.Errorf("OpMailFailed(member_welcome) = %v, want 1", val) 270 + } 271 + val = testutil.ToFloat64(metrics.OpMailSent.WithLabelValues("member_welcome")) 272 + if val != 0 { 273 + t.Errorf("OpMailSent(member_welcome) = %v, want 0", val) 274 + } 275 + }
+2
internal/relay/ospreyenforce.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/ospreyenforce_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/publicrouter.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — public HTTPS host-based router. 2 4 // 3 5 // The public listener answers for multiple hostnames with different roles:
+2
internal/relay/publicrouter_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/queue.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/queue_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/ratelimit.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/ratelimit_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/smtp.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/smtp_domain_fallback_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 // Regression test pinning the SMTP domain-fallback auth scope from
+2
internal/relay/smtp_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/spf.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/spf_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/spool.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/spool_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/srs.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — Sender Rewriting Scheme (SRS) for inbound mail forwarding. 2 4 // 3 5 // When the relay forwards mail (e.g. a reply from Gmail back to a member's
+2
internal/relay/srs_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/unsubscribe.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 // Package relay — List-Unsubscribe one-click support. 2 4 // 3 5 // Gmail's February 2024 bulk sender rules require List-Unsubscribe +
+2
internal/relay/unsubscribe_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/warming.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import "time"
+2
internal/relay/warming_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relay/warmup.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relay 2 4 3 5 import (
+2
internal/relaystore/inbound_messages.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 // Persistent log of inbound SMTP traffic (port 25). Every message the
+2
internal/relaystore/inbound_messages_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import (
+2
internal/relaystore/oauth_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import (
+2
internal/relaystore/pending_notifications.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import (
+2
internal/relaystore/pending_notifications_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import (
+2
internal/relaystore/relay_events.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 // Relay-local event store. Replaces Druid for Osprey rule-evaluation events.
+2
internal/relaystore/relay_events_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import (
+55 -15
internal/relaystore/store.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import ( ··· 125 127 } 126 128 127 129 type Message struct { 128 - ID int64 129 - MemberDID string 130 - FromAddr string 131 - ToAddr string 132 - MessageID string 133 - Status string 134 - SMTPCode int 135 - CreatedAt time.Time 136 - DeliveredAt time.Time 130 + ID int64 131 + MemberDID string 132 + FromAddr string 133 + ToAddr string 134 + MessageID string 135 + Status string 136 + SMTPCode int 137 + CreatedAt time.Time 138 + DeliveredAt time.Time 139 + ContentFingerprint string 137 140 } 138 141 139 142 type Stats struct { ··· 468 471 } 469 472 } 470 473 474 + // Add content_fingerprint column to messages if missing. Stores a 475 + // hex-sha256 of the normalized (subject, body) pair so we can count 476 + // how many unique recipients received the same content from a sender 477 + // in a time window — the core signal for bulk/newsletter detection. 478 + var hasFP int 479 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name = 'content_fingerprint'`).Scan(&hasFP) 480 + if hasFP == 0 { 481 + if _, err := s.db.Exec(`ALTER TABLE messages ADD COLUMN content_fingerprint TEXT NOT NULL DEFAULT ''`); err != nil { 482 + return fmt.Errorf("add content_fingerprint column: %v", err) 483 + } 484 + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_messages_fingerprint ON messages(member_did, content_fingerprint, created_at)`); err != nil { 485 + return fmt.Errorf("create fingerprint index: %v", err) 486 + } 487 + } 488 + 471 489 // Relay-local events store (replaces Druid for Osprey rule-evaluation 472 490 // events). Split into its own method so the migration lives next to 473 491 // the RelayEvent model. ··· 1030 1048 1031 1049 func (s *Store) InsertMessage(ctx context.Context, m *Message) (int64, error) { 1032 1050 res, err := s.db.ExecContext(ctx, 1033 - `INSERT INTO messages (member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at) 1034 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 1051 + `INSERT INTO messages (member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at, content_fingerprint) 1052 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1035 1053 m.MemberDID, m.FromAddr, m.ToAddr, m.MessageID, m.Status, m.SMTPCode, 1036 - formatTime(m.CreatedAt), formatTime(m.DeliveredAt), 1054 + formatTime(m.CreatedAt), formatTime(m.DeliveredAt), m.ContentFingerprint, 1037 1055 ) 1038 1056 if err != nil { 1039 1057 return 0, fmt.Errorf("insert message: %v", err) ··· 1043 1061 1044 1062 func (s *Store) GetMessage(ctx context.Context, id int64) (*Message, error) { 1045 1063 row := s.db.QueryRowContext(ctx, 1046 - `SELECT id, member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at 1064 + `SELECT id, member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at, content_fingerprint 1047 1065 FROM messages WHERE id = ?`, id, 1048 1066 ) 1049 1067 return scanMessage(row) ··· 1051 1069 1052 1070 func (s *Store) GetMessageByMessageID(ctx context.Context, messageID string) (*Message, error) { 1053 1071 row := s.db.QueryRowContext(ctx, 1054 - `SELECT id, member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at 1072 + `SELECT id, member_did, from_addr, to_addr, message_id, status, smtp_code, created_at, delivered_at, content_fingerprint 1055 1073 FROM messages WHERE message_id = ?`, messageID, 1056 1074 ) 1057 1075 return scanMessage(row) ··· 1075 1093 1076 1094 err := sc.Scan( 1077 1095 &m.ID, &m.MemberDID, &m.FromAddr, &m.ToAddr, &m.MessageID, 1078 - &m.Status, &m.SMTPCode, &createdAt, &deliveredAt, 1096 + &m.Status, &m.SMTPCode, &createdAt, &deliveredAt, &m.ContentFingerprint, 1079 1097 ) 1080 1098 if err == sql.ErrNoRows { 1081 1099 return nil, nil ··· 1249 1267 ).Scan(&n) 1250 1268 if err != nil { 1251 1269 return 0, fmt.Errorf("count sends since: %v", err) 1270 + } 1271 + return n, nil 1272 + } 1273 + 1274 + // GetSameContentRecipientsSince counts DISTINCT recipients that received 1275 + // a message with the given content fingerprint from a sender since the 1276 + // given time. Used by the content-spray detection rule — legitimate 1277 + // transactional mail has unique bodies (tokens, links) so fingerprints 1278 + // differ per recipient; bulk/newsletter mail sends the same body to many. 1279 + func (s *Store) GetSameContentRecipientsSince(ctx context.Context, memberDID, fingerprint string, since time.Time) (int, error) { 1280 + if fingerprint == "" { 1281 + return 0, nil 1282 + } 1283 + var n int 1284 + err := s.db.QueryRowContext(ctx, 1285 + `SELECT COUNT(DISTINCT LOWER(to_addr)) 1286 + FROM messages 1287 + WHERE member_did = ? AND content_fingerprint = ? AND created_at >= ?`, 1288 + memberDID, fingerprint, formatTime(since), 1289 + ).Scan(&n) 1290 + if err != nil { 1291 + return 0, fmt.Errorf("count same-content recipients: %v", err) 1252 1292 } 1253 1293 return n, nil 1254 1294 }
+126
internal/relaystore/store_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package relaystore 2 4 3 5 import ( ··· 1179 1181 } 1180 1182 if b != 7 { 1181 1183 t.Errorf("b got %d, want 7", b) 1184 + } 1185 + } 1186 + 1187 + // --- Content spray detection --- 1188 + 1189 + func TestGetSameContentRecipients_CountsDistinct(t *testing.T) { 1190 + s := testStore(t) 1191 + ctx := context.Background() 1192 + did := "did:plc:contentspray" 1193 + insertTestMemberWithDomain(t, s, did, "spray.example.com") 1194 + 1195 + now := time.Now().UTC() 1196 + fp := "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678" 1197 + recipients := []string{"a@gmail.com", "b@gmail.com", "c@yahoo.com", "d@proton.me"} 1198 + for _, r := range recipients { 1199 + _, err := s.InsertMessage(ctx, &Message{ 1200 + MemberDID: did, FromAddr: "x@spray.example.com", ToAddr: r, 1201 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now, 1202 + ContentFingerprint: fp, 1203 + }) 1204 + if err != nil { 1205 + t.Fatalf("insert: %v", err) 1206 + } 1207 + } 1208 + 1209 + n, err := s.GetSameContentRecipientsSince(ctx, did, fp, now.Add(-time.Minute)) 1210 + if err != nil { 1211 + t.Fatalf("count: %v", err) 1212 + } 1213 + if n != 4 { 1214 + t.Errorf("expected 4 unique recipients, got %d", n) 1215 + } 1216 + } 1217 + 1218 + func TestGetSameContentRecipients_DifferentFingerprintIsolated(t *testing.T) { 1219 + s := testStore(t) 1220 + ctx := context.Background() 1221 + did := "did:plc:fpiso" 1222 + insertTestMemberWithDomain(t, s, did, "iso.example.com") 1223 + 1224 + now := time.Now().UTC() 1225 + fp1 := "aaaa" + "0000000000000000000000000000000000000000000000000000000000000000"[:60] 1226 + fp2 := "bbbb" + "0000000000000000000000000000000000000000000000000000000000000000"[:60] 1227 + for _, r := range []string{"a@x.com", "b@x.com", "c@x.com"} { 1228 + _, _ = s.InsertMessage(ctx, &Message{ 1229 + MemberDID: did, FromAddr: "x@iso.example.com", ToAddr: r, 1230 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now, 1231 + ContentFingerprint: fp1, 1232 + }) 1233 + } 1234 + _, _ = s.InsertMessage(ctx, &Message{ 1235 + MemberDID: did, FromAddr: "x@iso.example.com", ToAddr: "d@x.com", 1236 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now, 1237 + ContentFingerprint: fp2, 1238 + }) 1239 + 1240 + n, _ := s.GetSameContentRecipientsSince(ctx, did, fp1, now.Add(-time.Minute)) 1241 + if n != 3 { 1242 + t.Errorf("fp1: expected 3, got %d", n) 1243 + } 1244 + n, _ = s.GetSameContentRecipientsSince(ctx, did, fp2, now.Add(-time.Minute)) 1245 + if n != 1 { 1246 + t.Errorf("fp2: expected 1, got %d", n) 1247 + } 1248 + } 1249 + 1250 + func TestGetSameContentRecipients_WindowRespected(t *testing.T) { 1251 + s := testStore(t) 1252 + ctx := context.Background() 1253 + did := "did:plc:fpwin" 1254 + insertTestMemberWithDomain(t, s, did, "win.example.com") 1255 + 1256 + now := time.Now().UTC() 1257 + fp := "cccc" + "0000000000000000000000000000000000000000000000000000000000000000"[:60] 1258 + // Old message (2 hours ago) — outside window 1259 + _, _ = s.InsertMessage(ctx, &Message{ 1260 + MemberDID: did, FromAddr: "x@win.example.com", ToAddr: "old@x.com", 1261 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now.Add(-2 * time.Hour), 1262 + ContentFingerprint: fp, 1263 + }) 1264 + // Recent message — inside window 1265 + _, _ = s.InsertMessage(ctx, &Message{ 1266 + MemberDID: did, FromAddr: "x@win.example.com", ToAddr: "new@x.com", 1267 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now, 1268 + ContentFingerprint: fp, 1269 + }) 1270 + 1271 + n, _ := s.GetSameContentRecipientsSince(ctx, did, fp, now.Add(-time.Hour)) 1272 + if n != 1 { 1273 + t.Errorf("expected 1 (only recent), got %d", n) 1274 + } 1275 + } 1276 + 1277 + func TestGetSameContentRecipients_EmptyFingerprintReturnsZero(t *testing.T) { 1278 + s := testStore(t) 1279 + ctx := context.Background() 1280 + n, err := s.GetSameContentRecipientsSince(ctx, "did:plc:any", "", time.Now().Add(-time.Hour)) 1281 + if err != nil { 1282 + t.Fatalf("unexpected error: %v", err) 1283 + } 1284 + if n != 0 { 1285 + t.Errorf("empty fingerprint should return 0, got %d", n) 1286 + } 1287 + } 1288 + 1289 + func TestGetSameContentRecipients_CaseInsensitiveRecipients(t *testing.T) { 1290 + s := testStore(t) 1291 + ctx := context.Background() 1292 + did := "did:plc:fpcase" 1293 + insertTestMemberWithDomain(t, s, did, "case.example.com") 1294 + 1295 + now := time.Now().UTC() 1296 + fp := "dddd" + "0000000000000000000000000000000000000000000000000000000000000000"[:60] 1297 + for _, r := range []string{"Alice@Gmail.COM", "alice@gmail.com", "ALICE@GMAIL.COM"} { 1298 + _, _ = s.InsertMessage(ctx, &Message{ 1299 + MemberDID: did, FromAddr: "x@case.example.com", ToAddr: r, 1300 + MessageID: "<m>", Status: MsgQueued, CreatedAt: now, 1301 + ContentFingerprint: fp, 1302 + }) 1303 + } 1304 + 1305 + n, _ := s.GetSameContentRecipientsSince(ctx, did, fp, now.Add(-time.Minute)) 1306 + if n != 1 { 1307 + t.Errorf("case-insensitive: expected 1 distinct recipient, got %d", n) 1182 1308 } 1183 1309 } 1184 1310
+2
internal/scheduler/reverify.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package scheduler 2 4 3 5 import (
+2
internal/scheduler/reverify_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package scheduler 2 4 3 5 import (
+2
internal/server/diagnostics.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package server 2 4 3 5 import (
+2
internal/server/query.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package server 2 4 3 5 import (
+2
internal/server/server.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package server 2 4 3 5 import (
+2
internal/server/server_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package server 2 4 3 5 import (
+2
internal/server/subscribe.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package server 2 4 3 5 import (
+2
internal/store/sqlite.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package store 2 4 3 5 import (
+2
internal/store/sqlite_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 1 3 package store 2 4 3 5 import (
+11
osprey/config/labels.yaml
··· 148 148 valid_for: [RecipientDomain] 149 149 connotation: neutral 150 150 description: "Destination domain reported a complaint in the last 7 days" 151 + 152 + # Content spray shadow labels (observe-only, no enforcement yet) 153 + shadow:content_spray: 154 + valid_for: [SenderDID] 155 + connotation: negative 156 + description: "Shadow: same message body sent to 15+ unique recipients in last hour — possible bulk/newsletter" 157 + 158 + shadow:content_spray_extreme: 159 + valid_for: [SenderDID] 160 + connotation: negative 161 + description: "Shadow: same message body sent to 50+ unique recipients in last hour — bulk mail"
+1
osprey/rules/main.sml
··· 23 23 Require(rule='rules/velocity.sml') 24 24 Require(rule='rules/velocity_subhour.sml') 25 25 Require(rule='rules/domain_spray.sml') 26 + Require(rule='rules/content_spray.sml') 26 27 Require(rule='rules/bulk_extreme.sml') 27 28 Require(rule='rules/gmail_gatekeeper.sml') 28 29 Require(rule='rules/reputation_lifecycle.sml')
+1
osprey/rules/models/relay.sml
··· 26 26 SendsLastHour: Entity[int] = EntityJson(type='SendsLastHour', path='$.sends_last_hour', coerce_type=True, required=False) 27 27 HardBouncesLast24h: Entity[int] = EntityJson(type='HardBouncesLast24h', path='$.hard_bounces_last_24h', coerce_type=True, required=False) 28 28 UniqueRecipientDomainsLastHour: Entity[int] = EntityJson(type='UniqueRecipientDomainsLastHour', path='$.unique_recipient_domains_last_hour', coerce_type=True, required=False) 29 + SameContentRecipientsLastHour: Entity[int] = EntityJson(type='SameContentRecipientsLastHour', path='$.same_content_recipients_last_hour', coerce_type=True, required=False) 29 30 30 31 # FBL / ARF complaint fields. Only populated for event_type=complaint_received. 31 32 # FeedbackType is the ARF category ("abuse", "fraud", "not-spam", "virus").
+56
osprey/rules/rules/content_spray.sml
··· 1 + # Content spray detection (bulk/newsletter enforcement) 2 + # 3 + # Transactional email has unique bodies per recipient: password resets 4 + # contain tokens, verification emails contain codes, account notifications 5 + # include user-specific details. The content fingerprint (sha256 of 6 + # normalized subject+body) naturally differs for each recipient. 7 + # 8 + # Newsletter/marketing/bulk mail sends the SAME body to many recipients. 9 + # same_content_recipients_last_hour counts distinct recipients who got 10 + # the same fingerprint from this sender in the last hour. 11 + # 12 + # Shadow mode first: labels are prefixed with shadow: so they're logged 13 + # but don't affect send behavior. Promote to real labels after bake-in 14 + # confirms zero false positives on production traffic. 15 + # 16 + # Privacy: the relay stores only the sha256 hash, never email addresses 17 + # or body content. The counter is a scalar — Osprey sees only the number. 18 + 19 + Import(rules=['models/relay.sml']) 20 + 21 + # Moderate content spray: same body to 15+ unique recipients in an hour. 22 + # Legitimate transactional senders won't hit this because each message 23 + # body contains recipient-specific tokens. 24 + ContentSpray = Rule( 25 + when_all=[ 26 + EventType == 'relay_attempt', 27 + SameContentRecipientsLastHour != None, 28 + SameContentRecipientsLastHour >= 15, 29 + ], 30 + description='Same message body sent to 15+ unique recipients in last hour — possible bulk/newsletter' 31 + ) 32 + 33 + WhenRules( 34 + rules_any=[ContentSpray], 35 + then=[ 36 + LabelAdd(entity=SenderDID, label='shadow:content_spray', expires_after=TimeDelta(hours=12)), 37 + ], 38 + ) 39 + 40 + # Extreme content spray: same body to 50+ unique recipients in an hour. 41 + # No legitimate transactional use case produces this pattern. 42 + ExtremeContentSpray = Rule( 43 + when_all=[ 44 + EventType == 'relay_attempt', 45 + SameContentRecipientsLastHour != None, 46 + SameContentRecipientsLastHour >= 50, 47 + ], 48 + description='Same message body sent to 50+ unique recipients in last hour — bulk mail' 49 + ) 50 + 51 + WhenRules( 52 + rules_any=[ExtremeContentSpray], 53 + then=[ 54 + LabelAdd(entity=SenderDID, label='shadow:content_spray_extreme', expires_after=TimeDelta(days=1)), 55 + ], 56 + )