···11+# Changelog
22+33+All notable changes to this project will be documented in this file.
44+55+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66+77+## [Unreleased]
88+99+### Changed
1010+- License changed from MIT to AGPL-3.0-or-later
1111+- Add SPDX-License-Identifier headers to all Go source files
1212+1313+### Security
1414+- Add DID validation to admin handleMember endpoint (#16)
1515+- Narrow OAuth scope from transition:generic to repo:email.atmos.attestation (#189)
1616+- sec(account): SameSite=Strict blocks cookie after OAuth cross-site redirect — switch to Lax (#180)
1717+- sec(account): Referrer-Policy breaks form POST CSRF — switch to strict-origin-when-cross-origin (#178)
1818+- sec(smtp): protocol-level review — smuggling, CRLF injection, boundary handling (#171)
1919+- sec(ci): show unreachable vulns verbosely in weekly govulncheck cron (#172)
2020+- sec(ci): add govulncheck dependency scan workflow (#170)
2121+- sec(startup): call ValidateWebhookURL + redact URL in notify.enabled log (#169)
2222+- sec(rotation): durable notification queue for API-key rotation emails (#158)
2323+- sec(account): validate domain+contact_email before log/store (#156)
2424+- sec(account): cap ticket map size + replace ad-hoc prune with single ticker (#157)
2525+- sec(account): drop query-string ticket fallback on /account/regenerate (#159)
2626+- sec(account): redact OAuth start errors in /account/start (match attest.go) (#162)
2727+- sec(oauth): downgrade DID-mismatch log to hash or debug level (#165)
2828+- sec(regress): pin SMTP domain-fallback + DID-mismatch behavior with tests (#166)
2929+- sec(ci): lint for html.EscapeString on template fmt.Fprintf sites (#167)
3030+- sec(webhook): reject non-https operatorWebhookURL unless loopback-dev (#164)
3131+- sec(webhook): ship notify.VerifySignature helper for receivers (#155)
3232+- sec(webhook): HMAC replay protection via signed timestamp header (#154)
3333+- sec(webhook): reject non-https operatorWebhookURL unless loopback-dev (#164)
3434+- sec(webhook): ship notify.VerifySignature helper for receivers (#155)
3535+- sec(webhook): HMAC replay protection via signed timestamp header (#154)
3636+- sec(admin): rate-limit /admin/enroll-start by IP (#163)
3737+- sec(admin): verify fireKeyRegenerated email body does not carry plaintext key (#161)
3838+- sec(admin): timing-equalize bcrypt on /admin/self-status not-found path (#160)
3939+- sec(logs): redact OAuth state + recovery ticket IDs in logs (#153)
4040+- sec(account): migrate recovery ticket to HttpOnly cookie + Referrer-Policy (#152)
4141+- sec(ui): CSRF protection on /ui admin + /account member UIs (#151)
4242+- Manual approval gate for new enrollments (#96)
4343+- Add DID validation to admin handleMember endpoint (#16)
4444+- Fix API key in query string → Authorization header (#70)
4545+- validDID regex divergence between admin and labeler (#35)
4646+- SMTP AUTH logs unsanitized username — log injection (#34)
4747+- Unbounded did:web length in DID validation (#30)
4848+- Suspend reason log injection via query param (#29)
4949+- did:web DID allows newline via percent-encoding — log injection risk (#24)
5050+- Spool file not removed on queue-full rejection → duplicate delivery (#20)
5151+- Add DID validation to admin handleMember endpoint (#16)
5252+- Add UNIQUE constraint on members.domain column (#15)
5353+- Fix From-header parser multi-address bypass — use mail.ParseAddress (#14)
5454+- Fix spool write outside queue lock — crash can lose accepted messages (#12)
5555+- Fix rate limit TOCTOU between concurrent SMTP sessions (#11)
5656+- Fix From-header validation to prevent phishing via forged headers (#6)
5757+- Fix multi-recipient rate limit bypass (#5)
5858+5959+### Added
6060+- Add Prometheus metrics for opmail system-mail sends (#195)
6161+- Persist label bypass list across restarts (#17)
6262+- feat: admin warmup batch send button (#188)
6363+- UX: seamless manage↔enroll navigation + consistent @ prefix on handle inputs (#187)
6464+- Add sign-out + consistent site nav (masthead → /) (#149)
6565+- Marketing landing page at / + focused /enroll + fix missing ContactEmail on /account/manage (#148)
6666+- Compact landing + rename /recover to /account + match chrome + contact_email edit (#147)
6767+- Include retry-after hint in 451 rate-limit SMTP rejection message (#140)
6868+- Admin UI button for API-key regeneration (wraps /admin/member/{did}/regenerate-key) (#142)
6969+- Add operator notifications (pending members, suspensions, FBL complaints, incidents) (#135)
7070+- Register Yahoo CFL + any future provider FBL programs once volume warrants (#134)
7171+- Enroll wizard: labeler-lag polling on success page until label lands (#139)
7272+- Approval notification email to new member when operator approves (#137)
7373+- Self-service member credential recovery (API key + DKIM via OAuth re-auth) (#136)
7474+- Human review queue in Osprey UI for auto-suspension overrides (#94)
7575+- Shadow-mode rule deployment (observe vs. enforce) (#93)
7676+- Content fingerprinting: relay emits body/subject hash for cross-sender correlation (#90)
7777+- Phase 2: atproto OAuth to verify DID ownership on enrollments (#107)
7878+- Warn user API key won't be re-shown after clicking Publish Attestation (#125)
7979+- Inbound message log + admin UI visibility (#79)
8080+- Forward operator-classified inbound mail externally (unlocks SNDS verification + Yahoo CFL) (#133)
8181+- Add operator runbook covering DKIM rotation, FBL, warming, incident response (#132)
8282+- Admin: per-member detail page + inbound log UI (#131)
8383+- Add dual DKIM (d=atmos.email) + Feedback-ID header for pool-level FBL (#127)
8484+- Replace Druid event store with direct Postgres reads from atmosphere-mail admin (#126)
8585+- Onboarding gap: wizard missing step to publish email.atmos.attestation record (#124)
8686+- Gmail/Yahoo/AOL Feedback Loop (FBL) integration (#91)
8787+- Check tryfamilia.com NS propagation and verify DNS records (#1)
8888+- Replace sign-the-nonce enrollment with atproto OAuth + DNS TXT (#106)
8989+- Add admin_force bypass for DID challenge on enrollment (#73)
9090+- Handle→DID resolver in enrollment + polish (#100)
9191+- Self-service enrollment UI at atmos.email (#95)
9292+- Unique recipient domain counter + domain spray detection rule (#89)
9393+- Sub-hour velocity counters: sends_last_minute, sends_last_5_minutes (#88)
9494+- Consume Osprey labels in relay enforcement (highly_trusted, burst_*, gmail_*) (#87)
9595+- Leverage Osprey: velocity counters in events + expanded rule set (#85)
9696+- Inbound reply forwarding with SRS (#77)
9797+- Implement List-Unsubscribe with full suppression system (#76)
9898+- Improve SMTP error pass-through from onAccept to client (#19)
9999+- VictoriaMetrics scrape target for atmos-relay (#65)
100100+- Member suspension notification: SMTP error + self-serve status endpoint (#69)
101101+- Add VictoriaMetrics scrape target for atmos-relay Prometheus metrics (#48)
102102+- Operator dashboard: relay stats overview (messages, bounces, queue depth) (#52)
103103+- Operator dashboard: member detail view with suspend/reactivate actions (#51)
104104+- Operator dashboard: member list view with status, domain, send count (#50)
105105+- Dashboard: add bounces and queue depth stats (#64)
106106+- Optimize N+1 queries in member list endpoints (#61)
107107+- Add member labels to dashboard detail page (#58)
108108+- Add Tailscale Serve to expose dashboard at https://atmos-relay without port (#57)
109109+- Build operator dashboard with templ + htmx + Pico CSS (#54)
110110+- Operator dashboard: tech spike — choose embedded UI framework (templ vs htmx vs SPA) (#53)
111111+- Add GET /admin/members list endpoint (#49)
112112+- Add DID ownership challenge-response for enrollment verification (#45)
113113+- Add inbound bounce processing via port 25 SMTP listener (#40)
114114+- Improve SMTP error pass-through from onAccept to client (#19)
115115+- Add per-connection deadline and worker pool for MX delivery (#18)
116116+- Persist label bypass list across restarts (#17)
117117+118118+### Fixed
119119+- Fix Tailscale admin URL to serve enrollment pages (#198)
120120+- Fix enroll landing: contact email layout + dynamic placeholder + recover link (#146)
121121+- Fix empty API key and DKIM on enroll success page (JSON tag mismatch) (#123)
122122+- Fix enroll swaks quickstart to use --auth PLAIN + warmup schedule copy (#130)
123123+- Enroll verify failure should preserve step 2 with inline error (#122)
124124+- osprey-kafka OOM killed — bump memory from 1GB to 3GB (#105)
125125+- Osprey UI empty again + Tangled stale branch cache (#112)
126126+- Fix legal docs: LLC is Washington, not Delaware (#102)
127127+- Review round 3 fixes: async Kafka observability + singleflight test + suppressed pre-init (#101)
128128+- Background health probes for labeler + osprey reachability gauges (#78)
129129+- Fix thundering herd + shutdown ordering in Osprey integration (#68)
130130+- Fix review round 2: omitempty, malformed cache, dead metric labels (#67)
131131+- Fix review findings: metric init + non-200 error handling (#66)
132132+- Fix enrollment atomicity — wrap member + domain insert in transaction (#60)
133133+- Fix dashboard SSL error by serving UI on standard HTTP port (#55)
134134+- Update atmos-relay scrape target port from 8443 to 8080 (#56)
135135+- Fix review findings from bounce+challenge PRs (#46)
136136+- Fix retry counter reset on restart — persist attempts in spool (#13)
137137+138138+### Changed
139139+- Fix multi-domain enrollment: skip contact email + terms for existing members (#197)
140140+- Add handle label to OAuth callback metrics (#193)
141141+- Add HTTP request metrics to relay and wire Grafana visibility (#190)
142142+- Add enrollment funnel + office OAuth visit counters for Grafana (#191)
143143+- Enrollment handle typeahead: Bluesky actor search dropdown with avatars (#186)
144144+- Enrollment UX: simplify copy for non-email-technical PDS operators (#185)
145145+- Write Go label API for Osprey enforcer (cmd/label-api) (#184)
146146+- Honest copy pass: distinguish member self-hosting from operator federation (#150)
147147+- ci(deploy): add missing internal/** paths to relay-deploy trigger (#168)
148148+- Update operator-runbook FBL status: Yahoo CFL verified 2026-04-20 (#145)
149149+- Drop yahoo-verification-key TXT from atmos.email (verification succeeded, record is no-op now) (#144)
150150+- SML rule test framework for atmosphere-mail Osprey rules (#92)
151151+- Write blog post / status update about Atmosphere Mail (#84)
152152+- Delete stale feat/enroll-dns-txt-verification branch from Tangled (#129)
153153+- Rename Atmosphere Docs → Atmosphere Office in spec + memory (#128)
154154+- Queue has no max size — could OOM under sustained load (#8)
155155+- Recover atmosphere-mail Terraform state via resource imports (#80)
156156+- Recover atmosphere-mail Terraform state via resource imports (#80)
157157+- Pre-Dave polish audit: enrollment flow, error handling, admin approval UX (#115)
158158+- Admin dashboard: surface pending-review members (#116)
159159+- Post-enrollment: tell user they're pending operator approval, can't send yet (#117)
160160+- First-send quickstart: swaks/curl example on enrollment success page + docs (#118)
161161+- Enrollment: server-side handle-to-DID fallback when JS doesn't resolve (#119)
162162+- HEAD requests return 405 at the apex — allow HEAD on public routes (#120)
163163+- Error page: show specific error text above the generic message (#121)
164164+- Dashboard panels for inbound classification + reply-forwarding metrics (#81)
165165+- Investigate: can osprey-ui-api serve /api/events/* from Postgres, drop Druid? (#113)
166166+- Harden Osprey: druid-init periodic state-aware + Kafka retention + rule validation + Grafana + e2e probe (#114)
167167+- Add apex ALIAS records for URL forwards (atmosphere-mail.org + atmosphereemail.org) (#110)
168168+- Remove admin_force enrollment override (#111)
169169+- Swap atmosphereemail.org → atmospheremail.com (#109)
170170+- Multi-domain relay: atmosphereemail.org marketing + atmos.email redirect (#108)
171171+- fix(ui): mobile masthead override + tighten About tests (#104)
172172+- Who we are section + design critique round 2 (#103)
173173+- Merge pending PR #86 delete-dns-record workflow (#82)
174174+- LLC attribution in footer + about page (#98)
175175+- Boilerplate Terms of Service + Privacy Policy + AUP (#97)
176176+- UI polish pass on enrollment + landing pages (#99)
177177+- Fix Osprey rules deploy: pull from atmosphere-mail repo instead of inline heredocs (#86)
178178+- Migrate labeler to cloud-nix with public access at labeler.atmos.email (#75)
179179+- E2E test: DNS + attestation + labeler + send without bypass (#74)
180180+- Open-source cleanup: remove homelab-specific references (#72)
181181+- Observability fixes: Kafka errors, EHLO injection, emitter metrics (#71)
182182+- Relay enforcement: read Osprey labels at SMTP auth/send time (#63)
183183+- Add Kafka emitter integration tests (#62)
184184+- Multi-domain member model: split members into DID-level + domain-level tables (#59)
185185+- Add Prometheus metrics endpoint to relay (#47)
186186+- Integrate inbound bounces with existing bounce processor (#44)
187187+- Add VERP address decoder to match bounces to members and recipients (#43)
188188+- Add DSN message parser with bounce classification (#42)
189189+- Add inbound SMTP listener for bounce DSN messages on port 25 (#41)
190190+- VERP hash only 32 bits — collision risk at scale (#25)
191191+- Message data retention purge (30 days) (#39)
192192+- Open-source cleanup: gitignore, remove hardcoded Tailscale URL (#38)
193193+- Zero test coverage for delivery pipeline (#28)
194194+- End-to-end relay test with atmosphere-mail.org (#3)
195195+- Publish DKIM DNS records for atmosphere-mail.org (#37)
196196+- Missing validation tests for DID format, domain, duplicate domain (#36)
197197+- Reset() does not clear labelChecked flag (#33)
198198+- time.After leak in queue Run loop (#32)
199199+- EHLO hostname sends destination MX instead of relay domain — RFC 5321 violation (#31)
200200+- processReady drops remaining ready entries on shutdown (#27)
201201+- Partial delivery in onAccept causes duplicates when queue fills mid-batch (#26)
202202+- Label check called per-RCPT, should be once per session (#23)
203203+- Empty recipient in RecordBounce call — bounce correlation lost (#22)
204204+- CheckAndIncrementRate uses DEFERRED tx instead of IMMEDIATE (#21)
205205+- Delivery retry backoff should be configurable (#10)
206206+- Queue entries lost on crash — reload from SQLite on startup (#9)
207207+- Queue has no max size — could OOM under sustained load (#8)
208208+- Label cache grows unboundedly — add eviction (#7)
+657-17
LICENSE
···11-MIT License
11+ GNU AFFERO GENERAL PUBLIC LICENSE
22+ Version 3, 19 November 2007
33+44+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
55+ Everyone is permitted to copy and distribute verbatim copies
66+ of this license document, but changing it is not allowed.
77+88+ Preamble
99+1010+ The GNU Affero General Public License is a free, copyleft license for
1111+software and other kinds of works, specifically designed to ensure
1212+cooperation with the community in the case of network server software.
1313+1414+ The licenses for most software and other practical works are designed
1515+to take away your freedom to share and change the works. By contrast,
1616+our General Public Licenses are intended to guarantee your freedom to
1717+share and change all versions of a program--to make sure it remains free
1818+software for all its users.
1919+2020+ When we speak of free software, we are referring to freedom, not
2121+price. Our General Public Licenses are designed to make sure that you
2222+have the freedom to distribute copies of free software (and charge for
2323+them if you wish), that you receive source code or can get it if you
2424+want it, that you can change the software or use pieces of it in new
2525+free programs, and that you know you can do these things.
2626+2727+ Developers that use our General Public Licenses protect your rights
2828+with two steps: (1) assert copyright on the software, and (2) offer
2929+you this License which gives you legal permission to copy, distribute
3030+and/or modify the software.
3131+3232+ A secondary benefit of defending all users' freedom is that
3333+improvements made in alternate versions of the program, if they
3434+receive widespread use, become available for other developers to
3535+incorporate. Many developers of free software are heartened and
3636+encouraged by the resulting cooperation. However, in the case of
3737+software used on network servers, this result may fail to come about.
3838+The GNU General Public License permits making a modified version and
3939+letting the public access it on a server without ever releasing its
4040+source code to the public.
4141+4242+ The GNU Affero General Public License is designed specifically to
4343+ensure that, in such cases, the modified source code becomes available
4444+to the community. It requires the operator of a network server to
4545+provide the source code of the modified version running there to the
4646+users of that server. Therefore, public use of a modified version, on
4747+a publicly accessible server, gives the public access to the source
4848+code of the modified version.
4949+5050+ An older license, called the Affero General Public License and
5151+published by Affero, was designed to accomplish similar goals. This is
5252+a different license, not a version of the Affero GPL, but Affero has
5353+released a new version of the Affero GPL which permits relicensing under
5454+this license.
5555+5656+ The precise terms and conditions for copying, distribution and
5757+modification follow.
5858+5959+ TERMS AND CONDITIONS
6060+6161+ 0. Definitions.
6262+6363+ "This License" refers to version 3 of the GNU Affero General Public License.
6464+6565+ "Copyright" also means copyright-like laws that apply to other kinds of
6666+works, such as semiconductor masks.
6767+6868+ "The Program" refers to any copyrightable work licensed under this
6969+License. Each licensee is addressed as "you". "Licensees" and
7070+"recipients" may be individuals or organizations.
7171+7272+ To "modify" a work means to copy from or adapt all or part of the work
7373+in a fashion requiring copyright permission, other than the making of an
7474+exact copy. The resulting work is called a "modified version" of the
7575+earlier work or a work "based on" the earlier work.
7676+7777+ A "covered work" means either the unmodified Program or a work based
7878+on the Program.
7979+8080+ To "propagate" a work means to do anything with it that, without
8181+permission, would make you directly or secondarily liable for
8282+infringement under applicable copyright law, except executing it on a
8383+computer or modifying a private copy. Propagation includes copying,
8484+distribution (with or without modification), making available to the
8585+public, and in some countries other activities as well.
8686+8787+ To "convey" a work means any kind of propagation that enables other
8888+parties to make or receive copies. Mere interaction with a user through
8989+a computer network, with no transfer of a copy, is not conveying.
9090+9191+ An interactive user interface displays "Appropriate Legal Notices"
9292+to the extent that it includes a convenient and prominently visible
9393+feature that (1) displays an appropriate copyright notice, and (2)
9494+tells the user that there is no warranty for the work (except to the
9595+extent that warranties are provided), that licensees may convey the
9696+work under this License, and how to view a copy of this License. If
9797+the interface presents a list of user commands or options, such as a
9898+menu, a prominent item in the list meets this criterion.
9999+100100+ 1. Source Code.
101101+102102+ The "source code" for a work means the preferred form of the work
103103+for making modifications to it. "Object code" means any non-source
104104+form of a work.
105105+106106+ A "Standard Interface" means an interface that either is an official
107107+standard defined by a recognized standards body, or, in the case of
108108+interfaces specified for a particular programming language, one that
109109+is widely used among developers working in that language.
110110+111111+ The "System Libraries" of an executable work include anything, other
112112+than the work as a whole, that (a) is included in the normal form of
113113+packaging a Major Component, but which is not part of that Major
114114+Component, and (b) serves only to enable use of the work with that
115115+Major Component, or to implement a Standard Interface for which an
116116+implementation is available to the public in source code form. A
117117+"Major Component", in this context, means a major essential component
118118+(kernel, window system, and so on) of the specific operating system
119119+(if any) on which the executable work runs, or a compiler used to
120120+produce the work, or an object code interpreter used to run it.
121121+122122+ The "Corresponding Source" for a work in object code form means all
123123+the source code needed to generate, install, and (for an executable
124124+work) run the object code and to modify the work, including scripts to
125125+control those activities. However, it does not include the work's
126126+System Libraries, or general-purpose tools or generally available free
127127+programs which are used unmodified in performing those activities but
128128+which are not part of the work. For example, Corresponding Source
129129+includes interface definition files associated with source files for
130130+the work, and the source code for shared libraries and dynamically
131131+linked subprograms that the work is specifically designed to require,
132132+such as by intimate data communication or control flow between those
133133+subprograms and other parts of the work.
134134+135135+ The Corresponding Source need not include anything that users
136136+can regenerate automatically from other parts of the Corresponding
137137+Source.
138138+139139+ The Corresponding Source for a work in source code form is that
140140+same work.
141141+142142+ 2. Basic Permissions.
143143+144144+ All rights granted under this License are granted for the term of
145145+copyright on the Program, and are irrevocable provided the stated
146146+conditions are met. This License explicitly affirms your unlimited
147147+permission to run the unmodified Program. The output from running a
148148+covered work is covered by this License only if the output, given its
149149+content, constitutes a covered work. This License acknowledges your
150150+rights of fair use or other equivalent, as provided by copyright law.
151151+152152+ You may make, run and propagate covered works that you do not
153153+convey, without conditions so long as your license otherwise remains
154154+in force. You may convey covered works to others for the sole purpose
155155+of having them make modifications exclusively for you, or provide you
156156+with facilities for running those works, provided that you comply with
157157+the terms of this License in conveying all material for which you do
158158+not control copyright. Those thus making or running the covered works
159159+for you must do so exclusively on your behalf, under your direction
160160+and control, on terms that prohibit them from making any copies of
161161+your copyrighted material outside their relationship with you.
162162+163163+ Conveying under any other circumstances is permitted solely under
164164+the conditions stated below. Sublicensing is not allowed; section 10
165165+makes it unnecessary.
166166+167167+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168168+169169+ No covered work shall be deemed part of an effective technological
170170+measure under any applicable law fulfilling obligations under article
171171+11 of the WIPO copyright treaty adopted on 20 December 1996, or
172172+similar laws prohibiting or restricting circumvention of such
173173+measures.
217433-Copyright (c) 2026 Scott Lanoue
175175+ When you convey a covered work, you waive any legal power to forbid
176176+circumvention of technological measures to the extent such circumvention
177177+is effected by exercising rights under this License with respect to
178178+the covered work, and you disclaim any intention to limit operation or
179179+modification of the work as a means of enforcing, against the work's
180180+users, your or third parties' legal rights to forbid circumvention of
181181+technological measures.
418255-Permission is hereby granted, free of charge, to any person obtaining a copy
66-of this software and associated documentation files (the "Software"), to deal
77-in the Software without restriction, including without limitation the rights
88-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99-copies of the Software, and to permit persons to whom the Software is
1010-furnished to do so, subject to the following conditions:
183183+ 4. Conveying Verbatim Copies.
111841212-The above copyright notice and this permission notice shall be included in all
1313-copies or substantial portions of the Software.
185185+ You may convey verbatim copies of the Program's source code as you
186186+receive it, in any medium, provided that you conspicuously and
187187+appropriately publish on each copy an appropriate copyright notice;
188188+keep intact all notices stating that this License and any
189189+non-permissive terms added in accord with section 7 apply to the code;
190190+keep intact all notices of the absence of any warranty; and give all
191191+recipients a copy of this License along with the Program.
192192+193193+ You may charge any price or no price for each copy that you convey,
194194+and you may offer support or warranty protection for a fee.
195195+196196+ 5. Conveying Modified Source Versions.
141971515-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121-SOFTWARE.
198198+ You may convey a work based on the Program, or the modifications to
199199+produce it from the Program, in the form of source code under the
200200+terms of section 4, provided that you also meet all of these conditions:
201201+202202+ a) The work must carry prominent notices stating that you modified
203203+ it, and giving a relevant date.
204204+205205+ b) The work must carry prominent notices stating that it is
206206+ released under this License and any conditions added under section
207207+ 7. This requirement modifies the requirement in section 4 to
208208+ "keep intact all notices".
209209+210210+ c) You must license the entire work, as a whole, under this
211211+ License to anyone who comes into possession of a copy. This
212212+ License will therefore apply, along with any applicable section 7
213213+ additional terms, to the whole of the work, and all its parts,
214214+ regardless of how they are packaged. This License gives no
215215+ permission to license the work in any other way, but it does not
216216+ invalidate such permission if you have separately received it.
217217+218218+ d) If the work has interactive user interfaces, each must display
219219+ Appropriate Legal Notices; however, if the Program has interactive
220220+ interfaces that do not display Appropriate Legal Notices, your
221221+ work need not make them do so.
222222+223223+ A compilation of a covered work with other separate and independent
224224+works, which are not by their nature extensions of the covered work,
225225+and which are not combined with it such as to form a larger program,
226226+in or on a volume of a storage or distribution medium, is called an
227227+"aggregate" if the compilation and its resulting copyright are not
228228+used to limit the access or legal rights of the compilation's users
229229+beyond what the individual works permit. Inclusion of a covered work
230230+in an aggregate does not cause this License to apply to the other
231231+parts of the aggregate.
232232+233233+ 6. Conveying Non-Source Forms.
234234+235235+ You may convey a covered work in object code form under the terms
236236+of sections 4 and 5, provided that you also convey the
237237+machine-readable Corresponding Source under the terms of this License,
238238+in one of these ways:
239239+240240+ a) Convey the object code in, or embodied in, a physical product
241241+ (including a physical distribution medium), accompanied by the
242242+ Corresponding Source fixed on a durable physical medium
243243+ customarily used for software interchange.
244244+245245+ b) Convey the object code in, or embodied in, a physical product
246246+ (including a physical distribution medium), accompanied by a
247247+ written offer, valid for at least three years and valid for as
248248+ long as you offer spare parts or customer support for that product
249249+ model, to give anyone who possesses the object code either (1) a
250250+ copy of the Corresponding Source for all the software in the
251251+ product that is covered by this License, on a durable physical
252252+ medium customarily used for software interchange, for a price no
253253+ more than your reasonable cost of physically performing this
254254+ conveying of source, or (2) access to copy the
255255+ Corresponding Source from a network server at no charge.
256256+257257+ c) Convey individual copies of the object code with a copy of the
258258+ written offer to provide the Corresponding Source. This
259259+ alternative is allowed only occasionally and noncommercially, and
260260+ only if you received the object code with such an offer, in accord
261261+ with subsection 6b.
262262+263263+ d) Convey the object code by offering access from a designated
264264+ place (gratis or for a charge), and offer equivalent access to the
265265+ Corresponding Source in the same way through the same place at no
266266+ further charge. You need not require recipients to copy the
267267+ Corresponding Source along with the object code. If the place to
268268+ copy the object code is a network server, the Corresponding Source
269269+ may be on a different server (operated by you or a third party)
270270+ that supports equivalent copying facilities, provided you maintain
271271+ clear directions next to the object code saying where to find the
272272+ Corresponding Source. Regardless of what server hosts the
273273+ Corresponding Source, you remain obligated to ensure that it is
274274+ available for as long as needed to satisfy these requirements.
275275+276276+ e) Convey the object code using peer-to-peer transmission, provided
277277+ you inform other peers where the object code and Corresponding
278278+ Source of the work are being offered to the general public at no
279279+ charge under subsection 6d.
280280+281281+ A separable portion of the object code, whose source code is excluded
282282+from the Corresponding Source as a System Library, need not be
283283+included in conveying the object code work.
284284+285285+ A "User Product" is either (1) a "consumer product", which means any
286286+tangible personal property which is normally used for personal, family,
287287+or household purposes, or (2) anything designed or sold for incorporation
288288+into a dwelling. In determining whether a product is a consumer product,
289289+doubtful cases shall be resolved in favor of coverage. For a particular
290290+product received by a particular user, "normally used" refers to a
291291+typical or common use of that class of product, regardless of the status
292292+of the particular user or of the way in which the particular user
293293+actually uses, or expects or is expected to use, the product. A product
294294+is a consumer product regardless of whether the product has substantial
295295+commercial, industrial or non-consumer uses, unless such uses represent
296296+the only significant mode of use of the product.
297297+298298+ "Installation Information" for a User Product means any methods,
299299+procedures, authorization keys, or other information required to install
300300+and execute modified versions of a covered work in that User Product from
301301+a modified version of its Corresponding Source. The information must
302302+suffice to ensure that the continued functioning of the modified object
303303+code is in no case prevented or interfered with solely because
304304+modification has been made.
305305+306306+ If you convey an object code work under this section in, or with, or
307307+specifically for use in, a User Product, and the conveying occurs as
308308+part of a transaction in which the right of possession and use of the
309309+User Product is transferred to the recipient in perpetuity or for a
310310+fixed term (regardless of how the transaction is characterized), the
311311+Corresponding Source conveyed under this section must be accompanied
312312+by the Installation Information. But this requirement does not apply
313313+if neither you nor any third party retains the ability to install
314314+modified object code on the User Product (for example, the work has
315315+been installed in ROM).
316316+317317+ The requirement to provide Installation Information does not include a
318318+requirement to continue to provide support service, warranty, or updates
319319+for a work that has been modified or installed by the recipient, or for
320320+the User Product in which it has been modified or installed. Access to a
321321+network may be denied when the modification itself materially and
322322+adversely affects the operation of the network or violates the rules and
323323+protocols for communication across the network.
324324+325325+ Corresponding Source conveyed, and Installation Information provided,
326326+in accord with this section must be in a format that is publicly
327327+documented (and with an implementation available to the public in
328328+source code form), and must require no special password or key for
329329+unpacking, reading or copying.
330330+331331+ 7. Additional Terms.
332332+333333+ "Additional permissions" are terms that supplement the terms of this
334334+License by making exceptions from one or more of its conditions.
335335+Additional permissions that are applicable to the entire Program shall
336336+be treated as though they were included in this License, to the extent
337337+that they are valid under applicable law. If additional permissions
338338+apply only to part of the Program, that part may be used separately
339339+under those permissions, but the entire Program remains governed by
340340+this License without regard to the additional permissions.
341341+342342+ When you convey a copy of a covered work, you may at your option
343343+remove any additional permissions from that copy, or from any part of
344344+it. (Additional permissions may be written to require their own
345345+removal in certain cases when you modify the work.) You may place
346346+additional permissions on material, added by you to a covered work,
347347+for which you have or can give appropriate copyright permission.
348348+349349+ Notwithstanding any other provision of this License, for material you
350350+add to a covered work, you may (if authorized by the copyright holders of
351351+that material) supplement the terms of this License with terms:
352352+353353+ a) Disclaiming warranty or limiting liability differently from the
354354+ terms of sections 15 and 16 of this License; or
355355+356356+ b) Requiring preservation of specified reasonable legal notices or
357357+ author attributions in that material or in the Appropriate Legal
358358+ Notices displayed by works containing it; or
359359+360360+ c) Prohibiting misrepresentation of the origin of that material, or
361361+ requiring that modified versions of such material be marked in
362362+ reasonable ways as different from the original version; or
363363+364364+ d) Limiting the use for publicity purposes of names of licensors or
365365+ authors of the material; or
366366+367367+ e) Declining to grant rights under trademark law for use of some
368368+ trade names, trademarks, or service marks; or
369369+370370+ f) Requiring indemnification of licensors and authors of that
371371+ material by anyone who conveys the material (or modified versions of
372372+ it) with contractual assumptions of liability to the recipient, for
373373+ any liability that these contractual assumptions directly impose on
374374+ those licensors and authors.
375375+376376+ All other non-permissive additional terms are considered "further
377377+restrictions" within the meaning of section 10. If the Program as you
378378+received it, or any part of it, contains a notice stating that it is
379379+governed by this License along with a term that is a further
380380+restriction, you may remove that term. If a license document contains
381381+a further restriction but permits relicensing or conveying under this
382382+License, you may add to a covered work material governed by the terms
383383+of that license document, provided that the further restriction does
384384+not survive such relicensing or conveying.
385385+386386+ If you add terms to a covered work in accord with this section, you
387387+must place, in the relevant source files, a statement of the
388388+additional terms that apply to those files, or a notice indicating
389389+where to find the applicable terms.
390390+391391+ Additional terms, permissive or non-permissive, may be stated in the
392392+form of a separately written license, or stated as exceptions;
393393+the above requirements apply either way.
394394+395395+ 8. Termination.
396396+397397+ You may not propagate or modify a covered work except as expressly
398398+provided under this License. Any attempt otherwise to propagate or
399399+modify it is void, and will automatically terminate your rights under
400400+this License (including any patent licenses granted under the third
401401+paragraph of section 11).
402402+403403+ However, if you cease all violation of this License, then your
404404+license from a particular copyright holder is reinstated (a)
405405+provisionally, unless and until the copyright holder explicitly and
406406+finally terminates your license, and (b) permanently, if the copyright
407407+holder fails to notify you of the violation by some reasonable means
408408+prior to 60 days after the cessation.
409409+410410+ Moreover, your license from a particular copyright holder is
411411+reinstated permanently if the copyright holder notifies you of the
412412+violation by some reasonable means, this is the first time you have
413413+received notice of violation of this License (for any work) from that
414414+copyright holder, and you cure the violation prior to 30 days after
415415+your receipt of the notice.
416416+417417+ Termination of your rights under this section does not terminate the
418418+licenses of parties who have received copies or rights from you under
419419+this License. If your rights have been terminated and not permanently
420420+reinstated, you do not qualify to receive new licenses for the same
421421+material under section 10.
422422+423423+ 9. Acceptance Not Required for Having Copies.
424424+425425+ You are not required to accept this License in order to receive or
426426+run a copy of the Program. Ancillary propagation of a covered work
427427+occurring solely as a consequence of using peer-to-peer transmission
428428+to receive a copy likewise does not require acceptance. However,
429429+nothing other than this License grants you permission to propagate or
430430+modify any covered work. These actions infringe copyright if you do
431431+not accept this License. Therefore, by modifying or propagating a
432432+covered work, you indicate your acceptance of this License to do so.
433433+434434+ 10. Automatic Licensing of Downstream Recipients.
435435+436436+ Each time you convey a covered work, the recipient automatically
437437+receives a license from the original licensors, to run, modify and
438438+propagate that work, subject to this License. You are not responsible
439439+for enforcing compliance by third parties with this License.
440440+441441+ An "entity transaction" is a transaction transferring control of an
442442+organization, or substantially all assets of one, or subdividing an
443443+organization, or merging organizations. If propagation of a covered
444444+work results from an entity transaction, each party to that
445445+transaction who receives a copy of the work also receives whatever
446446+licenses to the work the party's predecessor in interest had or could
447447+give under the previous paragraph, plus a right to possession of the
448448+Corresponding Source of the work from the predecessor in interest, if
449449+the predecessor has it or can get it with reasonable efforts.
450450+451451+ You may not impose any further restrictions on the exercise of the
452452+rights granted or affirmed under this License. For example, you may
453453+not impose a license fee, royalty, or other charge for exercise of
454454+rights granted under this License, and you may not initiate litigation
455455+(including a cross-claim or counterclaim in a lawsuit) alleging that
456456+any patent claim is infringed by making, using, selling, offering for
457457+sale, or importing the Program or any portion of it.
458458+459459+ 11. Patents.
460460+461461+ A "contributor" is a copyright holder who authorizes use under this
462462+License of the Program or a work on which the Program is based. The
463463+work thus licensed is called the contributor's "contributor version".
464464+465465+ A contributor's "essential patent claims" are all patent claims
466466+owned or controlled by the contributor, whether already acquired or
467467+hereafter acquired, that would be infringed by some manner, permitted
468468+by this License, of making, using, or selling its contributor version,
469469+but do not include claims that would be infringed only as a
470470+consequence of further modification of the contributor version. For
471471+purposes of this definition, "control" includes the right to grant
472472+patent sublicenses in a manner consistent with the requirements of
473473+this License.
474474+475475+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476476+patent license under the contributor's essential patent claims, to
477477+make, use, sell, offer for sale, import and otherwise run, modify and
478478+propagate the contents of its contributor version.
479479+480480+ In the following three paragraphs, a "patent license" is any express
481481+agreement or commitment, however denominated, not to enforce a patent
482482+(such as an express permission to practice a patent or covenant not to
483483+sue for patent infringement). To "grant" such a patent license to a
484484+party means to make such an agreement or commitment not to enforce a
485485+patent against the party.
486486+487487+ If you convey a covered work, knowingly relying on a patent license,
488488+and the Corresponding Source of the work is not available for anyone
489489+to copy, free of charge and under the terms of this License, through a
490490+publicly available network server or other readily accessible means,
491491+then you must either (1) cause the Corresponding Source to be so
492492+available, or (2) arrange to deprive yourself of the benefit of the
493493+patent license for this particular work, or (3) arrange, in a manner
494494+consistent with the requirements of this License, to extend the patent
495495+license to downstream recipients. "Knowingly relying" means you have
496496+actual knowledge that, but for the patent license, your conveying the
497497+covered work in a country, or your recipient's use of the covered work
498498+in a country, would infringe one or more identifiable patents in that
499499+country that you have reason to believe are valid.
500500+501501+ If, pursuant to or in connection with a single transaction or
502502+arrangement, you convey, or propagate by procuring conveyance of, a
503503+covered work, and grant a patent license to some of the parties
504504+receiving the covered work authorizing them to use, propagate, modify
505505+or convey a specific copy of the covered work, then the patent license
506506+you grant is automatically extended to all recipients of the covered
507507+work and works based on it.
508508+509509+ A patent license is "discriminatory" if it does not include within
510510+the scope of its coverage, prohibits the exercise of, or is
511511+conditioned on the non-exercise of one or more of the rights that are
512512+specifically granted under this License. You may not convey a covered
513513+work if you are a party to an arrangement with a third party that is
514514+in the business of distributing software, under which you make payment
515515+to the third party based on the extent of your activity of conveying
516516+the work, and under which the third party grants, to any of the
517517+parties who would receive the covered work from you, a discriminatory
518518+patent license (a) in connection with copies of the covered work
519519+conveyed by you (or copies made from those copies), or (b) primarily
520520+for and in connection with specific products or compilations that
521521+contain the covered work, unless you entered into that arrangement,
522522+or that patent license was granted, prior to 28 March 2007.
523523+524524+ Nothing in this License shall be construed as excluding or limiting
525525+any implied license or other defenses to infringement that may
526526+otherwise be available to you under applicable patent law.
527527+528528+ 12. No Surrender of Others' Freedom.
529529+530530+ If conditions are imposed on you (whether by court order, agreement or
531531+otherwise) that contradict the conditions of this License, they do not
532532+excuse you from the conditions of this License. If you cannot convey a
533533+covered work so as to satisfy simultaneously your obligations under this
534534+License and any other pertinent obligations, then as a consequence you may
535535+not convey it at all. For example, if you agree to terms that obligate you
536536+to collect a royalty for further conveying from those to whom you convey
537537+the Program, the only way you could satisfy both those terms and this
538538+License would be to refrain entirely from conveying the Program.
539539+540540+ 13. Remote Network Interaction; Use with the GNU General Public License.
541541+542542+ Notwithstanding any other provision of this License, if you modify the
543543+Program, your modified version must prominently offer all users
544544+interacting with it remotely through a computer network (if your version
545545+supports such interaction) an opportunity to receive the Corresponding
546546+Source of your version by providing access to the Corresponding Source
547547+from a network server at no charge, through some standard or customary
548548+means of facilitating copying of software. This Corresponding Source
549549+shall include the Corresponding Source for any work covered by version 3
550550+of the GNU General Public License that is incorporated pursuant to the
551551+following paragraph.
552552+553553+ Notwithstanding any other provision of this License, you have
554554+permission to link or combine any covered work with a work licensed
555555+under version 3 of the GNU General Public License into a single
556556+combined work, and to convey the resulting work. The terms of this
557557+License will continue to apply to the part which is the covered work,
558558+but the work with which it is combined will remain governed by version
559559+3 of the GNU General Public License.
560560+561561+ 14. Revised Versions of this License.
562562+563563+ The Free Software Foundation may publish revised and/or new versions of
564564+the GNU Affero General Public License from time to time. Such new versions
565565+will be similar in spirit to the present version, but may differ in detail to
566566+address new problems or concerns.
567567+568568+ Each version is given a distinguishing version number. If the
569569+Program specifies that a certain numbered version of the GNU Affero General
570570+Public License "or any later version" applies to it, you have the
571571+option of following the terms and conditions either of that numbered
572572+version or of any later version published by the Free Software
573573+Foundation. If the Program does not specify a version number of the
574574+GNU Affero General Public License, you may choose any version ever published
575575+by the Free Software Foundation.
576576+577577+ If the Program specifies that a proxy can decide which future
578578+versions of the GNU Affero General Public License can be used, that proxy's
579579+public statement of acceptance of a version permanently authorizes you
580580+to choose that version for the Program.
581581+582582+ Later license versions may give you additional or different
583583+permissions. However, no additional obligations are imposed on any
584584+author or copyright holder as a result of your choosing to follow a
585585+later version.
586586+587587+ 15. Disclaimer of Warranty.
588588+589589+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590590+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591591+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592592+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593593+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594594+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595595+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596596+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597597+598598+ 16. Limitation of Liability.
599599+600600+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601601+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602602+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603603+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604604+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605605+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606606+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607607+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608608+SUCH DAMAGES.
609609+610610+ 17. Interpretation of Sections 15 and 16.
611611+612612+ If the disclaimer of warranty and limitation of liability provided
613613+above cannot be given local legal effect according to their terms,
614614+reviewing courts shall apply local law that most closely approximates
615615+an absolute waiver of all civil liability in connection with the
616616+Program, unless a warranty or assumption of liability accompanies a
617617+copy of the Program in return for a fee.
618618+619619+ END OF TERMS AND CONDITIONS
620620+621621+ How to Apply These Terms to Your New Programs
622622+623623+ If you develop a new program, and you want it to be of the greatest
624624+possible use to the public, the best way to achieve this is to make it
625625+free software which everyone can redistribute and change under these terms.
626626+627627+ To do so, attach the following notices to the program. It is safest
628628+to attach them to the start of each source file to most effectively
629629+state the exclusion of warranty; and each file should have at least
630630+the "copyright" line and a pointer to where the full notice is found.
631631+632632+ <one line to give the program's name and a brief idea of what it does.>
633633+ Copyright (C) <year> <name of author>
634634+635635+ This program is free software: you can redistribute it and/or modify
636636+ it under the terms of the GNU Affero General Public License as published by
637637+ the Free Software Foundation, either version 3 of the License, or
638638+ (at your option) any later version.
639639+640640+ This program is distributed in the hope that it will be useful,
641641+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642642+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643643+ GNU Affero General Public License for more details.
644644+645645+ You should have received a copy of the GNU Affero General Public License
646646+ along with this program. If not, see <https://www.gnu.org/licenses/>.
647647+648648+Also add information on how to contact you by electronic and paper mail.
649649+650650+ If your software can interact with users remotely through a computer
651651+network, you should also make sure that it provides a way for users to
652652+get its source. For example, if your program is a web application, its
653653+interface could display a "Source" link that leads users to an archive
654654+of the code. There are many ways you could offer source, and different
655655+solutions will be better for different programs; see section 13 for the
656656+specific requirements.
657657+658658+ You should also get your employer (if you work as a programmer) or school,
659659+if any, to sign a "copyright disclaimer" for the program, if necessary.
660660+For more information on this, and how to apply and follow the GNU AGPL, see
661661+<https://www.gnu.org/licenses/>.
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Code generated by templ - DO NOT EDIT.
2435// templ: version: v0.3.1001
+15-13
internal/admin/ui/templates/enroll.templ
···881881 autocapitalize="off"
882882 />
883883884884- <label for="contact_email">Contact email</label>
885885- <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>
886886- <input
887887- type="email"
888888- id="contact_email"
889889- name="contact_email"
890890- placeholder="you@example.com"
891891- required
892892- autocomplete="email"
893893- spellcheck="false"
894894- autocapitalize="off"
895895- />
896896-897884 <button type="submit">Add domain →</button>
898885 </form>
899886 } else {
···928915 spellcheck="false"
929916 autocapitalize="off"
930917 />
918918+919919+ <label style="display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;">
920920+ <input
921921+ type="checkbox"
922922+ name="terms_accepted"
923923+ id="terms_accepted"
924924+ required
925925+ style="margin-top: 0.25rem; accent-color: var(--accent);"
926926+ />
927927+ <span style="font-size: var(--t-s);">
928928+ I agree to the <a href="/terms" target="_blank">Terms of Service</a> and
929929+ <a href="/aup" target="_blank">Acceptable Use Policy</a>,
930930+ which may be updated with reasonable notice.
931931+ </span>
932932+ </label>
931933932934 <button type="submit">Start enrollment →</button>
933935 </form>
+4-2
internal/admin/ui/templates/enroll_templ.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Code generated by templ - DO NOT EDIT.
2435// templ: version: v0.3.1001
···284286 return templ_7745c5c3_Err
285287 }
286288 } else if len(existingDomains) == 1 {
287287- 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>")
289289+ 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>")
288290 if templ_7745c5c3_Err != nil {
289291 return templ_7745c5c3_Err
290292 }
291293 } else {
292292- 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>")
294294+ 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>")
293295 if templ_7745c5c3_Err != nil {
294296 return templ_7745c5c3_Err
295297 }
+2
internal/admin/ui/templates/events_templ.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Code generated by templ - DO NOT EDIT.
2435// templ: version: v0.3.1001
+2
internal/admin/ui/templates/inbound.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package templates
2435// Admin UI templates for the inbound audit log.
+2
internal/admin/ui/templates/label_status.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package templates
2435// Small polling block for the enroll success page. Renders a status
+2
internal/admin/ui/templates/layout_templ.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Code generated by templ - DO NOT EDIT.
2435// templ: version: v0.3.1001
+2
internal/admin/ui/templates/marketing.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package templates
2435// Marketing landing at /. Top of funnel: tells the cooperative-
+2
internal/admin/ui/templates/member_detail_rich.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package templates
2435// Hand-written templates for the rich per-member detail page and the
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package atpoauth wraps indigo's atproto OAuth client for the
24// atmosphere-mail enrollment wizard. It exposes a narrow, purpose-built API
35// — StartAuthFlow, CompleteCallback, PutRecord — and hides indigo's type
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package enroll provides the domain-ownership-proof primitives used by the
24// self-service enrollment wizard.
35//
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package notify dispatches operator-facing notifications to a
24// configured HTTP webhook. Events are structured JSON; operators wire
35// the URL to whatever sink they prefer — Slack incoming-webhook, a
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package osprey emits relay events to an Osprey instance via Kafka.
24//
35// Events are emitted at key decision points in the relay pipeline:
···6365 SendsLastHour int `json:"sends_last_hour"`
6466 HardBouncesLast24h int `json:"hard_bounces_last_24h"`
6567 UniqueRecipientDomainsLastHour int `json:"unique_recipient_domains_last_hour"`
6868+ SameContentRecipientsLastHour int `json:"same_content_recipients_last_hour"`
66696770 // relay_rejected
6871 RejectReason string `json:"reject_reason,omitempty"`
+2
internal/relay/arf/parser.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package arf parses Abuse Reporting Format (ARF) messages per RFC 5965.
24//
35// ARF is how major mailbox providers (Gmail, Yahoo, Microsoft, AOL) deliver
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package relay
2435// Kafka consumer that pulls Osprey's rule-evaluation output
···5557 LastKafkaOffset(ctx context.Context) (int64, error)
5658}
57596060+// ConsumerMetrics is the subset of Metrics the event consumer needs.
6161+// Satisfied by *Metrics; tests can substitute a stub or nil.
6262+type ConsumerMetrics interface {
6363+ RecordEventConsumed(offset int64)
6464+}
6565+5866// OspreyEventConsumer pulls Osprey execution results from Kafka and
5967// writes them to the relay's local event store.
6068type OspreyEventConsumer struct {
6161- reader kafkaReader
6262- store OspreyEventStore
6969+ reader kafkaReader
7070+ store OspreyEventStore
7171+ metrics ConsumerMetrics
6372}
64736574// NewOspreyEventConsumer constructs a consumer reading from the given
6675// broker. The caller is expected to run Run(ctx) in a goroutine and
6776// Close on shutdown.
6868-func NewOspreyEventConsumer(broker string, store OspreyEventStore) *OspreyEventConsumer {
7777+func NewOspreyEventConsumer(broker string, store OspreyEventStore, opts ...func(*OspreyEventConsumer)) *OspreyEventConsumer {
6978 r := kafka.NewReader(kafka.ReaderConfig{
7079 Brokers: []string{broker},
7180 GroupID: ospreyConsumerGroupID,
···7584 MaxWait: 500 * time.Millisecond,
7685 StartOffset: kafka.FirstOffset, // only used when no committed offset exists
7786 })
7878- return &OspreyEventConsumer{reader: r, store: store}
8787+ c := &OspreyEventConsumer{reader: r, store: store}
8888+ for _, o := range opts {
8989+ o(c)
9090+ }
9191+ return c
9292+}
9393+9494+// WithConsumerMetrics sets the metrics recorder for the event consumer.
9595+func WithConsumerMetrics(metrics ConsumerMetrics) func(*OspreyEventConsumer) {
9696+ return func(c *OspreyEventConsumer) { c.metrics = metrics }
7997}
80988199// newOspreyEventConsumerWithReader is the test constructor.
8282-func newOspreyEventConsumerWithReader(r kafkaReader, store OspreyEventStore) *OspreyEventConsumer {
8383- return &OspreyEventConsumer{reader: r, store: store}
100100+func newOspreyEventConsumerWithReader(r kafkaReader, store OspreyEventStore, opts ...func(*OspreyEventConsumer)) *OspreyEventConsumer {
101101+ c := &OspreyEventConsumer{reader: r, store: store}
102102+ for _, o := range opts {
103103+ o(c)
104104+ }
105105+ return c
84106}
8510786108// Run polls Kafka until ctx is cancelled. Decoding or DB errors are
···134156 // until the NEXT successful ReadMessage auto-commit,
135157 // so we naturally re-read the message.
136158 continue
159159+ }
160160+161161+ if c.metrics != nil {
162162+ c.metrics.RecordEventConsumed(msg.Offset)
137163 }
138164139165 log.Printf("relay_events.consumer.insert: offset=%d action=%s did=%s",
+62
internal/relay/events_consumer_test.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package relay
2435import (
46 "context"
57 "errors"
68 "io"
99+ "math"
710 "testing"
811 "time"
9121013 "atmosphere-mail/internal/relaystore"
11141515+ "github.com/prometheus/client_golang/prometheus"
1616+ "github.com/prometheus/client_golang/prometheus/testutil"
1217 "github.com/segmentio/kafka-go"
1318)
1419···312317}
313318314319func (r *errThenEOFReader) Close() error { return nil }
320320+321321+func TestConsumerMetricsUpdatedOnIngest(t *testing.T) {
322322+ store := newMemStore(t)
323323+ metrics := NewMetrics(prometheus.NewRegistry())
324324+325325+ reader := &fakeReader{messages: []kafka.Message{
326326+ {Value: []byte(sampleRelayAttempt), Offset: 42},
327327+ {Value: []byte(sampleDeliveryResult), Offset: 99},
328328+ }}
329329+330330+ before := float64(time.Now().Unix())
331331+332332+ c := newOspreyEventConsumerWithReader(reader, store, WithConsumerMetrics(metrics))
333333+ if err := c.Run(context.Background()); err != nil {
334334+ t.Fatalf("Run: %v", err)
335335+ }
336336+337337+ after := float64(time.Now().Unix())
338338+339339+ // The offset gauge should reflect the last consumed message (offset 99).
340340+ gotOffset := testutil.ToFloat64(metrics.EventsConsumerOffset)
341341+ if gotOffset != 99 {
342342+ t.Errorf("EventsConsumerOffset = %v, want 99", gotOffset)
343343+ }
344344+345345+ // The timestamp gauge should be approximately now (within the test window).
346346+ gotTS := testutil.ToFloat64(metrics.EventsConsumerLastIngestTimestamp)
347347+ if gotTS < before || gotTS > after+1 {
348348+ t.Errorf("EventsConsumerLastIngestTimestamp = %v, want between %v and %v", gotTS, before, after+1)
349349+ }
350350+}
351351+352352+func TestConsumerMetricsNotUpdatedOnDecodeError(t *testing.T) {
353353+ store := newMemStore(t)
354354+ metrics := NewMetrics(prometheus.NewRegistry())
355355+356356+ reader := &fakeReader{messages: []kafka.Message{
357357+ {Value: []byte(`not json`), Offset: 5},
358358+ }}
359359+360360+ c := newOspreyEventConsumerWithReader(reader, store, WithConsumerMetrics(metrics))
361361+ if err := c.Run(context.Background()); err != nil {
362362+ t.Fatalf("Run: %v", err)
363363+ }
364364+365365+ // No successful ingest happened, so offset should remain at zero.
366366+ gotOffset := testutil.ToFloat64(metrics.EventsConsumerOffset)
367367+ if gotOffset != 0 {
368368+ t.Errorf("EventsConsumerOffset = %v, want 0 (no successful ingest)", gotOffset)
369369+ }
370370+371371+ // Timestamp should also remain at zero.
372372+ gotTS := testutil.ToFloat64(metrics.EventsConsumerLastIngestTimestamp)
373373+ if math.Abs(gotTS) > 0.001 {
374374+ t.Errorf("EventsConsumerLastIngestTimestamp = %v, want 0 (no successful ingest)", gotTS)
375375+ }
376376+}
+2
internal/relay/fingerprint.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package relay
2435// ContentFingerprint hashes a normalized view of (subject, body) so the
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package relay — inbound reply forwarding delivery path.
24//
35// When the inbound SMTP server classifies a message as a reply (not a
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package relay — public HTTPS host-based router.
24//
35// The public listener answers for multiple hostnames with different roles:
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13// Package relay — Sender Rewriting Scheme (SRS) for inbound mail forwarding.
24//
35// When the relay forwards mail (e.g. a reply from Gmail back to a member's
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package server
2435import (
+2
internal/server/query.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package server
2435import (
+2
internal/server/server.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package server
2435import (
+2
internal/server/server_test.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package server
2435import (
+2
internal/server/subscribe.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package server
2435import (
+2
internal/store/sqlite.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package store
2435import (
+2
internal/store/sqlite_test.go
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+13package store
2435import (
+11
osprey/config/labels.yaml
···148148 valid_for: [RecipientDomain]
149149 connotation: neutral
150150 description: "Destination domain reported a complaint in the last 7 days"
151151+152152+ # Content spray shadow labels (observe-only, no enforcement yet)
153153+ shadow:content_spray:
154154+ valid_for: [SenderDID]
155155+ connotation: negative
156156+ description: "Shadow: same message body sent to 15+ unique recipients in last hour — possible bulk/newsletter"
157157+158158+ shadow:content_spray_extreme:
159159+ valid_for: [SenderDID]
160160+ connotation: negative
161161+ description: "Shadow: same message body sent to 50+ unique recipients in last hour — bulk mail"
···11+# Content spray detection (bulk/newsletter enforcement)
22+#
33+# Transactional email has unique bodies per recipient: password resets
44+# contain tokens, verification emails contain codes, account notifications
55+# include user-specific details. The content fingerprint (sha256 of
66+# normalized subject+body) naturally differs for each recipient.
77+#
88+# Newsletter/marketing/bulk mail sends the SAME body to many recipients.
99+# same_content_recipients_last_hour counts distinct recipients who got
1010+# the same fingerprint from this sender in the last hour.
1111+#
1212+# Shadow mode first: labels are prefixed with shadow: so they're logged
1313+# but don't affect send behavior. Promote to real labels after bake-in
1414+# confirms zero false positives on production traffic.
1515+#
1616+# Privacy: the relay stores only the sha256 hash, never email addresses
1717+# or body content. The counter is a scalar — Osprey sees only the number.
1818+1919+Import(rules=['models/relay.sml'])
2020+2121+# Moderate content spray: same body to 15+ unique recipients in an hour.
2222+# Legitimate transactional senders won't hit this because each message
2323+# body contains recipient-specific tokens.
2424+ContentSpray = Rule(
2525+ when_all=[
2626+ EventType == 'relay_attempt',
2727+ SameContentRecipientsLastHour != None,
2828+ SameContentRecipientsLastHour >= 15,
2929+ ],
3030+ description='Same message body sent to 15+ unique recipients in last hour — possible bulk/newsletter'
3131+)
3232+3333+WhenRules(
3434+ rules_any=[ContentSpray],
3535+ then=[
3636+ LabelAdd(entity=SenderDID, label='shadow:content_spray', expires_after=TimeDelta(hours=12)),
3737+ ],
3838+)
3939+4040+# Extreme content spray: same body to 50+ unique recipients in an hour.
4141+# No legitimate transactional use case produces this pattern.
4242+ExtremeContentSpray = Rule(
4343+ when_all=[
4444+ EventType == 'relay_attempt',
4545+ SameContentRecipientsLastHour != None,
4646+ SameContentRecipientsLastHour >= 50,
4747+ ],
4848+ description='Same message body sent to 50+ unique recipients in last hour — bulk mail'
4949+)
5050+5151+WhenRules(
5252+ rules_any=[ExtremeContentSpray],
5353+ then=[
5454+ LabelAdd(entity=SenderDID, label='shadow:content_spray_extreme', expires_after=TimeDelta(days=1)),
5555+ ],
5656+)