notify: reply to notification mail to comment !534

merged merged by cmc on 2026-09-29 06:52 UTC · krz/gitbay:reply-mail-295 into main

52 files changed, +3585 −30

Layout: unified · split

.gitbay/wiki/Admin.org +88
@@ -147,6 +147,94 @@ build page's live log arrives only when the build ends;
147 =localhost= and loopback addresses; set =false= to allow plaintext 147 =localhost= and loopback addresses; set =false= to allow plaintext
148 to a remote relay. 148 to a remote relay.
149 149
150** [mail.inbound]
151Reply by mail: a reply to issue or merge request mail posts a comment
152(#295). gitbayd polls a mailbox over IMAP; no port is opened for it.
153Off unless =enabled=, and it requires =[mail] smtp_host=, since the
154replies answer mail gitbayd sends.
155
156#+begin_src toml
157[mail.inbound]
158enabled = true
159imap_host = "imap.example.org" # host:port; 993, or 143 with tls = "starttls"
160tls = "implicit" # or "starttls"; there is no plaintext setting
161user = "reply@gitbay.example"
162password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd's user
163mailbox = "INBOX" # default
164poll_interval = "1m" # default; at least 10s
165reply_address = "reply@gitbay.example"
166trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id
167#+end_src
168
169- The password is read from =password_file= at every connection and
170 never appears in the config, argv or the log. An unreadable file, or
171 one readable by group or others, stops the daemon at start.
172- =reply_address= is what each Reply-To is built from:
173 =reply@gitbay.example= becomes =reply+<token>@gitbay.example=. The
174 mailbox must receive that: give it a plus-addressing (subaddress)
175 mailbox, as most hosted mail does by default, or a catch-all for the
176 domain. Point the domain's MX at the mail host as for any other
177 mailbox; nothing about it involves gitbayd.
178- Use a mailbox that holds nothing else. gitbayd reads every unseen
179 message in it and marks each one =\Seen= when it is handled, posted
180 or refused. A message that fails for a reason that may pass (the
181 database busy, the server refusing that one fetch) stays unseen and is
182 tried again on the next poll, up to five times, then is marked seen
183 and audited. A dropped connection or a timeout ends the poll and
184 counts against no message. A
185 message over 10 MiB is refused by its size without being fetched. A
186 server that sends more than about 11 MiB, or more than a thousand
187 untagged responses, for one command has its connection closed; one
188 poll handles at most ten thousand messages.
189- =trusted_authserv_id= is required on any instance reachable from the
190 internet. It names the authserv-id the mail host writes at the start
191 of its =Authentication-Results= header (Gmail's is =mx.google.com=).
192 With it set, a reply is posted only when the topmost header with that
193 id shows =dmarc=pass= with =header.from= equal to the From domain, or
194 =dkim=pass= with a =header.d= in relaxed alignment with it (the same
195 organizational domain by the public suffix list; a public suffix such
196 as =github.io= aligns with nothing). The header is parsed per RFC
197 8601, so text inside a quoted string or a comment (a quoted MAIL FROM
198 local part, a reason) is never read as a result. Lower headers
199 claiming the same id are the sender's and are not read. This is only safe when the mail host
200 removes incoming =Authentication-Results= headers that claim its id,
201 as RFC 8601 asks; Gmail, Fastmail and Migadu do. Check yours before
202 relying on it. Unset, the daemon logs a warning at start and =admin
203 mail inbound check= repeats it: without it, =From= is whatever the
204 sender wrote.
205- =gitbay admin mail inbound check= logs in, opens the mailbox
206 read-only (EXAMINE) and reports the message and unseen counts, so a
207 check never marks a reply seen before the poller reads it. With
208 inbound off it says so and exits 0. Poll failures are logged as
209 =mail reply: poll failed= with the server and the IMAP error.
210
211A reply is posted when all of these hold, checked when it is read:
212
2131. It is not an automatic reply (=Auto-Submitted=, =Precedence: bulk=
214 and the like).
2152. A recipient header carries a reply address whose token verifies
216 and has not expired (thirty days from the mail it came on).
2173. The token's account exists, is active and not disabled, still has
218 reply by mail on, and was created before the token was: an id freed
219 by a delete and reused is not the account the token named. The same
220 holds for the repository.
2214. =From= is one of that account's verified addresses, and, with
222 =trusted_authserv_id= set, the mail host authenticated it.
2235. The message has a =text/plain= part (HTML-only mail is refused, not
224 converted), and what is left after quoted text and the signature
225 are removed is not empty and fits a comment (64 KiB).
2266. No earlier copy of the message (same =Message-ID=, account and
227 thread) posted.
2287. =issue comment= or =mr comment=, dispatched as the account, accepts
229 it: the account can still read the repository, the thread exists,
230 the repository is not archived, the write budget is not spent.
231
232The comment's audit row is =cmd issue comment= (or =mr comment=) with
233=source: mail=. A refusal writes a =refused mail reply= row with the
234reason and the =Message-ID=, never the message's content, at most sixty
235a minute, and sends nothing back. See Threat-Model for why the token
236and the sender address are both required.
237
150** [push] 238** [push]
151Push notifications to Apple devices, delivered by gitbayd talking to 239Push notifications to Apple devices, delivered by gitbayd talking to
152APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an 240APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an
.gitbay/wiki/Architecture/01-System-Context.org +1
@@ -35,6 +35,7 @@ do not reimplement logic (=internal/httpd/control.go=,
35| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go= | 35| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go= |
36| SMTP relay | out | verification, login links, notifications | =internal/mail/mail.go= | 36| SMTP relay | out | verification, login links, notifications | =internal/mail/mail.go= |
37| Apple Push Notification | out | iOS notifications | =internal/push/apns.go= | 37| Apple Push Notification | out | iOS notifications | =internal/push/apns.go= |
38| IMAP mailbox | out | replies to notification mail (opt-in) | =internal/mailin=, =internal/imapc= |
38| Webhook endpoints | out | event delivery, user-configured | =internal/webhook/webhook.go= | 39| Webhook endpoints | out | event delivery, user-configured | =internal/webhook/webhook.go= |
39| Mirror remotes | out / in | push and pull mirrors, user-configured | =internal/mirror/mirror.go= | 40| Mirror remotes | out / in | push and pull mirrors, user-configured | =internal/mirror/mirror.go= |
40| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go= | 41| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go= |
.gitbay/wiki/Architecture/02-Components.org +2
@@ -35,6 +35,7 @@ the hidden =hook= used by git (=cmd/gitbayd/main.go=,
35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. | 35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. |
36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. | 36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. |
37| =internal/push= | APNs queue drain and provider-token signing. | 37| =internal/push= | APNs queue drain and provider-token signing. |
38| =internal/mailin=, =internal/imapc=, =internal/mailreply= | Reply by mail: the IMAP client, the reply token, and the processor that posts a reply through =issue comment= / =mr comment=. |
38| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). | 39| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). |
39| =internal/config= | Configuration load and validation. | 40| =internal/config= | Configuration load and validation. |
40| =internal/web= | Embedded templates, stylesheet and fonts. | 41| =internal/web= | Embedded templates, stylesheet and fonts. |
@@ -77,6 +78,7 @@ Started by =gitbayd serve= (=cmd/gitbayd/main.go=):
77| Webhook delivery | always | 2 s poll | =webhook_deliveries= | 78| Webhook delivery | always | 2 s poll | =webhook_deliveries= |
78| Mail | =mail.smtp_host= set | 2 s poll | =notifications= | 79| Mail | =mail.smtp_host= set | 2 s poll | =notifications= |
79| APNs push | =push.enabled= | 2 s poll | =push_queue= | 80| APNs push | =push.enabled= | 2 s poll | =push_queue= |
81| Mail replies | =mail.inbound.enabled= | =mail.inbound.poll_interval= (1 min) | IMAP mailbox, =mail_replies= |
80| Mirrors | always | 10 s tick, per-mirror interval | =mirrors= | 82| Mirrors | always | 10 s tick, per-mirror interval | =mirrors= |
81| CI scheduler | always | 1 min tick; reaps stale builds | =build_schedules=, =builds= | 83| CI scheduler | always | 1 min tick; reaps stale builds | =build_schedules=, =builds= |
82| Dependency checks | always (repos opt in) | =deps.check_interval_hours= | =dep_checks= | 84| Dependency checks | always (repos opt in) | =deps.check_interval_hours= | =dep_checks= |
.gitbay/wiki/Architecture/03-Deployment.org +2
@@ -51,6 +51,7 @@ a database check.
51| =<root>/hooks= | generated hook scripts | 0755 | 51| =<root>/hooks= | generated hook scripts | 0755 |
52| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) | 52| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) |
53| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) | 53| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) |
54| =mail.inbound.password_file= | IMAP mailbox password | 0600 required; the daemon refuses to start otherwise |
54| =/var/backups/gitbay= | backup archives | 0750 (cloud-init) | 55| =/var/backups/gitbay= | backup archives | 0750 (cloud-init) |
55 56
56* Outbound connections from gitbayd 57* Outbound connections from gitbayd
@@ -60,6 +61,7 @@ a database check.
60| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) | 61| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
61| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | 62| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
62| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | 63| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
64| IMAP server | =mail.inbound.poll_interval= | implicit TLS or STARTTLS, certificate verified; no plaintext setting | operator-configured host only (=internal/imapc=) |
63| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) | 65| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
64| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) | 66| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
65| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | 67| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1
@@ -22,6 +22,7 @@
22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | 22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | 23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | 24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use |
25| Mail reply token | HMAC-SHA256 (96 bits) over account, repository, thread, expiry, in the Reply-To local part | not stored (stateless); a =Message-ID= that posted is kept | one comment thread, as one account, only from that account's verified =From= | 30 days | with the account's access, checked when the reply is read; =notifications settings reply off= |
25| LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs | 26| LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs |
26 27
27Generation and hashing: =internal/store/sessions.go= (=NewToken=, 28Generation and hashing: =internal/store/sessions.go= (=NewToken=,
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +3 −1
@@ -16,7 +16,7 @@ content (as confidential as the repository), *O* operational.
16| CI | =builds= (includes logs), =build_schedules=, =runner_repos=, =runner_seen= | R | build logs can echo anything a step prints | 16| CI | =builds= (includes logs), =build_schedules=, =runner_repos=, =runner_seen= | R | build logs can echo anything a step prints |
17| CI secrets | =build_secrets= | C | sealed (AES-256-GCM) | 17| CI secrets | =build_secrets= | C | sealed (AES-256-GCM) |
18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | webhook secret and mirror token sealed | 18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | webhook secret and mirror token sealed |
19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens sealed; looked up by SHA-256 | 19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue=, =mail_replies= (Message-IDs of posted replies) | P | device tokens sealed; looked up by SHA-256 |
20| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache | 20| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache |
21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) | 21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) |
22| Dependencies | =dep_checks=, =dep_reports= | O | | 22| Dependencies | =dep_checks=, =dep_reports= | O | |
@@ -31,6 +31,7 @@ Outside the database:
31| TLS keys (ACME) | =<root>/acme= | C | 31| TLS keys (ACME) | =<root>/acme= | C |
32| SMTP password | =/etc/gitbay/config.toml= | C | 32| SMTP password | =/etc/gitbay/config.toml= | C |
33| APNs signing key (.p8) | path in =push.key_file= | C | 33| APNs signing key (.p8) | path in =push.key_file= | C |
34| IMAP password | path in =mail.inbound.password_file= | C |
34| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C | 35| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C |
35| Backups | =/var/backups/gitbay=, offsite | all of the above | 36| Backups | =/var/backups/gitbay=, offsite | all of the above |
36 37
@@ -64,6 +65,7 @@ token without the key file, which neither carries. Rotation:
64| Runner ↔ server | SSH | 65| Runner ↔ server | SSH |
65| SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) | 66| SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) |
66| APNs | TLS, HTTP/2 | 67| APNs | TLS, HTTP/2 |
68| IMAP | implicit TLS or STARTTLS, TLS 1.2 minimum, certificate verified (=internal/imapc=) |
67| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) | 69| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) |
68| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) | 70| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) |
69 71
.gitbay/wiki/Parity.org +11
@@ -395,6 +395,7 @@ client has no use for one (krz/gitbay#57).
395| dashboard aggregate | yes | yes | yes | 395| dashboard aggregate | yes | yes | yes |
396| notification inbox | yes | yes | yes | 396| notification inbox | yes | yes | yes |
397| activity mail on, off | yes | yes | yes | 397| activity mail on, off | yes | yes | yes |
398| reply by mail on, off | yes | yes | no |
398| watch writable repos | yes | yes | yes | 399| watch writable repos | yes | yes | yes |
399| push device add | yes | yes | yes | 400| push device add | yes | yes | yes |
400| push device list | yes | yes | yes | 401| push device list | yes | yes | yes |
@@ -430,6 +431,15 @@ it, because only the iOS app can produce an APNs device token — a
430browser has no way to ask Apple for one. =device list= and =device 431browser has no way to ask Apple for one. =device list= and =device
431remove= have no such limit and are yes on the web like the rest. 432remove= have no such limit and are yes on the web like the rest.
432 433
434Replying to issue and merge request mail posts a comment (#295) when
435the instance reads a reply mailbox (=[mail.inbound]=) and the account
436ran =notifications settings reply on=. The account page shows the
437switch only on such an instance. The reply is itself a fourth surface
438for =issue comment= and =mr comment= and nothing else: it is dispatched
439as that command, so it cannot do what the command would refuse.
440=admin mail inbound check= has no page, like the rest of instance
441administration.
442
433A login link is requested from the login page by username or verified 443A login link is requested from the login page by username or verified
434address, and arrives by mail: it works once and expires in fifteen 444address, and arrives by mail: it works once and expires in fifteen
435minutes. The row is =n/a= for the CLI because a terminal with a 445minutes. The row is =n/a= for the CLI because a terminal with a
@@ -474,6 +484,7 @@ when there is none.
474| rebuild a symbol index | yes | no | no | 484| rebuild a symbol index | yes | no | no |
475| audit log | yes | no | no | 485| audit log | yes | no | no |
476| instance statistics | yes | no | no | 486| instance statistics | yes | no | no |
487| inbound mail check | yes | no | no |
477 488
478=/admin/users= carries the account list with the command's state 489=/admin/users= carries the account list with the command's state
479filter and cursor, and promote, demote, disable and enable per row; 490filter and cursor, and promote, demote, disable and enable per row;
.gitbay/wiki/Threat-Model.org +63
@@ -78,6 +78,10 @@ anonymous client has a fuzz target and must never panic:
78- =internal/gitd= — the =git://= pkt-line reader. 78- =internal/gitd= — the =git://= pkt-line reader.
79- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob 79- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
80 parser, and the OpenPGP armored-key reader. 80 parser, and the OpenPGP armored-key reader.
81- =internal/mailin= — an inbound reply's headers, reply token, MIME
82 body and quote stripping, where anyone who can send mail to the
83 reply mailbox chooses the bytes.
84- =internal/imapc= — the IMAP response reader, literals included.
81 85
82Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=. 86Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
83 87
@@ -90,6 +94,65 @@ itself is never compared in Go, so there is no timing oracle to exploit.
90Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies 94Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
91no inbound HMAC. 95no inbound HMAC.
92 96
97* Reply by mail
98
99When =[mail.inbound]= is on (#295), anyone can send mail to the reply
100mailbox, and a posted reply is a comment written as an account. Two
101things are required together, because neither is enough alone:
102
103- *The reply token.* Each Reply-To is =reply+<token>@<domain>=; the
104 token names the recipient, the repository, the issue or merge
105 request, and an expiry thirty days out, under an HMAC-SHA256
106 truncated to 96 bits. Its key is derived from the secret key file
107 (=seal.Keyring.Derive=), which lives outside =server.root= and out of
108 backups; no row is stored per message. Verification is
109 =hmac.Equal=, against every key in the file so a rotation does not
110 break mail already sent, and only the canonical encoding is
111 accepted. A token is per recipient: it is not a credential for
112 anyone else's account or any other thread.
113- *The sender address.* =From= must be one of the token's account's
114 verified addresses. A leaked token (a forwarded notification, a
115 mailing-list archive, a shared inbox) is not enough to post without
116 also sending as that person. =From= is only what the sender wrote
117 unless the mail host vouches for it. With =[mail.inbound]
118 trusted_authserv_id= set, gitbay reads the topmost
119 =Authentication-Results= header carrying that id and requires DMARC
120 pass for the From domain or a DKIM pass whose =header.d= has the same
121 organizational domain (public suffix list); a forged header lower
122 down, claiming the same id, is ignored. Quoted strings and comments
123 are tokenized as RFC 8601 defines them, so sender-controlled text the
124 mail host echoes into its header (a quoted MAIL FROM local part, a
125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on
126 the mail host removing incoming headers that claim its id (RFC 8601
127 §5); Gmail, Fastmail and Migadu do. Unset, the daemon warns at start,
128 and a leaked token plus a forged From posts. The Admin page calls it
129 required for any exposed instance.
130- *Reused ids.* Account and repository ids are reused after a hard
131 delete. A reply is refused when the account or repository was
132 created after its token was minted, so a token cannot post into a
133 later repository, or as a later account, that took the id.
134
135Access is judged when the reply is read, not when the mail was sent:
136the reply is posted by dispatching =issue comment= or =mr comment= as
137the account, so a revoked grant, a private repository, an archived
138repository, a disabled or pending account, or reply by mail turned
139off all refuse it. A =Message-ID= that already posted to a thread as an
140account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are
141refused so an out-of-office responder cannot post.
142
143Refusals send nothing back: no bounce, no error mail, so the mailbox
144cannot be used to make the instance mail a forged sender
145(backscatter). Each refusal is an audit row, =refused mail reply=, with
146the reason and the =Message-ID= and none of the message's content,
147bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS
148with certificate verification; there is no plaintext setting. The
149mailbox password is read from a 0600 file and never logged. The client
150bounds what the server can make it hold: 10 MiB a message (refused by
151size before fetching), about 11 MiB and a thousand responses a
152command, after which the connection is closed; literals other than
153the message body are read and discarded. The Reply-To address is
154blanked from the mail queue once the mail is sent or dead-lettered.
155
93* Network-facing request forgery 156* Network-facing request forgery
94 157
95Webhook delivery, GitHub-history import =--api-base=, mirror remotes 158Webhook delivery, GitHub-history import =--api-base=, mirror remotes
.gitbay/wiki/Users.org +16
@@ -934,6 +934,22 @@ someone else.
934You are never mailed about your own actions, and only verified primary 934You are never mailed about your own actions, and only verified primary
935addresses receive anything. Delivery retries on relay failure. 935addresses receive anything. Delivery retries on relay failure.
936 936
937=notifications settings reply on= lets you answer that mail: issue and
938merge request mail then carries a =Reply-To= address, and replying to
939it posts your reply as a comment on the thread, as you. Off by
940default; the account page has the switch when the instance reads
941replies, and turning it on is refused where it does not
942(=[mail.inbound]= off). A reply is posted only when it comes from one
943of your verified addresses, you can still comment on the thread, and
944the mail it answers is less than thirty days old. Quoted text (lines
945starting with =>=, the "On … wrote:" line and what follows it, the
946header block Outlook quotes) and everything after a =-- = signature
947line are removed, and the rest is stored as markdown. HTML-only mail is
948not accepted; send plain text or the usual plain-and-HTML pair. A
949refused reply gets no answer: nothing is mailed back. Each reply
950address names you, so do not forward notification mail you would not
951want answered from your own address.
952
937Push is the same activity again, delivered to a phone: the iOS app 953Push is the same activity again, delivered to a phone: the iOS app
938registers a device, and =notifications settings push off= silences it 954registers a device, and =notifications settings push off= silences it
939the way =mail off= silences mail, without deregistering anything. 955the way =mail off= silences mail, without deregistering anything.
CHANGELOG.org +19
@@ -16,6 +16,25 @@ anything beyond "replace the binary and restart" is needed.
16 field passed on stdin and never shown again), rename, transfer and 16 field passed on stdin and never shown again), rename, transfer and
17 delete with typed confirmation. =/new= gains an import form for 17 delete with typed confirmation. =/new= gains an import form for
18 =repo import= (#296). 18 =repo import= (#296).
19- Reply to notification mail to comment. With =[mail.inbound]=
20 configured (an IMAP mailbox over TLS or STARTTLS, polled; password
21 from =password_file=) and =notifications settings reply on= (per
22 account, off by default; also on the account page), issue and merge
23 request mail carries =Reply-To: reply+<token>@<domain>=. The token
24 names the recipient, repository and thread, expires after thirty
25 days, and is an HMAC under a key derived from the secret key file. A
26 reply is posted as =issue comment= or =mr comment= by that account
27 when it comes from one of the account's verified addresses and the
28 account may still comment; quoted text and signatures are removed,
29 HTML-only mail and automatic replies are refused, and a
30 =Message-ID= posts once. Refusals mail nothing back and are audited
31 as =refused mail reply= with the reason. =admin mail inbound check=
32 tests the mailbox read-only. =trusted_authserv_id= makes a reply
33 also need the mail host's DMARC pass or aligned DKIM pass in its
34 topmost =Authentication-Results= header; unset, the daemon warns.
35 A reply is refused when its account or repository was created after
36 the token (a reused id). A migration adds =users.notify_reply=,
37 =notifications.reply_to= and =mail_replies=. (#295)
19- =web diff set unified|split= and a Diff layout control on the account 38- =web diff set unified|split= and a Diff layout control on the account
20 page choose how the merge request, commit and compare pages draw a 39 page choose how the merge request, commit and compare pages draw a
21 diff; =?layout=split|unified= overrides it per request. The split 40 diff; =?layout=split|unified= overrides it per request. The split
cmd/gitbay/main.go +6
@@ -74,6 +74,7 @@ func newRoot() *cobra.Command {
74 group("settings", "notification preferences", 74 group("settings", "notification preferences",
75 pass("show", passOpts{server: []string{"notifications", "settings", "show"}}), 75 pass("show", passOpts{server: []string{"notifications", "settings", "show"}}),
76 pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}), 76 pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}),
77 pass("reply", passOpts{server: []string{"notifications", "settings", "reply"}}),
77 pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}), 78 pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}),
78 pass("push", passOpts{server: []string{"notifications", "settings", "push"}}), 79 pass("push", passOpts{server: []string{"notifications", "settings", "push"}}),
79 ), 80 ),
@@ -149,6 +150,11 @@ func newRoot() *cobra.Command {
149 group("symbols", "symbol indexes", 150 group("symbols", "symbol indexes",
150 pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}), 151 pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}),
151 ), 152 ),
153 group("mail", "the instance's mail",
154 group("inbound", "the mailbox replies to notification mail arrive in",
155 pass("check", passOpts{server: []string{"admin", "mail", "inbound", "check"}}),
156 ),
157 ),
152 ), 158 ),
153 manCmd(root), 159 manCmd(root),
154 ) 160 )
cmd/gitbay/summaries_gen.go +2
@@ -7,6 +7,7 @@ var summaries = map[string]string{
7 "account import-bundle": "replay an account bundle (see gitbay migrate)", 7 "account import-bundle": "replay an account bundle (see gitbay migrate)",
8 "admin email verify": "mark an address verified by admin assertion", 8 "admin email verify": "mark an address verified by admin assertion",
9 "admin invite": "issue a registration invite and mail its code", 9 "admin invite": "issue a registration invite and mail its code",
10 "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting",
10 "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", 11 "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
11 "admin repo archive": "archive any repository (instance admins; audited)", 12 "admin repo archive": "archive any repository (instance admins; audited)",
12 "admin repo delete": "delete any repository (instance admins; audited)", 13 "admin repo delete": "delete any repository (instance admins; audited)",
@@ -96,6 +97,7 @@ var summaries = map[string]string{
96 "notifications read": "mark notifications read", 97 "notifications read": "mark notifications read",
97 "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)", 98 "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)",
98 "notifications settings push": "activity on your registered devices as well as the inbox", 99 "notifications settings push": "activity on your registered devices as well as the inbox",
100 "notifications settings reply": "reply to issue and merge request mail to comment",
99 "notifications settings show": "your notification preferences", 101 "notifications settings show": "your notification preferences",
100 "notifications settings watch": "every issue and merge request on repositories you can write to", 102 "notifications settings watch": "every issue and merge request on repositories you can write to",
101 "org create": "create an organization (you become its first admin)", 103 "org create": "create an organization (you become its first admin)",
cmd/gitbayd/main.go +12
@@ -30,6 +30,7 @@ import (
30 "gitbay.org/gitbay/internal/gitd" 30 "gitbay.org/gitbay/internal/gitd"
31 "gitbay.org/gitbay/internal/hookd" 31 "gitbay.org/gitbay/internal/hookd"
32 "gitbay.org/gitbay/internal/httpd" 32 "gitbay.org/gitbay/internal/httpd"
33 "gitbay.org/gitbay/internal/mailin"
33 "gitbay.org/gitbay/internal/mirror" 34 "gitbay.org/gitbay/internal/mirror"
34 "gitbay.org/gitbay/internal/notify" 35 "gitbay.org/gitbay/internal/notify"
35 "gitbay.org/gitbay/internal/packlimit" 36 "gitbay.org/gitbay/internal/packlimit"
@@ -206,6 +207,17 @@ func serveCmd() *cobra.Command {
206 if cfg.Mail.SMTPHost != "" { 207 if cfg.Mail.SMTPHost != "" {
207 go notify.New(st, cfg, retryBase).Run(whCtx) 208 go notify.New(st, cfg, retryBase).Run(whCtx)
208 } 209 }
210 if in := cfg.Mail.Inbound; in.Enabled {
211 // An unreadable password file is a misconfiguration:
212 // refuse to start rather than poll and fail every tick.
213 if _, err := in.Password(); err != nil {
214 return err
215 }
216 if in.TrustedAuthservID == "" {
217 slog.Warn("mail reply: [mail.inbound] trusted_authserv_id is unset, so a reply's From is not checked against the mail host's DMARC and DKIM results; set it on any instance reachable from the internet")
218 }
219 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
220 }
209 if cfg.Push.Enabled { 221 if cfg.Push.Enabled {
210 p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase) 222 p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase)
211 if err != nil { 223 if err != nil {
deploy/audit.sh +3
@@ -16,5 +16,8 @@ go test -run xxx -fuzz FuzzParseCommit -fuzztime 10s ./internal/sig/
16go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ 16go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/
17go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ 17go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/
18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ 18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/
19go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/
20go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/
21go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/
19 22
20echo "== all clear ==" 23echo "== all clear =="
e2e/readonly_test.go +1
@@ -110,6 +110,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
110 "admin runners": {}, 110 "admin runners": {},
111 "admin repo list": {}, 111 "admin repo list": {},
112 "admin stats": {}, 112 "admin stats": {},
113 "admin mail inbound check": {},
113 "admin user show": {"alice"}, 114 "admin user show": {"alice"},
114 "profile show": {"alice"}, 115 "profile show": {"alice"},
115 "org show": {"theorg"}, 116 "org show": {"theorg"},
internal/config/config.go +124
@@ -7,6 +7,7 @@ import (
7 "encoding/pem" 7 "encoding/pem"
8 "errors" 8 "errors"
9 "fmt" 9 "fmt"
10 "io"
10 "math" 11 "math"
11 "net" 12 "net"
12 "os" 13 "os"
@@ -273,6 +274,125 @@ type Mail struct {
273 // TLS is "starttls" (the default, also when empty) or "implicit": 274 // TLS is "starttls" (the default, also when empty) or "implicit":
274 // TLS from the first byte, as relays on port 465 expect. 275 // TLS from the first byte, as relays on port 465 expect.
275 TLS string `toml:"tls,omitempty"` 276 TLS string `toml:"tls,omitempty"`
277 // Inbound polls a mailbox for replies to notification mail (#295).
278 Inbound MailInbound `toml:"inbound"`
279}
280
281// MailInbound is the IMAP mailbox replies to notification mail arrive
282// in. Off unless enabled. The connection is always encrypted; there is
283// no setting for plaintext.
284type MailInbound struct {
285 Enabled bool `toml:"enabled"`
286 // IMAPHost is host:port; the port defaults to 993 with tls =
287 // "implicit" (the default) and 143 with tls = "starttls".
288 IMAPHost string `toml:"imap_host"`
289 TLS string `toml:"tls"`
290 User string `toml:"user"`
291 // PasswordFile holds the mailbox password, read at each connection.
292 // Never inline in this file.
293 PasswordFile string `toml:"password_file"`
294 Mailbox string `toml:"mailbox"` // default INBOX
295 PollInterval string `toml:"poll_interval"` // default 1m
296 // ReplyAddress is the address a notification's Reply-To is built
297 // from: reply@example.org becomes reply+<token>@example.org, so the
298 // mailbox must receive plus-addressed mail for it (or a catch-all).
299 ReplyAddress string `toml:"reply_address"`
300 // TrustedAuthservID is the authserv-id the mail host writes in its
301 // Authentication-Results header. When set, a reply must carry DMARC
302 // pass, or an aligned DKIM pass, in the topmost such header. Only
303 // safe when the mail host removes incoming headers claiming its id.
304 TrustedAuthservID string `toml:"trusted_authserv_id"`
305}
306
307// DefaultInboundPoll is the poll interval when poll_interval is unset.
308const DefaultInboundPoll = time.Minute
309
310// Poll is the configured poll interval.
311func (m MailInbound) Poll() time.Duration {
312 if d, err := time.ParseDuration(m.PollInterval); err == nil && d > 0 {
313 return d
314 }
315 return DefaultInboundPoll
316}
317
318// Addr is IMAPHost with the default port filled in.
319func (m MailInbound) Addr() string {
320 if _, _, err := net.SplitHostPort(m.IMAPHost); err == nil {
321 return m.IMAPHost
322 }
323 if m.TLS == "starttls" {
324 return net.JoinHostPort(m.IMAPHost, "143")
325 }
326 return net.JoinHostPort(m.IMAPHost, "993")
327}
328
329// MailboxName is Mailbox, INBOX when unset.
330func (m MailInbound) MailboxName() string {
331 if m.Mailbox == "" {
332 return "INBOX"
333 }
334 return m.Mailbox
335}
336
337// Password reads PasswordFile: its first line, which must be all it
338// holds. The file must be readable by its owner alone.
339func (m MailInbound) Password() (string, error) {
340 f, err := os.Open(m.PasswordFile)
341 if err != nil {
342 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
343 }
344 defer f.Close()
345 fi, err := f.Stat()
346 if err != nil {
347 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
348 }
349 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
350 return "", fmt.Errorf("mail.inbound.password_file %s is mode %04o; it must be readable by its owner alone (0600)", m.PasswordFile, perm)
351 }
352 raw, err := io.ReadAll(io.LimitReader(f, 4097))
353 if err != nil {
354 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
355 }
356 pass := strings.TrimRight(string(raw), "\r\n")
357 if pass == "" || len(raw) > 4096 || strings.ContainsAny(pass, "\r\n") {
358 return "", fmt.Errorf("mail.inbound.password_file %s must hold the password on one line", m.PasswordFile)
359 }
360 return pass, nil
361}
362
363func (m MailInbound) validate() []error {
364 if !m.Enabled {
365 return nil
366 }
367 var errs []error
368 for _, f := range []struct{ name, val string }{
369 {"mail.inbound.imap_host", m.IMAPHost},
370 {"mail.inbound.user", m.User},
371 {"mail.inbound.password_file", m.PasswordFile},
372 {"mail.inbound.reply_address", m.ReplyAddress},
373 } {
374 if f.val == "" {
375 errs = append(errs, fmt.Errorf("%s is required when mail.inbound.enabled", f.name))
376 }
377 }
378 if t := m.TLS; t != "" && t != "implicit" && t != "starttls" {
379 errs = append(errs, fmt.Errorf("mail.inbound.tls must be implicit or starttls, got %q: IMAP in clear is not supported", t))
380 }
381 if m.PollInterval != "" {
382 if d, err := time.ParseDuration(m.PollInterval); err != nil || d < 10*time.Second {
383 errs = append(errs, fmt.Errorf("mail.inbound.poll_interval %q must be a duration of at least 10s", m.PollInterval))
384 }
385 }
386 if id := m.TrustedAuthservID; id != "" && strings.ContainsAny(id, " \t;()\"\r\n") {
387 errs = append(errs, fmt.Errorf("mail.inbound.trusted_authserv_id %q must be a bare host name such as mx.google.com", id))
388 }
389 if a := m.ReplyAddress; a != "" {
390 local, domain, ok := strings.Cut(a, "@")
391 if !ok || local == "" || domain == "" || strings.ContainsAny(a, "+ <>\"\r\n") || strings.Contains(domain, "@") {
392 errs = append(errs, fmt.Errorf("mail.inbound.reply_address %q must be a bare address such as reply@example.org, with no + in it", a))
393 }
394 }
395 return errs
276} 396}
277 397
278// TLSRequired reports whether mail must not go to the relay in clear. 398// TLSRequired reports whether mail must not go to the relay in clear.
@@ -563,6 +683,10 @@ func (c Config) Validate() error {
563 if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" { 683 if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" {
564 errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t)) 684 errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t))
565 } 685 }
686 errs = append(errs, c.Mail.Inbound.validate()...)
687 if c.Mail.Inbound.Enabled && c.Mail.SMTPHost == "" {
688 errs = append(errs, errors.New("mail.inbound.enabled requires [mail] smtp_host: replies answer notification mail, which is not sent without SMTP"))
689 }
566 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { 690 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
567 errs = append(errs, fmt.Errorf( 691 errs = append(errs, fmt.Errorf(
568 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", 692 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
internal/config/config_test.go +53
@@ -411,3 +411,56 @@ func TestBackupRecipients(t *testing.T) {
411 t.Fatalf("default: %v, %v", cfg.Backup, err) 411 t.Fatalf("default: %v, %v", cfg.Backup, err)
412 } 412 }
413} 413}
414
415func TestMailInbound(t *testing.T) {
416 const smtp = "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n"
417 const inbound = "[mail.inbound]\nenabled = true\nimap_host = \"imap.example\"\nuser = \"reply@example\"\npassword_file = \"/etc/gitbay/imap.pass\"\nreply_address = \"reply@gitbay.example\"\n"
418 cfg, err := Load(writeConfig(t, minimal+smtp+inbound))
419 if err != nil {
420 t.Fatal(err)
421 }
422 in := cfg.Mail.Inbound
423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
425 }
426 in.TLS = "starttls"
427 if in.Addr() != "imap.example:143" {
428 t.Fatalf("starttls default port: %q", in.Addr())
429 }
430 for body, want := range map[string]string{
431 minimal + inbound: "requires [mail] smtp_host",
432 minimal + smtp + inbound + "tls = \"none\"\n": "IMAP in clear is not supported",
433 minimal + smtp + inbound + "poll_interval = \"1s\"\n": "poll_interval",
434 minimal + smtp + "[mail.inbound]\nenabled = true\n": "mail.inbound.password_file is required",
435 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "reply+x@gitbay.example", 1): "no + in it",
436 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "gitbay.example", 1): "bare address",
437 minimal + smtp + inbound + "trusted_authserv_id = \"mx; x\"\n": "trusted_authserv_id",
438 minimal + smtp + inbound + "password = \"x\"\n": "unknown config key",
439 } {
440 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
441 t.Errorf("want %q, got %v\n%s", want, err, body)
442 }
443 }
444 // Off, nothing is required.
445 if _, err := Load(writeConfig(t, minimal+"\n[mail.inbound]\nenabled = false\n")); err != nil {
446 t.Fatal(err)
447 }
448}
449
450func TestMailInboundPassword(t *testing.T) {
451 dir := t.TempDir()
452 in := MailInbound{PasswordFile: dir + "/pass"}
453 os.WriteFile(in.PasswordFile, []byte("hunter2\n"), 0o600)
454 if p, err := in.Password(); err != nil || p != "hunter2" {
455 t.Fatalf("Password = %q, %v", p, err)
456 }
457 os.Chmod(in.PasswordFile, 0o644)
458 if _, err := in.Password(); err == nil || strings.Contains(err.Error(), "hunter2") {
459 t.Fatalf("group-readable file: %v", err)
460 }
461 os.WriteFile(in.PasswordFile, []byte("a\nb\n"), 0o600)
462 os.Chmod(in.PasswordFile, 0o600)
463 if _, err := in.Password(); err == nil {
464 t.Fatal("two lines accepted")
465 }
466}
internal/control/adminmail.go added +68
@@ -0,0 +1,68 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "time"
7
8 "gitbay.org/gitbay/internal/imapc"
9 "gitbay.org/gitbay/internal/protocol"
10)
11
12func init() {
13 register(Command{Path: []string{"admin", "mail", "inbound", "check"},
14 Summary: "connect to the reply mailbox read-only and report what is waiting",
15 Usage: "admin mail inbound check",
16 Examples: []string{"admin mail inbound check"},
17 ReadOnly: true, Run: runAdminMailInboundCheck})
18}
19
20const unauthenticatedWarning = "trusted_authserv_id is unset: a reply's From is not checked against the mail host's DMARC and DKIM results"
21
22// runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and
23// opens it with EXAMINE, which changes no flag, so a check never marks a
24// reply seen before the poller reads it.
25func runAdminMailInboundCheck(c *Ctx, args []string) int {
26 if code := requireInstanceAdmin(c); code >= 0 {
27 return code
28 }
29 if len(args) != 0 {
30 return c.usage()
31 }
32 in := c.Cfg.Mail.Inbound
33 type out struct {
34 Enabled bool `json:"enabled"`
35 Server string `json:"server,omitempty"`
36 Mailbox string `json:"mailbox,omitempty"`
37 Messages int `json:"messages"`
38 Unseen int `json:"unseen"`
39 Warning string `json:"warning,omitempty"`
40 }
41 if !in.Enabled {
42 return c.emit(out{}, func(w io.Writer) {
43 fmt.Fprintln(w, "inbound mail is off ([mail.inbound] enabled = false)")
44 })
45 }
46 cl, n, err := imapc.Open(in, true, 30*time.Second)
47 if err != nil {
48 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
49 }
50 defer cl.Close()
51 unseen, err := cl.Unseen()
52 if err != nil {
53 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
54 }
55 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen)}
56 if in.TrustedAuthservID == "" {
57 d.Warning = unauthenticatedWarning
58 fmt.Fprintln(c.Stderr, "warning: "+d.Warning)
59 }
60 return c.emit(d, func(w io.Writer) {
61 c.view(w).fields(
62 "server", d.Server,
63 "mailbox", d.Mailbox,
64 "messages", fmt.Sprintf("%d", d.Messages),
65 "unseen", fmt.Sprintf("%d", d.Unseen),
66 )
67 })
68}
internal/control/control.go +4
@@ -73,6 +73,10 @@ type Ctx struct {
73// User.SignedInAt is when that session signed in. 73// User.SignedInAt is when that session signed in.
74const SourceWeb = "web" 74const SourceWeb = "web"
75 75
76// SourceMail is Ctx.Source for a comment posted by replying to
77// notification mail (#295).
78const SourceMail = "mail"
79
76// ReauthWindow is how long after signing in a browser session may run a 80// ReauthWindow is how long after signing in a browser session may run a
77// NeedsRecentSignIn command. A session lasts days and its cookie is a 81// NeedsRecentSignIn command. A session lasts days and its cookie is a
78// bearer credential; what it creates or grants must come from a recent 82// bearer credential; what it creates or grants must come from a recent
internal/control/diffcomment.go +1 −1
@@ -160,7 +160,7 @@ func runDiffComment(c *Ctx, args []string) int {
160 // not reach anyone's inbox. `mr review` is what says it out loud. 160 // not reach anyone's inbox. `mr review` is what says it out loud.
161 if !pending { 161 if !pending {
162 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 162 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
163 notify(c, parts, notice{repo: repo, kind: "mr", 163 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
164 subject: mrSubject(repo, mr.Number, mr.Title), 164 subject: mrSubject(repo, mr.Number, mr.Title),
165 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number), 165 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
166 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 166 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
internal/control/issue.go +7 −4
@@ -14,6 +14,9 @@ import (
14 14
15const maxBodyBytes = 64 << 10 15const maxBodyBytes = 64 << 10
16 16
17// MaxCommentBytes is the most of a comment body a command reads.
18const MaxCommentBytes = maxBodyBytes
19
17func init() { 20func init() {
18 register(Command{Path: []string{"issue", "create"}, 21 register(Command{Path: []string{"issue", "create"},
19 Summary: "open an issue", 22 Summary: "open an issue",
@@ -250,7 +253,7 @@ func runIssueCreate(c *Ctx, args []string) int {
250 } 253 }
251 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) 254 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
252 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 255 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
253 notify(c, targets, notice{repo: repo, kind: "issue", 256 notify(c, targets, notice{repo: repo, kind: "issue", number: n,
254 subject: issueSubject(repo, n, title), 257 subject: issueSubject(repo, n, title),
255 action: fmt.Sprintf("opened issue #%d", n), 258 action: fmt.Sprintf("opened issue #%d", n),
256 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) 259 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
@@ -429,7 +432,7 @@ func setIssueState(c *Ctx, args []string, state string) int {
429 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) 432 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
430 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { 433 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
431 verb := map[string]string{"open": "reopened", "closed": "closed"}[state] 434 verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
432 notify(c, parts, notice{repo: repo, kind: "issue", 435 notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
433 subject: issueSubject(repo, issue.Number, issue.Title), 436 subject: issueSubject(repo, issue.Number, issue.Title),
434 action: fmt.Sprintf("%s #%d", verb, issue.Number), 437 action: fmt.Sprintf("%s #%d", verb, issue.Number),
435 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) 438 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
@@ -509,7 +512,7 @@ func runIssueEdit(c *Ctx, args []string) int {
509 } 512 }
510 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number)) 513 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
511 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { 514 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
512 notify(c, parts, notice{repo: repo, kind: "issue", 515 notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
513 subject: issueSubject(repo, issue.Number, issue.Title), 516 subject: issueSubject(repo, issue.Number, issue.Title),
514 action: fmt.Sprintf("edited #%d", issue.Number), 517 action: fmt.Sprintf("edited #%d", issue.Number),
515 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) 518 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
@@ -674,7 +677,7 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto
674 // and widening it to watchers would tell them "assigned you". 677 // and widening it to watchers would tell them "assigned you".
675 // Removals file nothing, and notify drops the actor, so assigning 678 // Removals file nothing, and notify drops the actor, so assigning
676 // yourself is silent. 679 // yourself is silent.
677 notify(c, added, notice{repo: repo, kind: "issue", direct: true, 680 notify(c, added, notice{repo: repo, kind: "issue", number: issue.Number, direct: true,
678 subject: issueSubject(repo, issue.Number, issue.Title), 681 subject: issueSubject(repo, issue.Number, issue.Title),
679 action: fmt.Sprintf("assigned you to #%d", issue.Number), 682 action: fmt.Sprintf("assigned you to #%d", issue.Number),
680 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) 683 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
internal/control/mr.go +8 −8
@@ -518,7 +518,7 @@ func runMRCreate(c *Ctx, args []string) int {
518 } 518 }
519 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) 519 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
520 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 520 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
521 notify(c, targets, notice{repo: repo, kind: "mr", 521 notify(c, targets, notice{repo: repo, kind: "mr", number: n,
522 subject: mrSubject(repo, n, title), 522 subject: mrSubject(repo, n, title),
523 action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), 523 action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target),
524 excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)}) 524 excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)})
@@ -1081,7 +1081,7 @@ func runMRRetarget(c *Ctx, args []string) int {
1081 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", 1081 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
1082 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) 1082 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
1083 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1083 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1084 notify(c, parts, notice{repo: repo, kind: "mr", 1084 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1085 subject: mrSubject(repo, mr.Number, mr.Title), 1085 subject: mrSubject(repo, mr.Number, mr.Title),
1086 action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), 1086 action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target),
1087 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1087 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1157,7 +1157,7 @@ func runMRReview(c *Ctx, args []string) int {
1157 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) 1157 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1158 TryQueuedMerge(c.Store, c.Cfg, mr.ID) 1158 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1159 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1159 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1160 notify(c, parts, notice{repo: repo, kind: "mr", 1160 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1161 subject: mrSubject(repo, mr.Number, mr.Title), 1161 subject: mrSubject(repo, mr.Number, mr.Title),
1162 action: reviewAction(mr.Number, verdict, published), 1162 action: reviewAction(mr.Number, verdict, published),
1163 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1163 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1217,7 +1217,7 @@ func runMRReviewRequest(c *Ctx, args []string) int {
1217 for i, u := range added { 1217 for i, u := range added {
1218 ids[i] = u.ID 1218 ids[i] = u.ID
1219 } 1219 }
1220 notify(c, ids, notice{repo: repo, kind: "mr", 1220 notify(c, ids, notice{repo: repo, kind: "mr", number: mr.Number,
1221 subject: mrSubject(repo, mr.Number, mr.Title), 1221 subject: mrSubject(repo, mr.Number, mr.Title),
1222 action: fmt.Sprintf("asked for a review on !%d", mr.Number), 1222 action: fmt.Sprintf("asked for a review on !%d", mr.Number),
1223 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1223 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1597,7 +1597,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
1597 } 1597 }
1598 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number)) 1598 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
1599 if parts, err := c.Store.MRParticipants(k.ID); err == nil { 1599 if parts, err := c.Store.MRParticipants(k.ID); err == nil {
1600 notify(c, parts, notice{repo: repo, kind: "mr", 1600 notify(c, parts, notice{repo: repo, kind: "mr", number: k.Number,
1601 subject: mrSubject(repo, k.Number, k.Title), 1601 subject: mrSubject(repo, k.Number, k.Title),
1602 action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), 1602 action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number),
1603 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)}) 1603 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)})
@@ -1623,7 +1623,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
1623 repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now()) 1623 repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now())
1624 c.Store.MarkMirrorsDirty(repo.ID, "push") 1624 c.Store.MarkMirrorsDirty(repo.ID, "push")
1625 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1625 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1626 notify(c, parts, notice{repo: repo, kind: "mr", 1626 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1627 subject: mrSubject(repo, mr.Number, mr.Title), 1627 subject: mrSubject(repo, mr.Number, mr.Title),
1628 action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), 1628 action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy),
1629 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1629 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1907,7 +1907,7 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
1907 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 1907 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
1908 parts, _ := c.Store.MRParticipants(mr.ID) 1908 parts, _ := c.Store.MRParticipants(mr.ID)
1909 reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID) 1909 reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID)
1910 notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", 1910 notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", number: mr.Number,
1911 subject: mrSubject(repo, mr.Number, mr.Title), 1911 subject: mrSubject(repo, mr.Number, mr.Title),
1912 action: fmt.Sprintf("marked !%d ready for review", mr.Number), 1912 action: fmt.Sprintf("marked !%d ready for review", mr.Number),
1913 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1913 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1959,7 +1959,7 @@ func runMRClose(c *Ctx, args []string) int {
1959 } 1959 }
1960 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData) 1960 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData)
1961 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1961 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1962 notify(c, parts, notice{repo: repo, kind: "mr", 1962 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1963 subject: mrSubject(repo, mr.Number, mr.Title), 1963 subject: mrSubject(repo, mr.Number, mr.Title),
1964 action: fmt.Sprintf("closed !%d", mr.Number), 1964 action: fmt.Sprintf("closed !%d", mr.Number),
1965 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 1965 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
internal/control/notifications.go +66 −2
@@ -6,8 +6,10 @@ import (
6 "io" 6 "io"
7 "strconv" 7 "strconv"
8 "strings" 8 "strings"
9 "time"
9 10
10 "gitbay.org/gitbay/internal/autolink" 11 "gitbay.org/gitbay/internal/autolink"
12 "gitbay.org/gitbay/internal/mailreply"
11 "gitbay.org/gitbay/internal/policy" 13 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol" 14 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store" 15 "gitbay.org/gitbay/internal/store"
@@ -42,6 +44,11 @@ func init() {
42 Usage: "notifications settings mail on|off", 44 Usage: "notifications settings mail on|off",
43 Examples: []string{"notifications settings mail on"}, 45 Examples: []string{"notifications settings mail on"},
44 Run: runNotificationsSettingsMail}) 46 Run: runNotificationsSettingsMail})
47 register(Command{Path: []string{"notifications", "settings", "reply"},
48 Summary: "reply to issue and merge request mail to comment",
49 Usage: "notifications settings reply on|off",
50 Examples: []string{"notifications settings reply on"},
51 Run: runNotificationsSettingsReply})
45 register(Command{Path: []string{"notifications", "settings", "watch"}, 52 register(Command{Path: []string{"notifications", "settings", "watch"},
46 Summary: "every issue and merge request on repositories you can write to", 53 Summary: "every issue and merge request on repositories you can write to",
47 Usage: "notifications settings watch on|off", 54 Usage: "notifications settings watch on|off",
@@ -96,6 +103,7 @@ func init() {
96type notice struct { 103type notice struct {
97 repo store.Repo 104 repo store.Repo
98 kind string // issue, mr, or build 105 kind string // issue, mr, or build
106 number int64 // the issue or merge request; 0 for a build
99 subject string // mail subject 107 subject string // mail subject
100 action string // "opened issue #12" — also the inbox summary 108 action string // "opened issue #12" — also the inbox summary
101 excerpt string // quoted into the mail, not the inbox 109 excerpt string // quoted into the mail, not the inbox
@@ -136,10 +144,43 @@ func notify(c *Ctx, userIDs []int64, n notice) {
136 if err != nil || email == "" { 144 if err != nil || email == "" {
137 continue 145 continue
138 } 146 }
147 if replyTo := replyAddress(c, id, n); replyTo != "" {
148 c.Store.EnqueueMailReplyTo(email, replyTo, n.subject, body+replyFooter)
149 continue
150 }
139 c.Store.EnqueueMail(email, n.subject, body) 151 c.Store.EnqueueMail(email, n.subject, body)
140 } 152 }
141} 153}
142 154
155// replyFooter ends mail that carries a reply address.
156const replyFooter = "\nReply to this mail to comment. Replies are accepted from your verified addresses.\n"
157
158// replyAddress is the Reply-To for recipient's mail about n (#295): an
159// address carrying a token for the recipient and the thread, when the
160// instance polls for replies and the recipient turned replies on. ""
161// otherwise, or when no token can be minted.
162func replyAddress(c *Ctx, recipient int64, n notice) string {
163 in := c.Cfg.Mail.Inbound
164 keys := c.Store.Keyring()
165 if !in.Enabled || keys == nil || n.number == 0 || (n.kind != "issue" && n.kind != "mr") {
166 return ""
167 }
168 if on, err := c.Store.ReplyEnabled(recipient); err != nil || !on {
169 return ""
170 }
171 secrets, err := keys.Derive(mailreply.Purpose)
172 if err != nil {
173 return ""
174 }
175 tok, err := mailreply.Mint(secrets, mailreply.Target{
176 UserID: recipient, RepoID: n.repo.ID, Kind: n.kind, Number: n.number,
177 }, time.Now().Add(mailreply.Lifetime))
178 if err != nil {
179 return ""
180 }
181 return mailreply.Address(in.ReplyAddress, tok)
182}
183
143// notifyMentions files an inbox row for every account text mentions by 184// notifyMentions files an inbox row for every account text mentions by
144// @name that can read the repository, and records them as participants 185// @name that can read the repository, and records them as participants
145// of the thread so they hear what follows (#202). Mute is honoured by 186// of the thread so they hear what follows (#202). Mute is honoured by
@@ -170,7 +211,7 @@ func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, tit
170 return 211 return
171 } 212 }
172 c.Store.AddMentions(repo.ID, t.kind, itemID, ids) 213 c.Store.AddMentions(repo.ID, t.kind, itemID, ids)
173 notify(c, ids, notice{repo: repo, kind: t.kind, direct: true, 214 notify(c, ids, notice{repo: repo, kind: t.kind, number: number, direct: true,
174 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), 215 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
175 action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number), 216 action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
176 excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) 217 excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
@@ -223,7 +264,11 @@ func emitNotificationSettings(c *Ctx) int {
223 if err != nil { 264 if err != nil {
224 return c.fail(protocol.ExitFailure, "%v", err) 265 return c.fail(protocol.ExitFailure, "%v", err)
225 } 266 }
226 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) { 267 reply, err := c.Store.ReplyEnabled(c.User.ID)
268 if err != nil {
269 return c.fail(protocol.ExitFailure, "%v", err)
270 }
271 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) {
227 onOff := func(on bool) string { 272 onOff := func(on bool) string {
228 if on { 273 if on {
229 return "on" 274 return "on"
@@ -233,6 +278,7 @@ func emitNotificationSettings(c *Ctx) int {
233 v := c.view(w) 278 v := c.view(w)
234 v.fields( 279 v.fields(
235 "mail", onOff(mail), 280 "mail", onOff(mail),
281 "reply", onOff(reply),
236 "watch", onOff(watch), 282 "watch", onOff(watch),
237 "push", onOff(push), 283 "push", onOff(push),
238 ) 284 )
@@ -258,6 +304,24 @@ func runNotificationsSettingsMail(c *Ctx, args []string) int {
258 return emitNotificationSettings(c) 304 return emitNotificationSettings(c)
259} 305}
260 306
307// runNotificationsSettingsReply turns on a Reply-To on the account's
308// issue and merge request mail (#295). Turning it on is refused where
309// nothing reads the replies.
310func runNotificationsSettingsReply(c *Ctx, args []string) int {
311 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
312 return c.usage()
313 }
314 on := args[0] == "on"
315 if on && !c.Cfg.Mail.Inbound.Enabled {
316 return c.fail(protocol.ExitFailure,
317 "this instance does not read replies to its mail ([mail.inbound] enabled = false); ask an admin")
318 }
319 if err := c.Store.SetReplyEnabled(c.User.ID, on); err != nil {
320 return c.fail(protocol.ExitFailure, "%v", err)
321 }
322 return emitNotificationSettings(c)
323}
324
261// runNotificationsSettingsWatch is the default watch state for 325// runNotificationsSettingsWatch is the default watch state for
262// repositories the account can write to: consulted when a notice is 326// repositories the account can write to: consulted when a notice is
263// delivered, so a grant or a revoke needs no watch row of its own (#194). 327// delivered, so a grant or a revoke needs no watch row of its own (#194).
internal/control/notifications_test.go +91
@@ -5,9 +5,12 @@ import (
5 "fmt" 5 "fmt"
6 "strings" 6 "strings"
7 "testing" 7 "testing"
8 "time"
8 9
9 "gitbay.org/gitbay/internal/config" 10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/mailreply"
10 "gitbay.org/gitbay/internal/protocol" 12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/seal"
11 "gitbay.org/gitbay/internal/store" 14 "gitbay.org/gitbay/internal/store"
12) 15)
13 16
@@ -310,3 +313,91 @@ func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
310 t.Errorf("--all did not show the read notice: %q", out.String()) 313 t.Errorf("--all did not show the read notice: %q", out.String())
311 } 314 }
312} 315}
316
317// The Reply-To is on issue and merge request mail only when the instance
318// reads replies and the recipient turned them on (#295).
319func TestNotifyReplyTo(t *testing.T) {
320 key, err := seal.NewKey()
321 if err != nil {
322 t.Fatal(err)
323 }
324 keyFile := t.TempDir() + "/secret.key"
325 if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
326 t.Fatal(err)
327 }
328 ring, err := seal.Load(keyFile)
329 if err != nil {
330 t.Fatal(err)
331 }
332 for _, tc := range []struct {
333 name string
334 instance, user bool
335 kind string
336 number int64
337 want bool
338 }{
339 {"both", true, true, "issue", 1, true},
340 {"merge request", true, true, "mr", 1, true},
341 {"instance off", false, true, "issue", 1, false},
342 {"user off", true, false, "issue", 1, false},
343 {"build", true, true, "build", 0, false},
344 } {
345 t.Run(tc.name, func(t *testing.T) {
346 c, repo, bob := testRepoWithWatcher(t)
347 c.Store.SetKeyring(ring)
348 c.Cfg.Push.Enabled = false
349 c.Cfg.Mail.SMTPHost, c.Cfg.Mail.From = "mx.example", "gitbay@example.test"
350 c.Cfg.Mail.Inbound = config.MailInbound{Enabled: tc.instance, ReplyAddress: "reply@gitbay.example"}
351 if err := c.Store.AddEmail(bob, "bob@example.test", "admin", true); err != nil {
352 t.Fatal(err)
353 }
354 if err := c.Store.SetReplyEnabled(bob, tc.user); err != nil {
355 t.Fatal(err)
356 }
357 notify(c, []int64{bob}, notice{repo: repo, kind: tc.kind, number: tc.number,
358 subject: "s", action: "commented", path: "alice/app/issues/1"})
359 due, err := c.Store.DueMail(20)
360 if err != nil || len(due) != 1 {
361 t.Fatalf("DueMail = %v, %v", due, err)
362 }
363 got := due[0].ReplyTo
364 if !tc.want {
365 if got != "" || strings.Contains(due[0].Body, "Reply to this mail") {
366 t.Fatalf("Reply-To %q, body %q", got, due[0].Body)
367 }
368 return
369 }
370 tok, ok := mailreply.TokenFrom("reply@gitbay.example", got)
371 if !ok {
372 t.Fatalf("Reply-To %q", got)
373 }
374 secrets, _ := ring.Derive(mailreply.Purpose)
375 target, err := mailreply.Verify(secrets, tok, time.Now())
376 want := mailreply.Target{UserID: bob, RepoID: repo.ID, Kind: tc.kind, Number: 1, Expires: target.Expires}
377 if err != nil || target != want || time.Since(target.Issued()) > time.Minute {
378 t.Fatalf("token names %+v, %v; want %+v", target, err, want)
379 }
380 })
381 }
382}
383
384func TestNotificationsSettingsReply(t *testing.T) {
385 c := notifTestCtx(t, "alice")
386 if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitFailure {
387 t.Fatalf("on without [mail.inbound]: exit %d", code)
388 }
389 c.Cfg.Mail.Inbound.Enabled = true
390 if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitOK {
391 t.Fatalf("on: exit %d: %s", code, c.Stdout)
392 }
393 if on, _ := c.Store.ReplyEnabled(c.User.ID); !on {
394 t.Fatal("reply not on")
395 }
396 c.Cfg.Mail.Inbound.Enabled = false
397 if code := Dispatch(c, []string{"notifications", "settings", "reply", "off"}); code != protocol.ExitOK {
398 t.Fatalf("off: exit %d", code)
399 }
400 if on, _ := c.Store.ReplyEnabled(c.User.ID); on {
401 t.Fatal("reply still on")
402 }
403}
internal/control/thread.go +1 −1
@@ -110,7 +110,7 @@ func runComment(c *Ctx, args []string, t thread, noun string,
110 } 110 }
111 c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number)) 111 c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number))
112 if parts, err := participants(id); err == nil { 112 if parts, err := participants(id); err == nil {
113 notify(c, parts, notice{repo: repo, kind: t.kind, 113 notify(c, parts, notice{repo: repo, kind: t.kind, number: number,
114 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), 114 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
115 action: fmt.Sprintf("commented on %s%d", t.symbol, number), 115 action: fmt.Sprintf("commented on %s%d", t.symbol, number),
116 excerpt: body, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) 116 excerpt: body, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
internal/httpd/account.go +6 −2
@@ -95,6 +95,7 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
95 mailOn, _ := s.st.MailEnabled(u.ID) 95 mailOn, _ := s.st.MailEnabled(u.ID)
96 watchOn, _ := s.st.WatchEnabled(u.ID) 96 watchOn, _ := s.st.WatchEnabled(u.ID)
97 pushOn, _ := s.st.PushEnabled(u.ID) 97 pushOn, _ := s.st.PushEnabled(u.ID)
98 replyOn, _ := s.st.ReplyEnabled(u.ID)
98 theme, _ := s.st.Theme(u.ID) 99 theme, _ := s.st.Theme(u.ID)
99 diffPref, _ := s.st.DiffLayout(u.ID) 100 diffPref, _ := s.st.DiffLayout(u.ID)
100 101
@@ -148,6 +149,8 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
148 MailOn bool 149 MailOn bool
149 WatchOn bool 150 WatchOn bool
150 PushOn bool 151 PushOn bool
152 ReplyOn bool
153 ReplyOffered bool // the instance reads replies to its mail
151 Devices []accountDevice 154 Devices []accountDevice
152 ThemeSetting string // system, light or dark: the form's selected option 155 ThemeSetting string // system, light or dark: the form's selected option
153 DiffSetting string // unified or split: the form's selected option 156 DiffSetting string // unified or split: the form's selected option
@@ -156,7 +159,8 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
156 Reauth bool // Notice is the stale-session refusal: link to sign in 159 Reauth bool // Notice is the stale-session refusal: link to sign in
157 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), 160 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
158 aboutRepo, aboutEdit, s.cfg.SiteHost(), 161 aboutRepo, aboutEdit, s.cfg.SiteHost(),
159 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, diffPref, 162 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn,
163 replyOn, s.cfg.Mail.Inbound.Enabled, devices, theme, diffPref,
160 tokens, tokenShown, reauth}) 164 tokens, tokenShown, reauth})
161} 165}
162 166
@@ -350,7 +354,7 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
350 return 354 return
351 } 355 }
352 back("", "diff layout saved") 356 back("", "diff layout saved")
353 case "notify-mail", "notify-watch", "notify-push": 357 case "notify-mail", "notify-watch", "notify-push", "notify-reply":
354 pref := strings.TrimPrefix(r.FormValue("field"), "notify-") 358 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
355 state := "off" 359 state := "off"
356 if r.FormValue(pref) == "on" { 360 if r.FormValue(pref) == "on" {
internal/httpd/account_test.go +36
@@ -541,3 +541,39 @@ func TestNotificationsReadDispatches(t *testing.T) {
541 t.Fatalf("unread after all = %d, want 0", n) 541 t.Fatalf("unread after all = %d, want 0", n)
542 } 542 }
543} 543}
544
545// The reply toggle is offered only where the instance reads replies, and
546// posting it dispatches notifications settings reply (#295).
547func TestAccountNotifyReply(t *testing.T) {
548 st, err := store.Open(":memory:")
549 if err != nil {
550 t.Fatal(err)
551 }
552 defer st.Close()
553 if err := st.MigrateUp(); err != nil {
554 t.Fatal(err)
555 }
556 uid, err := st.CreateUser("alice", false)
557 if err != nil {
558 t.Fatal(err)
559 }
560 u := store.User{ID: uid, Username: "alice"}
561 page := func(s *Server) string {
562 rr := httptest.NewRecorder()
563 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
564 return rr.Body.String()
565 }
566 if strings.Contains(page(New(config.Default(), st, nil)), `value="notify-reply"`) {
567 t.Fatal("reply toggle offered without [mail.inbound]")
568 }
569 cfg := config.Default()
570 cfg.Mail.Inbound.Enabled = true
571 s := New(cfg, st, nil)
572 if !strings.Contains(page(s), `value="notify-reply"`) {
573 t.Fatal("no reply toggle")
574 }
575 submitAccountForm(t, s, u, url.Values{"field": {"notify-reply"}, "reply": {"on"}})
576 if on, err := st.ReplyEnabled(uid); err != nil || !on {
577 t.Fatalf("ReplyEnabled after notify-reply=on: %v %v", on, err)
578 }
579}
internal/imapc/fuzz_test.go added +25
@@ -0,0 +1,25 @@
1package imapc
2
3import (
4 "bufio"
5 "bytes"
6 "net"
7 "testing"
8)
9
10// FuzzReadResponse feeds server bytes to the response reader, literals
11// included.
12func FuzzReadResponse(f *testing.F) {
13 f.Add([]byte("* 1 FETCH (UID 3 BODY[] {5}\r\nhello)\r\ng1 OK done\r\n"))
14 f.Add([]byte("* SEARCH 1 2 3\r\n* OK {99999999999}\r\n"))
15 f.Fuzz(func(t *testing.T, in []byte) {
16 a, b := net.Pipe()
17 defer b.Close()
18 c := &Client{conn: a, r: bufio.NewReader(bytes.NewReader(in)), left: cmdBudget}
19 for i := 0; i < 8; i++ {
20 if _, err := c.readResponse(); err != nil {
21 return
22 }
23 }
24 })
25}
internal/imapc/imapc.go added +387
@@ -0,0 +1,387 @@
1// Package imapc is the IMAP4rev1 client the reply-by-mail poller needs
2// (#295): LOGIN, SELECT or EXAMINE, UID SEARCH UNSEEN, UID FETCH
3// BODY.PEEK[], UID STORE +FLAGS (\Seen), LOGOUT. Nothing else. The
4// connection is TLS from the first byte or upgraded with STARTTLS
5// before LOGIN; there is no plaintext mode.
6package imapc
7
8import (
9 "bufio"
10 "crypto/tls"
11 "crypto/x509"
12 "errors"
13 "fmt"
14 "io"
15 "net"
16 "strconv"
17 "strings"
18 "time"
19)
20
21// MaxMessage is the largest message Fetch returns; a larger one is
22// refused by its RFC822.SIZE before its body is fetched (ErrTooLarge).
23const MaxMessage = 10 << 20
24
25// Limits on what one command may make the client read. A server that
26// exceeds one has its connection closed and the command returns
27// ErrLimit.
28const (
29 cmdBudget = MaxMessage + 1<<20 // bytes read for one command
30 maxUntagged = 1000 // untagged responses to one command
31 maxLine = 1 << 20 // one response line outside literals
32)
33
34// MaxUnseen is the most UIDs Unseen returns; the rest wait for the next
35// poll.
36const MaxUnseen = 10000
37
38// RefusedError is the server answering a command NO or BAD, or
39// answering a FETCH with no message: a refusal of that command, with the
40// session still usable.
41type RefusedError struct{ Text string }
42
43func (e *RefusedError) Error() string { return e.Text }
44
45var (
46 ErrTooLarge = errors.New("message larger than the fetch limit")
47 ErrLimit = errors.New("IMAP server exceeded a response limit; connection closed")
48)
49
50// rootCAs verifies the server's certificate; nil is the system pool.
51// Tests set it.
52var rootCAs *x509.CertPool
53
54// Client is one authenticated IMAP session.
55type Client struct {
56 conn net.Conn
57 r *bufio.Reader
58 tag int
59 left int64 // bytes the current command may still read
60}
61
62// Dial connects to addr (host:port) and reads the greeting. With
63// starttls it upgrades the connection before returning; otherwise TLS
64// runs from the first byte.
65func Dial(addr string, starttls bool, timeout time.Duration) (*Client, error) {
66 host, _, err := net.SplitHostPort(addr)
67 if err != nil {
68 return nil, err
69 }
70 tlsCfg := &tls.Config{ServerName: host, RootCAs: rootCAs, MinVersion: tls.VersionTLS12}
71 d := &net.Dialer{Timeout: timeout}
72 var conn net.Conn
73 if starttls {
74 conn, err = d.Dial("tcp", addr)
75 } else {
76 conn, err = tls.DialWithDialer(d, "tcp", addr, tlsCfg)
77 }
78 if err != nil {
79 return nil, err
80 }
81 c := New(conn)
82 conn.SetDeadline(time.Now().Add(timeout))
83 if err := c.greeting(); err != nil {
84 conn.Close()
85 return nil, err
86 }
87 if starttls {
88 if _, err := c.cmd("STARTTLS"); err != nil {
89 conn.Close()
90 return nil, fmt.Errorf("STARTTLS: %w", err)
91 }
92 tc := tls.Client(conn, tlsCfg)
93 if err := tc.Handshake(); err != nil {
94 conn.Close()
95 return nil, fmt.Errorf("STARTTLS: %w", err)
96 }
97 c.conn, c.r = tc, bufio.NewReader(tc)
98 }
99 return c, nil
100}
101
102// New wraps a connection that is already past its TLS handshake, or a
103// test's pipe. The greeting has not been read.
104func New(conn net.Conn) *Client {
105 return &Client{conn: conn, r: bufio.NewReader(conn)}
106}
107
108// SetDeadline bounds the session's remaining I/O.
109func (c *Client) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) }
110
111func (c *Client) greeting() error {
112 c.left = cmdBudget
113 resp, err := c.readResponse()
114 line := resp.line
115 if err != nil {
116 return err
117 }
118 if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
119 return fmt.Errorf("unexpected greeting %q", clip(line))
120 }
121 return nil
122}
123
124// Login authenticates. The password goes as a quoted string, so it may
125// not hold a line break or a byte outside printable ASCII.
126func (c *Client) Login(user, pass string) error {
127 u, err := quote(user)
128 if err != nil {
129 return fmt.Errorf("user: %w", err)
130 }
131 p, err := quote(pass)
132 if err != nil {
133 return fmt.Errorf("password: %w", err)
134 }
135 if _, err := c.cmd("LOGIN " + u + " " + p); err != nil {
136 // The server's text is not echoed: some quote the command.
137 return errors.New("LOGIN refused")
138 }
139 return nil
140}
141
142// Select opens mailbox for reading and writing flags; Examine opens it
143// read-only. Both return the number of messages in it.
144func (c *Client) Select(mailbox string) (int, error) { return c.open("SELECT", mailbox) }
145func (c *Client) Examine(mailbox string) (int, error) { return c.open("EXAMINE", mailbox) }
146
147func (c *Client) open(verb, mailbox string) (int, error) {
148 m, err := quote(mailbox)
149 if err != nil {
150 return 0, err
151 }
152 untagged, err := c.cmd(verb + " " + m)
153 if err != nil {
154 return 0, fmt.Errorf("%s %s: %w", verb, mailbox, err)
155 }
156 exists := 0
157 for _, u := range untagged {
158 f := strings.Fields(u.line)
159 if len(f) >= 3 && strings.EqualFold(f[2], "EXISTS") {
160 exists, _ = strconv.Atoi(f[1])
161 }
162 }
163 return exists, nil
164}
165
166// Unseen returns the UIDs of messages without \Seen.
167func (c *Client) Unseen() ([]uint32, error) {
168 untagged, err := c.cmd("UID SEARCH UNSEEN")
169 if err != nil {
170 return nil, fmt.Errorf("UID SEARCH: %w", err)
171 }
172 var uids []uint32
173 for _, u := range untagged {
174 f := strings.Fields(u.line)
175 if len(f) < 2 || !strings.EqualFold(f[1], "SEARCH") {
176 continue
177 }
178 for _, s := range f[2:] {
179 n, err := strconv.ParseUint(s, 10, 32)
180 if err != nil {
181 return nil, fmt.Errorf("UID SEARCH: bad uid %q", clip(s))
182 }
183 if len(uids) < MaxUnseen {
184 uids = append(uids, uint32(n))
185 }
186 }
187 }
188 return uids, nil
189}
190
191// Fetch returns the whole message without setting \Seen. A message
192// over MaxMessage is refused by its size first. A server answering
193// BODY[] with NIL or "" returns an empty message.
194func (c *Client) Fetch(uid uint32) ([]byte, error) {
195 untagged, err := c.cmd(fmt.Sprintf("UID FETCH %d RFC822.SIZE", uid))
196 if err != nil {
197 return nil, fmt.Errorf("UID FETCH: %w", err)
198 }
199 for _, u := range untagged {
200 up := strings.ToUpper(u.line)
201 if i := strings.Index(up, "RFC822.SIZE "); i >= 0 && strings.Contains(up, " FETCH ") {
202 f := strings.Fields(strings.TrimRight(up[i+len("RFC822.SIZE "):], ")"))
203 if len(f) > 0 {
204 if n, err := strconv.ParseInt(strings.TrimRight(f[0], ")"), 10, 64); err == nil && n > MaxMessage {
205 return nil, ErrTooLarge
206 }
207 }
208 }
209 }
210 untagged, err = c.cmd(fmt.Sprintf("UID FETCH %d BODY.PEEK[]", uid))
211 if err != nil {
212 return nil, fmt.Errorf("UID FETCH: %w", err)
213 }
214 for _, u := range untagged {
215 f := strings.Fields(u.line)
216 if len(f) < 3 || !strings.EqualFold(f[2], "FETCH") {
217 continue
218 }
219 if u.tooLarge {
220 return nil, ErrTooLarge
221 }
222 if u.body != nil {
223 return u.body, nil
224 }
225 up := strings.ToUpper(u.line)
226 if strings.Contains(up, "BODY[] NIL") || strings.Contains(up, `BODY[] ""`) {
227 return []byte{}, nil
228 }
229 }
230 return nil, &RefusedError{fmt.Sprintf("UID FETCH %d: no message body in the response", uid)}
231}
232
233// MarkSeen sets \Seen.
234func (c *Client) MarkSeen(uid uint32) error {
235 if _, err := c.cmd(fmt.Sprintf("UID STORE %d +FLAGS.SILENT (\\Seen)", uid)); err != nil {
236 return fmt.Errorf("UID STORE: %w", err)
237 }
238 return nil
239}
240
241// Close logs out and closes the connection.
242func (c *Client) Close() error {
243 c.cmd("LOGOUT")
244 return c.conn.Close()
245}
246
247type response struct {
248 line string // the response with each literal's bytes left out
249 body []byte // the BODY[] literal, when the response carried one
250 tooLarge bool // the BODY[] literal was over MaxMessage and not kept
251}
252
253// cmd sends one tagged command and collects the untagged responses up to
254// its completion. A NO or BAD completion is an error.
255func (c *Client) cmd(command string) ([]response, error) {
256 c.tag++
257 c.left = cmdBudget
258 tag := "g" + strconv.Itoa(c.tag)
259 if _, err := io.WriteString(c.conn, tag+" "+command+"\r\n"); err != nil {
260 return nil, err
261 }
262 var untagged []response
263 for {
264 resp, err := c.readResponse()
265 if err != nil {
266 return nil, err
267 }
268 line := resp.line
269 if rest, ok := strings.CutPrefix(line, tag+" "); ok {
270 status, _, _ := strings.Cut(rest, " ")
271 if strings.EqualFold(status, "OK") {
272 return untagged, nil
273 }
274 return nil, &RefusedError{clip(rest)}
275 }
276 if strings.HasPrefix(line, "* BYE") && command != "LOGOUT" {
277 return nil, fmt.Errorf("server closed the session: %s", clip(line))
278 }
279 if strings.HasPrefix(line, "*") {
280 if len(untagged) >= maxUntagged {
281 return nil, c.limit()
282 }
283 untagged = append(untagged, resp)
284 }
285 // A "+" continuation is not expected: no command here sends a
286 // literal.
287 }
288}
289
290// limit closes a connection whose server exceeded a limit.
291func (c *Client) limit() error {
292 c.conn.Close()
293 return ErrLimit
294}
295
296// readResponse reads one response: a line, and for each literal it
297// announces ("{n}" at the end of a line) the n bytes and the rest of the
298// response after them. Only the literal after "BODY[]" is kept; any
299// other is read and discarded. Everything read counts against the
300// command's budget.
301func (c *Client) readResponse() (response, error) {
302 var b strings.Builder
303 var resp response
304 for {
305 line, err := c.readLine()
306 if err != nil {
307 return response{}, err
308 }
309 b.WriteString(line)
310 n, ok := literalSize(line)
311 if !ok {
312 resp.line = b.String()
313 return resp, nil
314 }
315 if n > c.left {
316 return response{}, c.limit()
317 }
318 c.left -= n
319 prefix := strings.TrimRight(line[:strings.LastIndexByte(line, '{')], " ")
320 keep := resp.body == nil && !resp.tooLarge && strings.HasSuffix(strings.ToUpper(prefix), "BODY[]")
321 if !keep || n > MaxMessage {
322 if _, err := io.CopyN(io.Discard, c.r, n); err != nil {
323 return response{}, err
324 }
325 if keep {
326 resp.tooLarge = true
327 }
328 continue
329 }
330 buf := make([]byte, n)
331 if _, err := io.ReadFull(c.r, buf); err != nil {
332 return response{}, err
333 }
334 resp.body = buf
335 }
336}
337
338func (c *Client) readLine() (string, error) {
339 var b []byte
340 for {
341 chunk, isPrefix, err := c.r.ReadLine()
342 if err != nil {
343 return "", err
344 }
345 b = append(b, chunk...)
346 c.left -= int64(len(chunk)) + 2
347 if len(b) > maxLine || c.left < 0 {
348 return "", c.limit()
349 }
350 if !isPrefix {
351 return string(b), nil
352 }
353 }
354}
355
356// literalSize reads a trailing "{n}" (or "{n+}").
357func literalSize(line string) (int64, bool) {
358 if !strings.HasSuffix(line, "}") {
359 return 0, false
360 }
361 i := strings.LastIndexByte(line, '{')
362 if i < 0 {
363 return 0, false
364 }
365 n, err := strconv.ParseInt(strings.TrimSuffix(line[i+1:len(line)-1], "+"), 10, 64)
366 if err != nil || n < 0 {
367 return 0, false
368 }
369 return n, true
370}
371
372// quote renders s as an IMAP quoted string.
373func quote(s string) (string, error) {
374 for i := 0; i < len(s); i++ {
375 if s[i] < 0x20 || s[i] > 0x7e {
376 return "", errors.New("only printable ASCII can be sent")
377 }
378 }
379 return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`, nil
380}
381
382func clip(s string) string {
383 if len(s) > 200 {
384 return s[:200] + "…"
385 }
386 return s
387}
internal/imapc/imapc_test.go added +353
@@ -0,0 +1,353 @@
1package imapc
2
3import (
4 "bufio"
5 "crypto/tls"
6 "crypto/x509"
7 "errors"
8 "fmt"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "strings"
13 "testing"
14 "time"
15)
16
17// fakeServer answers the commands this client sends from a map of UID to
18// message. It records the commands it saw.
19type fakeServer struct {
20 msgs map[uint32]string
21 seen map[uint32]bool
22 cmds []string
23 // raw, when set, answers a command in place of the default: it
24 // writes whatever it likes and reports whether it handled it.
25 raw func(conn net.Conn, tag, cmd string) bool
26}
27
28func (f *fakeServer) serve(conn net.Conn) {
29 defer conn.Close()
30 r := bufio.NewReader(conn)
31 fmt.Fprint(conn, "* OK fake ready\r\n")
32 for {
33 line, err := r.ReadString('\n')
34 if err != nil {
35 return
36 }
37 line = strings.TrimRight(line, "\r\n")
38 tag, cmd, _ := strings.Cut(line, " ")
39 f.cmds = append(f.cmds, cmd)
40 up := strings.ToUpper(cmd)
41 if f.raw != nil && f.raw(conn, tag, cmd) {
42 continue
43 }
44 switch {
45 case strings.HasPrefix(up, "STARTTLS"):
46 fmt.Fprintf(conn, "%s OK begin\r\n", tag)
47 tc := tls.Server(conn, serverTLS)
48 if err := tc.Handshake(); err != nil {
49 return
50 }
51 conn, r = tc, bufio.NewReader(tc)
52 case strings.HasPrefix(up, "LOGIN"):
53 if cmd != `LOGIN "u" "p\"w"` {
54 fmt.Fprintf(conn, "%s NO [AUTHENTICATIONFAILED] %s\r\n", tag, cmd)
55 continue
56 }
57 fmt.Fprintf(conn, "* CAPABILITY IMAP4rev1\r\n%s OK logged in\r\n", tag)
58 case strings.HasPrefix(up, "SELECT"), strings.HasPrefix(up, "EXAMINE"):
59 fmt.Fprintf(conn, "* %d EXISTS\r\n* 0 RECENT\r\n%s OK done\r\n", len(f.msgs), tag)
60 case up == "UID SEARCH UNSEEN":
61 var ids []string
62 for uid := range f.msgs {
63 if !f.seen[uid] {
64 ids = append(ids, fmt.Sprint(uid))
65 }
66 }
67 fmt.Fprintf(conn, "* SEARCH %s\r\n%s OK done\r\n", strings.Join(ids, " "), tag)
68 case strings.HasPrefix(up, "UID FETCH") && strings.HasSuffix(up, "RFC822.SIZE"):
69 var uid uint32
70 fmt.Sscanf(cmd, "UID FETCH %d", &uid)
71 fmt.Fprintf(conn, "* 1 FETCH (UID %d RFC822.SIZE %d)\r\n%s OK done\r\n", uid, len(f.msgs[uid]), tag)
72 case strings.HasPrefix(up, "UID FETCH"):
73 var uid uint32
74 fmt.Sscanf(cmd, "UID FETCH %d", &uid)
75 m := f.msgs[uid]
76 // FLAGS ahead of the body and UID after it, as some servers order them.
77 fmt.Fprintf(conn, "* 1 FETCH (FLAGS () BODY[] {%d}\r\n%s UID %d)\r\n%s OK done\r\n", len(m), m, uid, tag)
78 case strings.HasPrefix(up, "UID STORE"):
79 var uid uint32
80 fmt.Sscanf(cmd, "UID STORE %d", &uid)
81 f.seen[uid] = true
82 fmt.Fprintf(conn, "%s OK done\r\n", tag)
83 case up == "LOGOUT":
84 fmt.Fprintf(conn, "* BYE\r\n%s OK bye\r\n", tag)
85 return
86 default:
87 fmt.Fprintf(conn, "%s BAD unknown\r\n", tag)
88 }
89 }
90}
91
92var serverTLS *tls.Config
93
94func setupTLS(t *testing.T) {
95 ts := httptest.NewTLSServer(http.NotFoundHandler())
96 t.Cleanup(ts.Close)
97 pool := x509.NewCertPool()
98 pool.AddCert(ts.Certificate())
99 prev := rootCAs
100 rootCAs = pool
101 t.Cleanup(func() { rootCAs = prev })
102 serverTLS = &tls.Config{Certificates: ts.TLS.Certificates}
103}
104
105func listen(t *testing.T, f *fakeServer, implicit bool) string {
106 t.Helper()
107 ln, err := net.Listen("tcp", "127.0.0.1:0")
108 if err != nil {
109 t.Fatal(err)
110 }
111 if implicit {
112 ln = tls.NewListener(ln, serverTLS)
113 }
114 t.Cleanup(func() { ln.Close() })
115 go func() {
116 for {
117 conn, err := ln.Accept()
118 if err != nil {
119 return
120 }
121 go f.serve(conn)
122 }
123 }()
124 // The test certificate is for example.com and 127.0.0.1.
125 return ln.Addr().String()
126}
127
128func TestSession(t *testing.T) {
129 setupTLS(t)
130 body := "From: a@example.test\r\nSubject: x\r\n\r\nhello {3}\r\n"
131 for _, starttls := range []bool{false, true} {
132 f := &fakeServer{msgs: map[uint32]string{7: body, 9: "other"}, seen: map[uint32]bool{9: true}}
133 c, err := Dial(listen(t, f, !starttls), starttls, 5*time.Second)
134 if err != nil {
135 t.Fatal(err)
136 }
137 if err := c.Login("u", `p"w`); err != nil {
138 t.Fatal(err)
139 }
140 n, err := c.Select("INBOX")
141 if err != nil || n != 2 {
142 t.Fatalf("Select = %d, %v", n, err)
143 }
144 uids, err := c.Unseen()
145 if err != nil || len(uids) != 1 || uids[0] != 7 {
146 t.Fatalf("Unseen = %v, %v", uids, err)
147 }
148 got, err := c.Fetch(7)
149 if err != nil || string(got) != body {
150 t.Fatalf("Fetch = %q, %v", got, err)
151 }
152 if err := c.MarkSeen(7); err != nil {
153 t.Fatal(err)
154 }
155 if uids, _ := c.Unseen(); len(uids) != 0 {
156 t.Fatalf("still unseen: %v", uids)
157 }
158 c.Close()
159 if !strings.Contains(strings.Join(f.cmds, "\n"), "UID FETCH 7 BODY.PEEK[]") {
160 t.Fatalf("fetch did not peek: %v", f.cmds)
161 }
162 if starttls && f.cmds[0] != "STARTTLS" {
163 t.Fatalf("first command %q, want STARTTLS before LOGIN", f.cmds[0])
164 }
165 }
166}
167
168func TestLoginRefusedHidesServerText(t *testing.T) {
169 setupTLS(t)
170 f := &fakeServer{msgs: map[uint32]string{}, seen: map[uint32]bool{}}
171 c, err := Dial(listen(t, f, true), false, 5*time.Second)
172 if err != nil {
173 t.Fatal(err)
174 }
175 defer c.Close()
176 err = c.Login("u", "secret")
177 if err == nil || strings.Contains(err.Error(), "secret") {
178 t.Fatalf("Login = %v", err)
179 }
180 if err := c.Login("u", "bad\r\npass"); err == nil {
181 t.Fatal("a line break in the password was sent")
182 }
183}
184
185// A server with a certificate the client does not trust is refused.
186func TestUntrustedCertificate(t *testing.T) {
187 setupTLS(t)
188 f := &fakeServer{msgs: map[uint32]string{}, seen: map[uint32]bool{}}
189 addr := listen(t, f, true)
190 rootCAs = x509.NewCertPool()
191 if _, err := Dial(addr, false, 5*time.Second); err == nil {
192 t.Fatal("dialled a server with an untrusted certificate")
193 }
194}
195
196func TestLiteralSize(t *testing.T) {
197 for line, want := range map[string]int64{
198 "* 1 FETCH (BODY[] {12}": 12,
199 "* 1 FETCH (BODY[] {5+}": 5,
200 "* OK {x}": -1,
201 "* OK done": -1,
202 } {
203 n, ok := literalSize(line)
204 if (want < 0) == ok || (ok && n != want) {
205 t.Errorf("literalSize(%q) = %d, %v", line, n, ok)
206 }
207 }
208}
209
210// session dials f over implicit TLS and logs in.
211func session(t *testing.T, f *fakeServer) *Client {
212 t.Helper()
213 setupTLS(t)
214 if f.msgs == nil {
215 f.msgs, f.seen = map[uint32]string{}, map[uint32]bool{}
216 }
217 c, err := Dial(listen(t, f, true), false, 10*time.Second)
218 if err != nil {
219 t.Fatal(err)
220 }
221 t.Cleanup(func() { c.Close() })
222 if err := c.Login("u", `p"w`); err != nil {
223 t.Fatal(err)
224 }
225 return c
226}
227
228// A server that answers one FETCH with literal after literal is cut off
229// at the command's byte budget, however it labels them.
230func TestHostileRepeatedLiterals(t *testing.T) {
231 chunk := strings.Repeat("x", 10<<20)
232 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
233 if !strings.HasPrefix(cmd, "UID FETCH 1 BODY") {
234 return false
235 }
236 for i := 0; i < 5; i++ {
237 if _, err := fmt.Fprintf(conn, "* 1 FETCH (FLAGS {%d}\r\n%s)\r\n", len(chunk), chunk); err != nil {
238 return true
239 }
240 }
241 fmt.Fprintf(conn, "%s OK done\r\n", tag)
242 return true
243 }}
244 c := session(t, f)
245 f.msgs[1] = "small"
246 if _, err := c.Fetch(1); !errors.Is(err, ErrLimit) {
247 t.Fatalf("Fetch = %v, want ErrLimit", err)
248 }
249}
250
251// A body literal over MaxMessage is refused even when RFC822.SIZE lied.
252func TestLyingSize(t *testing.T) {
253 big := strings.Repeat("x", MaxMessage+10)
254 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
255 if !strings.HasPrefix(cmd, "UID FETCH 1 BODY") {
256 return false
257 }
258 fmt.Fprintf(conn, "* 1 FETCH (BODY[] {%d}\r\n%s)\r\n%s OK done\r\n", len(big), big, tag)
259 return true
260 }}
261 c := session(t, f)
262 f.msgs[1] = "small"
263 if _, err := c.Fetch(1); !errors.Is(err, ErrTooLarge) {
264 t.Fatalf("Fetch = %v, want ErrTooLarge", err)
265 }
266}
267
268func TestSizeRefusedBeforeFetch(t *testing.T) {
269 f := &fakeServer{}
270 c := session(t, f)
271 f.msgs[1] = strings.Repeat("x", MaxMessage+1)
272 if _, err := c.Fetch(1); !errors.Is(err, ErrTooLarge) {
273 t.Fatalf("Fetch = %v, want ErrTooLarge", err)
274 }
275 for _, cmd := range f.cmds {
276 if strings.Contains(cmd, "BODY.PEEK") {
277 t.Fatal("fetched the body of an oversized message")
278 }
279 }
280}
281
282func TestHugeSearch(t *testing.T) {
283 var b strings.Builder
284 for i := 1; i <= 20000; i++ {
285 fmt.Fprintf(&b, " %d", i)
286 }
287 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
288 if cmd != "UID SEARCH UNSEEN" {
289 return false
290 }
291 fmt.Fprintf(conn, "* SEARCH%s\r\n%s OK done\r\n", b.String(), tag)
292 return true
293 }}
294 c := session(t, f)
295 uids, err := c.Unseen()
296 if err != nil || len(uids) != MaxUnseen {
297 t.Fatalf("Unseen = %d uids, %v", len(uids), err)
298 }
299}
300
301func TestTooManyUntagged(t *testing.T) {
302 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
303 if cmd != "UID SEARCH UNSEEN" {
304 return false
305 }
306 for i := 0; i < maxUntagged+10; i++ {
307 fmt.Fprint(conn, "* OK noise\r\n")
308 }
309 fmt.Fprintf(conn, "%s OK done\r\n", tag)
310 return true
311 }}
312 c := session(t, f)
313 if _, err := c.Unseen(); !errors.Is(err, ErrLimit) {
314 t.Fatalf("Unseen = %v, want ErrLimit", err)
315 }
316}
317
318func TestEmptyBody(t *testing.T) {
319 for _, form := range []string{"NIL", `""`} {
320 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
321 if !strings.HasPrefix(cmd, "UID FETCH 1 BODY") {
322 return false
323 }
324 fmt.Fprintf(conn, "* 1 FETCH (UID 1 BODY[] %s)\r\n%s OK done\r\n", form, tag)
325 return true
326 }}
327 c := session(t, f)
328 f.msgs[1] = ""
329 if b, err := c.Fetch(1); err != nil || len(b) != 0 {
330 t.Fatalf("%s: Fetch = %q, %v", form, b, err)
331 }
332 }
333}
334
335// A NO to one FETCH is a RefusedError; the session goes on.
336func TestFetchRefused(t *testing.T) {
337 f := &fakeServer{raw: func(conn net.Conn, tag, cmd string) bool {
338 if !strings.HasPrefix(cmd, "UID FETCH 1 ") {
339 return false
340 }
341 fmt.Fprintf(conn, "%s NO [UNAVAILABLE] try later\r\n", tag)
342 return true
343 }}
344 c := session(t, f)
345 f.msgs[1], f.msgs[2] = "a", "b"
346 var re *RefusedError
347 if _, err := c.Fetch(1); !errors.As(err, &re) {
348 t.Fatalf("Fetch = %v, want a RefusedError", err)
349 }
350 if b, err := c.Fetch(2); err != nil || string(b) != "b" {
351 t.Fatalf("next Fetch = %q, %v", b, err)
352 }
353}
internal/imapc/open.go added +35
@@ -0,0 +1,35 @@
1package imapc
2
3import (
4 "time"
5
6 "gitbay.org/gitbay/internal/config"
7)
8
9// Open connects to the configured mailbox, logs in with the password
10// file's password, and opens the mailbox: read-only (EXAMINE) or for
11// setting flags (SELECT). It returns the number of messages in it.
12func Open(in config.MailInbound, readOnly bool, timeout time.Duration) (*Client, int, error) {
13 pass, err := in.Password()
14 if err != nil {
15 return nil, 0, err
16 }
17 c, err := Dial(in.Addr(), in.TLS == "starttls", timeout)
18 if err != nil {
19 return nil, 0, err
20 }
21 if err := c.Login(in.User, pass); err != nil {
22 c.Close()
23 return nil, 0, err
24 }
25 open := c.Select
26 if readOnly {
27 open = c.Examine
28 }
29 n, err := open(in.MailboxName())
30 if err != nil {
31 c.Close()
32 return nil, 0, err
33 }
34 return c, n, nil
35}
internal/mail/mail.go +15 −2
@@ -22,10 +22,23 @@ var rootCAs *x509.CertPool
22 22
23// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. 23// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port.
24func Send(cfg config.Config, to, subject, body string) error { 24func Send(cfg config.Config, to, subject, body string) error {
25 return SendReplyTo(cfg, to, "", subject, body)
26}
27
28// SendReplyTo is Send with a Reply-To header, left out when replyTo is
29// empty.
30func SendReplyTo(cfg config.Config, to, replyTo, subject, body string) error {
25 m := cfg.Mail 31 m := cfg.Mail
26 if m.SMTPHost == "" || m.From == "" { 32 if m.SMTPHost == "" || m.From == "" {
27 return fmt.Errorf("[mail] smtp_host and from must be configured") 33 return fmt.Errorf("[mail] smtp_host and from must be configured")
28 } 34 }
35 if strings.ContainsAny(replyTo, "\r\n") {
36 return fmt.Errorf("reply-to address contains a line break")
37 }
38 header := ""
39 if replyTo != "" {
40 header = "Reply-To: " + replyTo + "\n"
41 }
29 implicit := m.TLS == "implicit" 42 implicit := m.TLS == "implicit"
30 host := m.SMTPHost 43 host := m.SMTPHost
31 if !strings.Contains(host, ":") { 44 if !strings.Contains(host, ":") {
@@ -39,8 +52,8 @@ func Send(cfg config.Config, to, subject, body string) error {
39 tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs} 52 tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs}
40 53
41 msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( 54 msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf(
42 "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", 55 "From: %s\nTo: %s\n%sSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n",
43 m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) 56 m.From, to, header, subject, time.Now().Format(time.RFC1123Z), body))
44 57
45 c, err := dial(host, hostname, implicit, tlsCfg) 58 c, err := dial(host, hostname, implicit, tlsCfg)
46 if err != nil { 59 if err != nil {
internal/mail/mail_test.go +23
@@ -140,3 +140,26 @@ func TestImplicitTLS(t *testing.T) {
140 t.Fatalf("delivered %d, want 1", n) 140 t.Fatalf("delivered %d, want 1", n)
141 } 141 }
142} 142}
143
144func TestReplyToHeader(t *testing.T) {
145 relay := startRelay(t, nil)
146 cfg := mailCfg(relay.addr)
147 if err := SendReplyTo(cfg, "a@example.test", "reply+tok@example.test", "subject", "body"); err != nil {
148 t.Fatal(err)
149 }
150 if err := Send(cfg, "a@example.test", "subject", "body"); err != nil {
151 t.Fatal(err)
152 }
153 relay.mu.Lock()
154 with, without := relay.data[0], relay.data[1]
155 relay.mu.Unlock()
156 if !strings.Contains(with, "\nReply-To: reply+tok@example.test\n") {
157 t.Fatalf("no Reply-To:\n%s", with)
158 }
159 if strings.Contains(without, "Reply-To:") {
160 t.Fatalf("Send added a Reply-To:\n%s", without)
161 }
162 if err := SendReplyTo(cfg, "a@example.test", "x@example.test\r\nBcc: b@example.test", "s", "b"); err == nil {
163 t.Fatal("a line break in the reply address was sent")
164 }
165}
internal/mailin/authres.go added +206
@@ -0,0 +1,206 @@
1package mailin
2
3import (
4 "net/mail"
5 "strings"
6
7 "golang.org/x/net/publicsuffix"
8)
9
10// authenticated checks the sender against the mail host's own verdict:
11// the topmost Authentication-Results header (RFC 8601) whose authserv-id
12// is authserv. The mail host adds its header above any the message
13// arrived with, so a lower header claiming the same id is the sender's
14// and is not read. It returns "" when the header shows dmarc=pass for
15// the From domain, or dkim=pass with a header.d aligned with it, and the
16// refusal's reason otherwise.
17func authenticated(h mail.Header, authserv, from string) string {
18 _, fromDomain, ok := strings.Cut(strings.ToLower(from), "@")
19 if !ok || fromDomain == "" {
20 return "no From domain"
21 }
22 for _, v := range h["Authentication-Results"] {
23 segs := splitResults(tokenize(v))
24 if len(segs) == 0 || len(segs[0]) == 0 || segs[0][0].kind != tokAtom ||
25 !strings.EqualFold(segs[0][0].text, authserv) {
26 continue
27 }
28 for _, seg := range segs[1:] {
29 method, result, props, ok := resinfo(seg)
30 if !ok || result != "pass" {
31 continue
32 }
33 switch method {
34 case "dmarc":
35 if props["header.from"] == fromDomain {
36 return ""
37 }
38 case "dkim":
39 if aligned(props["header.d"], fromDomain) {
40 return ""
41 }
42 }
43 }
44 return "sender not authenticated by " + authserv + " (no DMARC pass or aligned DKIM pass)"
45 }
46 return "no Authentication-Results from " + authserv
47}
48
49type tokKind int
50
51const (
52 tokAtom tokKind = iota
53 tokQuoted
54 tokEquals
55 tokSemi
56)
57
58type token struct {
59 kind tokKind
60 text string // an atom's text, or a quoted string's content unescaped
61 // joined marks a token with no whitespace or comment before it, so
62 // "x"@example.org is one value.
63 joined bool
64}
65
66// tokenize splits a header value into atoms, quoted strings, "=" and
67// ";". Comments, nested or not, and whitespace separate tokens and are
68// dropped; backslash escapes are honoured in both comments and quoted
69// strings, so nothing inside either can end it early. An unterminated
70// quoted string or comment runs to the end of the value.
71func tokenize(s string) []token {
72 var out []token
73 joined := false
74 emit := func(t token) {
75 t.joined = joined
76 out = append(out, t)
77 joined = true
78 }
79 for i := 0; i < len(s); {
80 c := s[i]
81 switch {
82 case c == ' ' || c == '\t' || c == '\r' || c == '\n':
83 joined = false
84 i++
85 case c == '(':
86 depth := 0
87 for ; i < len(s); i++ {
88 if s[i] == '\\' {
89 i++
90 continue
91 }
92 if s[i] == '(' {
93 depth++
94 } else if s[i] == ')' {
95 depth--
96 if depth == 0 {
97 i++
98 break
99 }
100 }
101 }
102 joined = false
103 case c == '"':
104 var b strings.Builder
105 i++
106 for i < len(s) && s[i] != '"' {
107 if s[i] == '\\' && i+1 < len(s) {
108 i++
109 }
110 b.WriteByte(s[i])
111 i++
112 }
113 i++ // the closing quote
114 emit(token{kind: tokQuoted, text: b.String()})
115 case c == '=':
116 emit(token{kind: tokEquals})
117 i++
118 case c == ';':
119 emit(token{kind: tokSemi})
120 i++
121 default:
122 j := i
123 for j < len(s) && !strings.ContainsRune(" \t\r\n()\";=\\", rune(s[j])) {
124 j++
125 }
126 if j == i { // a stray backslash
127 j++
128 }
129 emit(token{kind: tokAtom, text: s[i:j]})
130 i = j
131 }
132 }
133 return out
134}
135
136// splitResults splits tokens at each ";".
137func splitResults(ts []token) [][]token {
138 segs := [][]token{nil}
139 for _, t := range ts {
140 if t.kind == tokSemi {
141 segs = append(segs, nil)
142 continue
143 }
144 segs[len(segs)-1] = append(segs[len(segs)-1], t)
145 }
146 return segs
147}
148
149// resinfo reads "method[/version]=result" and the "name=value" pairs
150// after it. Method, result and each value must be plain atoms; a quoted
151// value (a reason, or a quoted local part) is kept only as a quoted
152// value and never read as a domain. ok is false when the method does
153// not parse.
154func resinfo(ts []token) (method, result string, props map[string]string, ok bool) {
155 props = map[string]string{}
156 if len(ts) < 3 || ts[0].kind != tokAtom || ts[1].kind != tokEquals || ts[2].kind != tokAtom {
157 return "", "", props, false
158 }
159 method, _, _ = strings.Cut(strings.ToLower(ts[0].text), "/")
160 result = strings.ToLower(ts[2].text)
161 i := 3
162 // A value continues through tokens joined to it: "x"@example.org.
163 for i < len(ts) && ts[i].joined && ts[i].kind != tokEquals {
164 i++
165 }
166 for i < len(ts) {
167 if ts[i].kind != tokAtom || i+2 >= len(ts) || ts[i+1].kind != tokEquals {
168 i++
169 continue
170 }
171 name := strings.ToLower(ts[i].text)
172 v := ts[i+2]
173 j := i + 3
174 plain := v.kind == tokAtom
175 for j < len(ts) && ts[j].joined && ts[j].kind != tokEquals {
176 plain = false
177 j++
178 }
179 // The first value for a name is the one the mail host wrote
180 // beside the result.
181 if _, seen := props[name]; !seen {
182 if plain {
183 props[name] = strings.ToLower(v.text)
184 } else {
185 props[name] = "" // present, but not a plain domain
186 }
187 }
188 i = j
189 }
190 return method, result, props, true
191}
192
193// aligned is DMARC relaxed alignment: the signing domain and the From
194// domain have the same organizational domain (public suffix plus one
195// label). A domain that is itself a public suffix aligns with nothing.
196func aligned(d, from string) bool {
197 if d == "" {
198 return false
199 }
200 od, err := publicsuffix.EffectiveTLDPlusOne(d)
201 if err != nil {
202 return false
203 }
204 of, err := publicsuffix.EffectiveTLDPlusOne(from)
205 return err == nil && od == of
206}
internal/mailin/authres_test.go added +92
@@ -0,0 +1,92 @@
1package mailin
2
3import (
4 "net/mail"
5 "strings"
6 "testing"
7)
8
9func arHeader(values ...string) mail.Header {
10 return mail.Header{"Authentication-Results": values}
11}
12
13func TestAuthenticatedCrafted(t *testing.T) {
14 const id = "mx.example.net"
15 for _, tc := range []struct {
16 name, value, from string
17 pass bool
18 }{
19 {"plain dmarc pass", `mx.example.net; dmarc=pass header.from=victim.example`, "a@victim.example", true},
20 {"quoted local part in smtp.mailfrom",
21 `mx.example.net; spf=pass smtp.mailfrom="x; dmarc=pass header.from=victim.example y"@evil.example; dmarc=pass header.from=evil.example`,
22 "a@victim.example", false},
23 {"quoted reason",
24 `mx.example.net; spf=fail reason="bad; dmarc=pass header.from=victim.example"; dmarc=fail header.from=victim.example`,
25 "a@victim.example", false},
26 {"comment containing a fake result",
27 `mx.example.net; spf=none (sender says; dmarc=pass header.from=victim.example) smtp.mailfrom=evil.example; dmarc=fail header.from=victim.example`,
28 "a@victim.example", false},
29 {"escaped quote inside a quoted string",
30 `mx.example.net; spf=pass smtp.mailfrom="x\"; dmarc=pass header.from=victim.example; \"y"@evil.example`,
31 "a@victim.example", false},
32 {"nested comment with an escaped paren",
33 `mx.example.net; spf=none (a (b\) ; dmarc=pass header.from=victim.example) c); dmarc=fail header.from=victim.example`,
34 "a@victim.example", false},
35 {"quoted header.from value", `mx.example.net; dmarc=pass header.from="victim.example"`, "a@victim.example", false},
36 {"dkim on a public suffix", `mx.example.net; dkim=pass header.d=github.io`, "bob@user.github.io", false},
37 {"dkim relaxed alignment", `mx.example.net; dkim=pass header.d=example.com`, "a@mail.example.com", true},
38 {"dkim unrelated domain", `mx.example.net; dkim=pass header.d=example.org`, "a@example.com", false},
39 {"dkim header.i only", `mx.example.net; dkim=pass header.i=@example.com`, "a@example.com", false},
40 {"property named like a method",
41 `mx.example.net; spf=pass dmarc=pass header.from=victim.example`, "a@victim.example", false},
42 } {
43 t.Run(tc.name, func(t *testing.T) {
44 got := authenticated(arHeader(tc.value), id, tc.from)
45 if (got == "") != tc.pass {
46 t.Fatalf("authenticated = %q, want pass %v", got, tc.pass)
47 }
48 })
49 }
50}
51
52// FuzzAuthResults: no input panics, and a header whose only mention of
53// the victim domain is inside a quoted string or a comment never
54// passes. Prefix and suffix are arbitrary text with the characters that
55// could open or close a quote or comment removed, so the wrapped payload
56// stays wrapped.
57func FuzzAuthResults(f *testing.F) {
58 f.Add("spf=pass smtp.mailfrom=", "@evil.example; dmarc=pass header.from=evil.example", 0, "x; dmarc=pass header.from=victim.example y")
59 f.Add("spf=none ", "; dkim=pass header.d=evil.example", 1, "dmarc=pass header.from=victim.example")
60 f.Add("", "", 2, `a\"; dkim=pass header.d=victim.example; \"b`)
61 strip := strings.NewReplacer(`"`, "", "(", "", ")", "", `\`, "")
62 f.Fuzz(func(t *testing.T, prefix, suffix string, wrap int, payload string) {
63 authenticated(arHeader(prefix+payload+suffix), "mx.example.net", "a@victim.example")
64 prefix, suffix = strip.Replace(prefix), strip.Replace(suffix)
65 if strings.Contains(strings.ToLower(prefix+suffix), "victim") {
66 return
67 }
68 var wrapped string
69 switch wrap % 3 {
70 case 0: // a quoted string, escapes kept balanced
71 wrapped = `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(payload) + `"`
72 case 1: // a comment, parentheses escaped
73 wrapped = "(" + strings.NewReplacer(`\`, `\\`, "(", `\(`, ")", `\)`).Replace(payload) + ")"
74 default: // payload already escaped by the fuzzer, inside quotes
75 for i := 0; i < len(payload); i++ {
76 if payload[i] == '\\' {
77 if i+1 == len(payload) {
78 return // it would escape the closing quote
79 }
80 i++
81 } else if payload[i] == '"' {
82 return // an unescaped quote ends the string early
83 }
84 }
85 wrapped = `"` + payload + `"`
86 }
87 v := "mx.example.net; " + prefix + wrapped + suffix
88 if authenticated(arHeader(v), "mx.example.net", "a@victim.example") == "" {
89 t.Fatalf("passed with the victim domain only inside a quote or comment: %q", v)
90 }
91 })
92}
internal/mailin/body.go added +214
@@ -0,0 +1,214 @@
1package mailin
2
3import (
4 "bufio"
5 "encoding/base64"
6 "errors"
7 "io"
8 "mime"
9 "mime/multipart"
10 "mime/quotedprintable"
11 "net/textproto"
12 "regexp"
13 "strings"
14 "unicode/utf8"
15
16 "golang.org/x/text/encoding/htmlindex"
17)
18
19var errNoText = errors.New("no text/plain part")
20
21// maxParts and maxDepth bound the walk through a multipart message.
22const (
23 maxParts = 64
24 maxDepth = 5
25)
26
27// textBody returns the message's first text/plain part that is not an
28// attachment, decoded to UTF-8. A message with only HTML has none, and
29// is refused rather than converted.
30func textBody(h textproto.MIMEHeader, body io.Reader, limit int64) (string, error) {
31 parts := 0
32 return walk(h, body, limit, 0, &parts)
33}
34
35func walk(h textproto.MIMEHeader, body io.Reader, limit int64, depth int, parts *int) (string, error) {
36 ct := h.Get("Content-Type")
37 if ct == "" {
38 ct = "text/plain"
39 }
40 mt, params, err := mime.ParseMediaType(ct)
41 if err != nil {
42 return "", errNoText
43 }
44 switch {
45 case mt == "text/plain":
46 if d, _, _ := mime.ParseMediaType(h.Get("Content-Disposition")); d == "attachment" {
47 return "", errNoText
48 }
49 return decodeText(h.Get("Content-Transfer-Encoding"), params["charset"], body, limit)
50 case strings.HasPrefix(mt, "multipart/") && depth < maxDepth:
51 if params["boundary"] == "" {
52 return "", errNoText
53 }
54 mr := multipart.NewReader(body, params["boundary"])
55 for {
56 // NextRawPart leaves quoted-printable to decodeText, so every
57 // part is decoded the same way.
58 p, err := mr.NextRawPart()
59 if err == io.EOF {
60 return "", errNoText
61 }
62 if err != nil {
63 return "", err
64 }
65 if *parts++; *parts > maxParts {
66 return "", errNoText
67 }
68 s, err := walk(p.Header, p, limit, depth+1, parts)
69 if err == nil {
70 return s, nil
71 }
72 if !errors.Is(err, errNoText) {
73 return "", err
74 }
75 }
76 }
77 return "", errNoText
78}
79
80var errTooLong = errors.New("reply is longer than a comment may be")
81
82func decodeText(cte, charset string, body io.Reader, limit int64) (string, error) {
83 var r io.Reader = body
84 switch strings.ToLower(strings.TrimSpace(cte)) {
85 case "quoted-printable":
86 r = quotedprintable.NewReader(r)
87 case "base64":
88 r = base64.NewDecoder(base64.StdEncoding, &skipSpace{r: bufio.NewReader(r)})
89 case "", "7bit", "8bit", "binary":
90 default:
91 return "", errNoText
92 }
93 switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
94 case "", "utf-8", "utf8", "us-ascii":
95 default:
96 enc, err := htmlindex.Get(cs)
97 if err != nil {
98 return "", errNoText
99 }
100 r = enc.NewDecoder().Reader(r)
101 }
102 // Quoted history is stripped after reading, so the read allows for
103 // a reply several times the size of a comment before refusing it.
104 raw, err := io.ReadAll(io.LimitReader(r, 8*limit+1))
105 if err != nil {
106 return "", err
107 }
108 if int64(len(raw)) > 8*limit {
109 return "", errTooLong
110 }
111 return strings.ToValidUTF8(string(raw), string(utf8.RuneError)), nil
112}
113
114// skipSpace drops the line breaks and spaces base64 bodies are wrapped
115// with.
116type skipSpace struct{ r *bufio.Reader }
117
118func (s *skipSpace) Read(p []byte) (int, error) {
119 n := 0
120 for n < len(p) {
121 b, err := s.r.ReadByte()
122 if err != nil {
123 if n > 0 {
124 return n, nil
125 }
126 return 0, err
127 }
128 if b == '\r' || b == '\n' || b == ' ' || b == '\t' {
129 continue
130 }
131 p[n] = b
132 n++
133 }
134 return n, nil
135}
136
137var (
138 // "On Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+…@…> wrote:", which
139 // Gmail, Apple Mail and Thunderbird all put above the quote, and
140 // which Gmail wraps onto a second line when it is long.
141 attribution = regexp.MustCompile(`(?i)^on\s.*\bwrote:\s*$`)
142 // Outlook: "-----Original Message-----", or a rule of underscores
143 // above a From:/Sent: header block.
144 originalMessage = regexp.MustCompile(`(?i)^\s*-{2,}\s*original message\s*-{2,}\s*$`)
145 underscores = regexp.MustCompile(`^\s*_{10,}\s*$`)
146 headerFrom = regexp.MustCompile(`(?i)^\s*\*?from:\*?\s`)
147 headerSentDate = regexp.MustCompile(`(?i)^\s*\*?(sent|date):\*?\s`)
148 mobileSig = regexp.MustCompile(`(?i)^sent from my \S`)
149)
150
151// stripQuoted returns the text a person wrote in a reply: quoted lines
152// ("> …") dropped wherever they are, and everything from the first
153// separator a mail client puts above the quoted message, or from the
154// signature delimiter "-- ", cut off.
155func stripQuoted(s string) string {
156 s = strings.ReplaceAll(s, "\r\n", "\n")
157 lines := strings.Split(s, "\n")
158 cut := len(lines)
159 for i, l := range lines {
160 t := strings.TrimRight(l, " \t")
161 next := ""
162 if i+1 < len(lines) {
163 next = strings.TrimSpace(lines[i+1])
164 }
165 switch {
166 case l == "-- " || t == "--":
167 case originalMessage.MatchString(t):
168 case underscores.MatchString(t):
169 case attribution.MatchString(strings.TrimSpace(t)):
170 case strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "on ") &&
171 attribution.MatchString(strings.TrimSpace(t)+" "+next):
172 case headerFrom.MatchString(t) && followedByHeader(lines[i+1:]):
173 default:
174 continue
175 }
176 cut = i
177 break
178 }
179 var out []string
180 for _, l := range lines[:cut] {
181 if strings.HasPrefix(strings.TrimLeft(l, " "), ">") {
182 continue
183 }
184 out = append(out, strings.TrimRight(l, " \t"))
185 }
186 // A phone's canned signature, when it is the last thing written.
187 for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
188 out = out[:len(out)-1]
189 }
190 if len(out) > 0 && mobileSig.MatchString(strings.TrimSpace(out[len(out)-1])) {
191 out = out[:len(out)-1]
192 }
193 return strings.TrimSpace(collapseBlank(strings.Join(out, "\n")))
194}
195
196// followedByHeader reports whether a Sent: or Date: line comes within
197// the next few lines, as in the header block Outlook quotes.
198func followedByHeader(rest []string) bool {
199 for i := 0; i < len(rest) && i < 4; i++ {
200 if headerSentDate.MatchString(rest[i]) {
201 return true
202 }
203 }
204 return false
205}
206
207// collapseBlank turns runs of blank lines, left where quoted lines were
208// dropped, into one.
209func collapseBlank(s string) string {
210 for strings.Contains(s, "\n\n\n") {
211 s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
212 }
213 return s
214}
internal/mailin/body_test.go added +100
@@ -0,0 +1,100 @@
1package mailin
2
3import (
4 "bytes"
5 "errors"
6 "net/mail"
7 "net/textproto"
8 "strings"
9 "testing"
10)
11
12func TestStripQuoted(t *testing.T) {
13 for _, tc := range []struct{ name, in, want string }{
14 {"gmail",
15 "Agreed, ship it.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented on #1\n>\n> looks fine\n",
16 "Agreed, ship it."},
17 {"gmail, attribution wrapped",
18 "Agreed.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <\nreply+abc@gitbay.example> wrote:\n\n> alice commented\n",
19 "Agreed."},
20 {"apple mail",
21 "Fixed in the next push.\n\nSent from my iPhone\n\n> On Sep 28, 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n> \n> alice commented on #1\n",
22 "Fixed in the next push."},
23 {"apple mail, unquoted attribution",
24 "Yes.\n\nOn 28 Sep 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented\n",
25 "Yes."},
26 {"outlook",
27 "Will do.\r\n\r\n________________________________\r\nFrom: gitbay <reply+abc@gitbay.example>\r\nSent: Monday, September 28, 2026 9:00 AM\r\nTo: Bob\r\nSubject: [alice/app] #1: title\r\n\r\nalice commented on #1\r\n",
28 "Will do."},
29 {"outlook, no rule",
30 "Will do.\n\nFrom: gitbay <reply+abc@gitbay.example>\nSent: Monday, September 28, 2026 9:00 AM\nTo: Bob\n\nalice commented on #1\n",
31 "Will do."},
32 {"outlook, original message",
33 "Noted.\n\n-----Original Message-----\nFrom: gitbay\nalice commented\n",
34 "Noted."},
35 {"thunderbird",
36 "Thanks, merged.\n\n-- \nBob Example\nExample Corp\n\nOn 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n",
37 "Thanks, merged."},
38 {"thunderbird, quote first",
39 "On 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n\nThat was me.\n",
40 ""},
41 {"inline answers keep the answers",
42 "> does this build?\nYes, on main.\n> and the tests?\nAll green.\n",
43 "Yes, on main.\nAll green."},
44 {"a From: line in prose is kept",
45 "From: the log it looks like a timeout.\nRetrying.\n",
46 "From: the log it looks like a timeout.\nRetrying."},
47 {"markdown rule is not a signature",
48 "one\n\n---\n\ntwo\n",
49 "one\n\n---\n\ntwo"},
50 } {
51 t.Run(tc.name, func(t *testing.T) {
52 if got := stripQuoted(tc.in); got != tc.want {
53 t.Errorf("got %q\nwant %q", got, tc.want)
54 }
55 })
56 }
57}
58
59func body(t *testing.T, raw string) (string, error) {
60 t.Helper()
61 msg, err := mail.ReadMessage(strings.NewReader(raw))
62 if err != nil {
63 t.Fatal(err)
64 }
65 return textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16)
66}
67
68func TestTextBody(t *testing.T) {
69 for _, tc := range []struct{ name, raw, want string }{
70 {"plain, no content type", "Subject: x\r\n\r\nhello\r\n", "hello\r\n"},
71 {"quoted-printable", "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=C3=A9 =\r\nau lait\r\n", "café au lait\r\n"},
72 {"base64", "Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\naGVs\r\nbG8=\r\n", "hello"},
73 {"latin-1", "Content-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=E9\r\n", "café\r\n"},
74 {"alternative prefers plain",
75 "Content-Type: multipart/alternative; boundary=b\r\n\r\n--b\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n--b\r\nContent-Type: text/plain\r\n\r\nplain\r\n--b--\r\n",
76 "plain"},
77 {"mixed with nested alternative and an attachment",
78 "Content-Type: multipart/mixed; boundary=m\r\n\r\n--m\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=a.txt\r\n\r\nattached\r\n--m\r\nContent-Type: multipart/alternative; boundary=a\r\n\r\n--a\r\nContent-Type: text/plain\r\n\r\nbody\r\n--a--\r\n--m--\r\n",
79 "body"},
80 } {
81 t.Run(tc.name, func(t *testing.T) {
82 got, err := body(t, tc.raw)
83 if err != nil || got != tc.want {
84 t.Errorf("got %q, %v; want %q", got, err, tc.want)
85 }
86 })
87 }
88 for name, raw := range map[string]string{
89 "html only": "Content-Type: text/html\r\n\r\n<p>hi</p>\r\n",
90 "unknown charset": "Content-Type: text/plain; charset=x-nonesuch\r\n\r\nhi\r\n",
91 "unknown encoding": "Content-Type: text/plain\r\nContent-Transfer-Encoding: x-uuencode\r\n\r\nhi\r\n",
92 } {
93 if _, err := body(t, raw); !errors.Is(err, errNoText) {
94 t.Errorf("%s: %v, want errNoText", name, err)
95 }
96 }
97 if _, err := body(t, "Subject: x\r\n\r\n"+string(bytes.Repeat([]byte("a"), 8<<16+1))); !errors.Is(err, errTooLong) {
98 t.Errorf("oversized body: %v", err)
99 }
100}
internal/mailin/fuzz_test.go added +35
@@ -0,0 +1,35 @@
1package mailin
2
3import (
4 "bytes"
5 "net/mail"
6 "net/textproto"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/mailreply"
11)
12
13// FuzzReply runs what an inbound message goes through before any
14// account is looked up: header parsing, token extraction and
15// verification, body extraction and quote stripping.
16func FuzzReply(f *testing.F) {
17 f.Add([]byte("From: a@b\r\nTo: reply+abc@x.example\r\nContent-Type: multipart/alternative; boundary=b\r\n\r\n--b\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nhi=\r\n\r\n> q\r\n--b--\r\n"))
18 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n"))
19 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")}
20 f.Fuzz(func(t *testing.T, raw []byte) {
21 msg, err := mail.ReadMessage(bytes.NewReader(raw))
22 if err != nil {
23 return
24 }
25 automatic(msg.Header)
26 if tok := findToken(msg.Header, "reply@x.example"); tok != "" {
27 mailreply.Verify(key, tok, fuzzNow)
28 }
29 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil {
30 stripQuoted(s)
31 }
32 })
33}
34
35var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC)
internal/mailin/mailin.go added +392
@@ -0,0 +1,392 @@
1// Package mailin turns replies to notification mail into comments
2// (#295). A poller reads a mailbox over IMAP; each unseen message
3// addressed to reply+<token>@<domain> is checked (token, account, sender
4// address, the account's access to the thread now) and posted by
5// dispatching issue comment or mr comment as that account. A refusal
6// sends nothing back and is written to the audit log with its reason,
7// never the message's content. Every message is marked seen once it is
8// handled, posted or refused; only a failure that may pass (the
9// database busy) leaves it for the next poll.
10package mailin
11
12import (
13 "bytes"
14 "context"
15 "crypto/sha256"
16 "encoding/hex"
17 "errors"
18 "fmt"
19 "log/slog"
20 "net/mail"
21 "net/textproto"
22 "strconv"
23 "strings"
24 "time"
25
26 "gitbay.org/gitbay/internal/config"
27 "gitbay.org/gitbay/internal/control"
28 "gitbay.org/gitbay/internal/imapc"
29 "gitbay.org/gitbay/internal/mailreply"
30 "gitbay.org/gitbay/internal/protocol"
31 "gitbay.org/gitbay/internal/store"
32)
33
34// Mailbox is what the processor needs of an IMAP session; imapc.Client
35// implements it, and tests use a fake.
36type Mailbox interface {
37 Unseen() ([]uint32, error)
38 Fetch(uid uint32) ([]byte, error)
39 MarkSeen(uid uint32) error
40}
41
42// maxTries is how many polls a message that keeps failing is tried in
43// before it is marked seen and given up on.
44const maxTries = 5
45
46// Processor handles fetched messages.
47type Processor struct {
48 St *store.Store
49 Cfg config.Config
50 Now func() time.Time
51
52 tries map[uint32]int
53 // A window of refusal rows, bounded because anyone can send mail
54 // to the mailbox.
55 windowStart time.Time
56 windowRows int
57}
58
59// refusalsPerMinute bounds the audit rows refusals write.
60const refusalsPerMinute = 60
61
62// Result is what became of one message.
63type Result struct {
64 Posted bool
65 Retry bool // a failure that may pass; the message is left unseen
66 Reason string // why it was refused or failed; empty when posted
67}
68
69func refused(format string, args ...any) Result {
70 return Result{Reason: fmt.Sprintf(format, args...)}
71}
72
73// Drain handles every unseen message in mb. It stops at the first
74// mailbox error.
75func (p *Processor) Drain(mb Mailbox) error {
76 if p.tries == nil {
77 p.tries = map[uint32]int{}
78 }
79 uids, err := mb.Unseen()
80 if err != nil {
81 return err
82 }
83 for _, uid := range uids {
84 // A message that failed in earlier polls before it could be
85 // handled (a fetch the server cut off) is given up on unread.
86 if p.tries[uid] >= maxTries {
87 p.audit(0, "", "gave up after "+strconv.Itoa(maxTries)+" tries")
88 delete(p.tries, uid)
89 if err := mb.MarkSeen(uid); err != nil {
90 return err
91 }
92 continue
93 }
94 raw, err := mb.Fetch(uid)
95 var res Result
96 switch {
97 case errors.Is(err, imapc.ErrTooLarge):
98 res = refused("message larger than %d bytes", imapc.MaxMessage)
99 p.audit(0, "", res.Reason)
100 case errors.Is(err, imapc.ErrLimit):
101 // The server sent more than the limits allow for this
102 // message: it counts a try, and the closed connection ends
103 // the poll.
104 p.tries[uid]++
105 return err
106 case errors.As(err, new(*imapc.RefusedError)):
107 // The server refused this message; the session goes on.
108 res = Result{Retry: true, Reason: "fetch: " + err.Error()}
109 case err != nil:
110 // A connection failure or a timeout says nothing about this
111 // message or the ones after it: the poll ends, no try is
112 // counted.
113 return err
114 default:
115 res = p.Handle(raw)
116 }
117 if res.Retry {
118 p.tries[uid]++
119 if p.tries[uid] < maxTries {
120 slog.Warn("mail reply: will retry", "uid", uid, "err", res.Reason)
121 continue
122 }
123 p.audit(0, "", "gave up after "+strconv.Itoa(maxTries)+" tries: "+res.Reason)
124 }
125 delete(p.tries, uid)
126 if err := mb.MarkSeen(uid); err != nil {
127 return err
128 }
129 }
130 return nil
131}
132
133// Handle checks one message and posts it when every check passes.
134// Refusals are audited here.
135func (p *Processor) Handle(raw []byte) Result {
136 if len(bytes.TrimSpace(raw)) == 0 {
137 return p.refuse(0, "", "empty message")
138 }
139 msg, err := mail.ReadMessage(bytes.NewReader(raw))
140 if err != nil {
141 return p.refuse(0, "", "unreadable message")
142 }
143 msgID := strings.TrimSpace(msg.Header.Get("Message-Id"))
144 if len(msgID) > 200 {
145 msgID = msgID[:200]
146 }
147 if automatic(msg.Header) {
148 return p.refuse(0, msgID, "automatic reply")
149 }
150 in := p.Cfg.Mail.Inbound
151 token := findToken(msg.Header, in.ReplyAddress)
152 if token == "" {
153 return p.refuse(0, msgID, "not addressed to a reply address")
154 }
155 keys := p.St.Keyring()
156 if keys == nil {
157 return Result{Retry: true, Reason: "no secret key loaded"}
158 }
159 secrets, err := keys.Derive(mailreply.Purpose)
160 if err != nil {
161 return Result{Retry: true, Reason: "secret key: " + err.Error()}
162 }
163 target, err := mailreply.Verify(secrets, token, p.now())
164 switch {
165 case errors.Is(err, mailreply.ErrExpired):
166 return p.refuse(target.UserID, msgID, "reply token expired")
167 case err != nil:
168 return p.refuse(0, msgID, err.Error())
169 }
170
171 u, err := p.St.UserByID(target.UserID)
172 switch {
173 case errors.Is(err, store.ErrNotFound):
174 return p.refuse(0, msgID, "account no longer exists")
175 case err != nil:
176 return Result{Retry: true, Reason: err.Error()}
177 case u.Disabled:
178 return p.refuse(u.ID, msgID, "account disabled")
179 case u.Pending:
180 return p.refuse(u.ID, msgID, "account not active")
181 }
182 // Ids are reused after a hard delete: an account created after the
183 // token was minted is not the one it named.
184 if code := p.createdAfter("users", u.ID, target, msgID, "account"); code != nil {
185 return *code
186 }
187 // The token alone is not enough: the reply must come from one of
188 // the account's verified addresses.
189 from, err := msg.Header.AddressList("From")
190 if err != nil || len(from) != 1 {
191 return p.refuse(u.ID, msgID, "no single From address")
192 }
193 ok, err := p.St.VerifiedEmailOf(u.ID, from[0].Address)
194 if err != nil {
195 return Result{Retry: true, Reason: err.Error()}
196 }
197 if !ok {
198 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
199 }
200 if id := p.Cfg.Mail.Inbound.TrustedAuthservID; id != "" {
201 if reason := authenticated(msg.Header, id, from[0].Address); reason != "" {
202 return p.refuse(u.ID, msgID, reason)
203 }
204 }
205 if on, err := p.St.ReplyEnabled(u.ID); err != nil {
206 return Result{Retry: true, Reason: err.Error()}
207 } else if !on {
208 return p.refuse(u.ID, msgID, "reply by mail is off for the account")
209 }
210
211 text, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, control.MaxCommentBytes)
212 switch {
213 case errors.Is(err, errNoText):
214 return p.refuse(u.ID, msgID, "no text/plain part")
215 case errors.Is(err, errTooLong):
216 return p.refuse(u.ID, msgID, "reply too long")
217 case err != nil:
218 return p.refuse(u.ID, msgID, "unreadable body")
219 }
220 text = stripQuoted(text)
221 if text == "" {
222 return p.refuse(u.ID, msgID, "empty reply")
223 }
224 if len(text) > control.MaxCommentBytes {
225 return p.refuse(u.ID, msgID, "reply too long")
226 }
227
228 repo, err := p.St.RepoByID(target.RepoID)
229 switch {
230 case errors.Is(err, store.ErrNotFound):
231 return p.refuse(u.ID, msgID, "repository no longer exists")
232 case err != nil:
233 return Result{Retry: true, Reason: err.Error()}
234 }
235 if code := p.createdAfter("repos", repo.ID, target, msgID, "repository"); code != nil {
236 return *code
237 }
238
239 // The claim names the thread and the account as well as the
240 // message, so one account's Message-ID cannot suppress another's.
241 id := msgID
242 if id == "" {
243 sum := sha256.Sum256(raw)
244 id = "sha256:" + hex.EncodeToString(sum[:])
245 }
246 key := fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id)
247 claimed, err := p.St.ClaimMailReply(key)
248 if err != nil {
249 return Result{Retry: true, Reason: err.Error()}
250 }
251 if !claimed {
252 return p.refuse(u.ID, msgID, "already posted")
253 }
254
255 var stdout, stderr bytes.Buffer
256 c := &control.Ctx{User: u, Scope: "full", Store: p.St, Cfg: p.Cfg,
257 Stdin: strings.NewReader(text), Stdout: &stdout, Stderr: &stderr,
258 Source: control.SourceMail}
259 code := control.Dispatch(c, []string{target.Kind, "comment", repo.Path(),
260 strconv.FormatInt(target.Number, 10), "--file", "-"})
261 if code == protocol.ExitOK {
262 return Result{Posted: true}
263 }
264 p.St.ReleaseMailReply(key)
265 reason := strings.TrimSpace(stderr.String())
266 if code == protocol.ExitFailure {
267 return Result{Retry: true, Reason: reason}
268 }
269 // Dispatch has audited a denied or not-found refusal already; this
270 // row says it came by mail and why.
271 return p.refuse(u.ID, msgID, "comment refused: "+reason)
272}
273
274// createdAfter refuses when the row was created after the token was
275// minted: a later account or repository that took a freed id. Created
276// times are compared to the second, the token's precision.
277func (p *Processor) createdAfter(table string, id int64, target mailreply.Target, msgID, what string) *Result {
278 created, err := p.St.CreatedAt(table, id)
279 if err != nil {
280 return &Result{Retry: true, Reason: err.Error()}
281 }
282 if created.Truncate(time.Second).After(target.Issued()) {
283 r := p.refuse(0, msgID, what+" created after the reply token was issued")
284 return &r
285 }
286 return nil
287}
288
289func (p *Processor) now() time.Time {
290 if p.Now != nil {
291 return p.Now()
292 }
293 return time.Now()
294}
295
296func (p *Processor) refuse(actor int64, msgID, reason string) Result {
297 p.audit(actor, msgID, reason)
298 return Result{Reason: reason}
299}
300
301// audit records a refusal: the reason and the Message-ID, never content
302// from the message. Past refusalsPerMinute rows in a minute, the rest of
303// that minute's refusals are counted in one row, written with the first
304// refusal after it.
305func (p *Processor) audit(actor int64, msgID, reason string) {
306 now := p.now()
307 if now.Sub(p.windowStart) >= time.Minute {
308 if over := p.windowRows - refusalsPerMinute; over > 0 {
309 p.St.Audit(0, "refused mail reply", map[string]any{"reason": "throttled", "dropped": over, "source": control.SourceMail})
310 }
311 p.windowStart, p.windowRows = now, 0
312 }
313 p.windowRows++
314 if p.windowRows > refusalsPerMinute {
315 return
316 }
317 data := map[string]any{"reason": reason, "source": control.SourceMail}
318 if msgID != "" {
319 data["message_id"] = msgID
320 }
321 p.St.Audit(actor, "refused mail reply", data)
322}
323
324// automatic reports an auto-responder's message (RFC 3834, and the
325// headers older responders use), which must not post a comment.
326func automatic(h mail.Header) bool {
327 if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
328 return true
329 }
330 switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
331 case "bulk", "junk", "list", "auto_reply":
332 return true
333 }
334 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != ""
335}
336
337// findToken returns the reply token from the first recipient header
338// that carries one.
339func findToken(h mail.Header, base string) string {
340 for _, name := range []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} {
341 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] {
342 addrs, err := mail.ParseAddressList(v)
343 if err != nil {
344 continue
345 }
346 for _, a := range addrs {
347 if tok, ok := mailreply.TokenFrom(base, a.Address); ok {
348 return tok
349 }
350 }
351 }
352 }
353 return ""
354}
355
356// Poller reads the configured mailbox every poll interval.
357type Poller struct {
358 P *Processor
359 In config.MailInbound
360}
361
362// Run polls until ctx is done.
363func (pl *Poller) Run(ctx context.Context) {
364 t := time.NewTicker(pl.In.Poll())
365 defer t.Stop()
366 for {
367 if err := pl.Once(); err != nil {
368 // The error names the server and the IMAP failure; the
369 // password never reaches it (imapc.Client.Login).
370 slog.Warn("mail reply: poll failed", "server", pl.In.Addr(), "err", err)
371 }
372 select {
373 case <-ctx.Done():
374 return
375 case <-t.C:
376 }
377 }
378}
379
380// Once connects, handles what is waiting, and disconnects.
381func (pl *Poller) Once() error {
382 c, _, err := imapc.Open(pl.In, false, time.Minute)
383 if err != nil {
384 return err
385 }
386 defer c.Close()
387 c.SetDeadline(time.Now().Add(10 * time.Minute))
388 if err := pl.P.Drain(c); err != nil {
389 return err
390 }
391 return pl.P.St.PruneMailReplies(pl.P.now().Add(-mailreply.Lifetime - 24*time.Hour))
392}
internal/mailin/mailin_test.go added +512
@@ -0,0 +1,512 @@
1package mailin
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "slices"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/imapc"
14 "gitbay.org/gitbay/internal/mailreply"
15 "gitbay.org/gitbay/internal/seal"
16 "gitbay.org/gitbay/internal/store"
17)
18
19const replyBase = "reply@gitbay.example"
20
21type fixture struct {
22 p *Processor
23 st *store.Store
24 repo store.Repo
25 issueID int64
26 bob int64
27 secrets [][]byte
28 issued time.Time // when the fixture's tokens are minted
29}
30
31// setup is alice's public repository alice/app with issue #1, and bob,
32// who has a verified address and reply by mail on.
33func setup(t *testing.T) *fixture {
34 t.Helper()
35 st, err := store.Open(":memory:")
36 if err != nil {
37 t.Fatal(err)
38 }
39 t.Cleanup(func() { st.Close() })
40 if err := st.MigrateUp(); err != nil {
41 t.Fatal(err)
42 }
43 key, err := seal.NewKey()
44 if err != nil {
45 t.Fatal(err)
46 }
47 keyFile := t.TempDir() + "/secret.key"
48 if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
49 t.Fatal(err)
50 }
51 ring, err := seal.Load(keyFile)
52 if err != nil {
53 t.Fatal(err)
54 }
55 st.SetKeyring(ring)
56 alice, err := st.CreateUser("alice", false)
57 if err != nil {
58 t.Fatal(err)
59 }
60 bob, err := st.CreateUser("bob", false)
61 if err != nil {
62 t.Fatal(err)
63 }
64 if err := st.AddEmail(bob, "Bob@Example.test", "admin", true); err != nil {
65 t.Fatal(err)
66 }
67 if err := st.AddEmail(bob, "old@example.test", "", false); err != nil {
68 t.Fatal(err)
69 }
70 st.SetReplyEnabled(bob, true)
71 repoID, err := st.CreateRepo("user", alice, "app", "public")
72 if err != nil {
73 t.Fatal(err)
74 }
75 repo, err := st.RepoByID(repoID)
76 if err != nil {
77 t.Fatal(err)
78 }
79 issueID, err := st.CreateIssue(repo.ID, alice, "title", "", "md")
80 if err != nil {
81 t.Fatal(err)
82 }
83 var cfg config.Config
84 cfg.Server.SiteURL = "https://gitbay.example"
85 cfg.Mail.Inbound = config.MailInbound{Enabled: true, ReplyAddress: replyBase}
86 secrets, err := ring.Derive(mailreply.Purpose)
87 if err != nil {
88 t.Fatal(err)
89 }
90 return &fixture{p: &Processor{St: st, Cfg: cfg}, st: st, repo: repo,
91 issueID: issueID, bob: bob, secrets: secrets, issued: time.Now()}
92}
93
94func (f *fixture) token(t *testing.T, user int64) string {
95 t.Helper()
96 tok, err := mailreply.Mint(f.secrets, mailreply.Target{UserID: user, RepoID: f.repo.ID, Kind: "issue", Number: 1},
97 f.issued.Add(mailreply.Lifetime))
98 if err != nil {
99 t.Fatal(err)
100 }
101 return tok
102}
103
104var msgSeq int
105
106func (f *fixture) message(t *testing.T, from, body string) string {
107 t.Helper()
108 msgSeq++
109 return f.messageAs(t, f.bob, from, fmt.Sprintf("<m%d@example.test>", msgSeq), "", body)
110}
111
112// messageAs is a reply from user's token with the given Message-ID and
113// extra header lines (each ending in CRLF).
114func (f *fixture) messageAs(t *testing.T, user int64, from, msgID, headers, body string) string {
115 t.Helper()
116 return fmt.Sprintf("%sFrom: Someone <%s>\r\nTo: gitbay <%s>\r\nSubject: Re: [alice/app] #1: title\r\nMessage-ID: %s\r\n"+
117 "Content-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", headers, from, mailreply.Address(replyBase, f.token(t, user)), msgID, body)
118}
119
120func (f *fixture) comments(t *testing.T) []store.IssueComment {
121 t.Helper()
122 cs, err := f.st.ListIssueComments(f.issueID)
123 if err != nil {
124 t.Fatal(err)
125 }
126 return cs
127}
128
129// refusalReasons reads back the audit rows the processor wrote.
130func (f *fixture) refusalReasons(t *testing.T) string {
131 t.Helper()
132 entries, err := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "refused mail reply", Limit: 100})
133 if err != nil {
134 t.Fatal(err)
135 }
136 var b strings.Builder
137 for _, e := range entries {
138 b.WriteString(e.Data + "\n")
139 }
140 return b.String()
141}
142
143func TestReplyPostsComment(t *testing.T) {
144 f := setup(t)
145 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "Looks good.\r\n\r\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <x@y> wrote:\r\n> opened issue #1\r\n")))
146 if !res.Posted {
147 t.Fatalf("not posted: %+v", res)
148 }
149 cs := f.comments(t)
150 if len(cs) != 1 || cs[0].Body != "Looks good." || cs[0].Author != "bob" {
151 t.Fatalf("comments = %+v", cs)
152 }
153 entries, _ := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "cmd issue comment", Limit: 10})
154 if len(entries) != 1 || !strings.Contains(entries[0].Data, `"source":"mail"`) {
155 t.Fatalf("audit = %+v", entries)
156 }
157}
158
159func TestReplyRefusals(t *testing.T) {
160 for _, tc := range []struct {
161 name string
162 prep func(t *testing.T, f *fixture) string // returns the message
163 reason string
164 }{
165 {"wrong From", func(t *testing.T, f *fixture) string {
166 return f.message(t, "mallory@example.test", "hi")
167 }, "From is not a verified address"},
168 {"unverified From", func(t *testing.T, f *fixture) string {
169 return f.message(t, "old@example.test", "hi")
170 }, "From is not a verified address"},
171 {"revoked access", func(t *testing.T, f *fixture) string {
172 f.st.SetRepoVisibility(f.repo.ID, "private")
173 return f.message(t, "bob@example.test", "hi")
174 }, "comment refused: repository alice/app not found"},
175 {"disabled account", func(t *testing.T, f *fixture) string {
176 f.st.SetUserDisabled(f.bob, true)
177 return f.message(t, "bob@example.test", "hi")
178 }, "account disabled"},
179 {"archived repository", func(t *testing.T, f *fixture) string {
180 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
181 return f.message(t, "bob@example.test", "hi")
182 }, "archived"},
183 {"expired token", func(t *testing.T, f *fixture) string {
184 f.p.Now = func() time.Time { return time.Now().Add(mailreply.Lifetime + time.Hour) }
185 return f.message(t, "bob@example.test", "hi")
186 }, "reply token expired"},
187 {"reply turned off", func(t *testing.T, f *fixture) string {
188 f.st.SetReplyEnabled(f.bob, false)
189 return f.message(t, "bob@example.test", "hi")
190 }, "reply by mail is off"},
191 {"forged token", func(t *testing.T, f *fixture) string {
192 m := f.message(t, "bob@example.test", "hi")
193 tok := f.token(t, f.bob)
194 c := "a"
195 if tok[0] == 'a' {
196 c = "b"
197 }
198 return strings.Replace(m, tok, c+tok[1:], 1)
199 }, "does not verify"},
200 {"no reply address", func(t *testing.T, f *fixture) string {
201 return strings.Replace(f.message(t, "bob@example.test", "hi"), "reply+", "other+", 1)
202 }, "not addressed to a reply address"},
203 {"empty after stripping", func(t *testing.T, f *fixture) string {
204 return f.message(t, "bob@example.test", "> quoted only\r\n-- \r\nBob")
205 }, "empty reply"},
206 {"automatic reply", func(t *testing.T, f *fixture) string {
207 return "Auto-Submitted: auto-replied\r\n" + f.message(t, "bob@example.test", "I am away")
208 }, "automatic reply"},
209 {"html only", func(t *testing.T, f *fixture) string {
210 return strings.Replace(f.message(t, "bob@example.test", "<p>hi</p>"), "text/plain", "text/html", 1)
211 }, "no text/plain part"},
212 {"too long", func(t *testing.T, f *fixture) string {
213 return f.message(t, "bob@example.test", strings.Repeat("a", 70<<10))
214 }, "reply too long"},
215 } {
216 t.Run(tc.name, func(t *testing.T) {
217 f := setup(t)
218 res := f.p.Handle([]byte(tc.prep(t, f)))
219 if res.Posted || res.Retry || !strings.Contains(res.Reason, tc.reason) {
220 t.Fatalf("result %+v, want refusal %q", res, tc.reason)
221 }
222 if n := len(f.comments(t)); n != 0 {
223 t.Fatalf("%d comments posted", n)
224 }
225 audit := f.refusalReasons(t)
226 if !strings.Contains(audit, tc.reason) {
227 t.Fatalf("audit does not name the reason:\n%s", audit)
228 }
229 if strings.Contains(audit, "I am away") || strings.Contains(audit, "<p>hi") {
230 t.Fatalf("audit carries message content:\n%s", audit)
231 }
232 })
233 }
234}
235
236func TestDuplicateMessageID(t *testing.T) {
237 f := setup(t)
238 m := []byte(f.message(t, "bob@example.test", "once"))
239 if res := f.p.Handle(m); !res.Posted {
240 t.Fatalf("first: %+v", res)
241 }
242 if res := f.p.Handle(m); res.Posted || !strings.Contains(res.Reason, "already posted") {
243 t.Fatalf("second: %+v", res)
244 }
245 if n := len(f.comments(t)); n != 1 {
246 t.Fatalf("%d comments", n)
247 }
248}
249
250// A refused reply leaves no claim, so the same message is judged afresh.
251func TestRefusalLeavesNoClaim(t *testing.T) {
252 f := setup(t)
253 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
254 m := []byte(f.message(t, "bob@example.test", "hi"))
255 if res := f.p.Handle(m); res.Posted {
256 t.Fatal("posted to an archived repository")
257 }
258 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = false })
259 if res := f.p.Handle(m); !res.Posted {
260 t.Fatalf("after unarchive: %+v", res)
261 }
262}
263
264type fakeMailbox struct {
265 msgs map[uint32][]byte
266 seen map[uint32]bool
267 errs map[uint32]error
268}
269
270func (m *fakeMailbox) Unseen() ([]uint32, error) {
271 var out []uint32
272 for uid := range m.msgs {
273 if !m.seen[uid] {
274 out = append(out, uid)
275 }
276 }
277 slices.Sort(out)
278 return out, nil
279}
280
281func (m *fakeMailbox) Fetch(uid uint32) ([]byte, error) {
282 if err := m.errs[uid]; err != nil {
283 return nil, err
284 }
285 if m.msgs[uid] == nil {
286 return nil, imapc.ErrTooLarge
287 }
288 return m.msgs[uid], nil
289}
290
291func (m *fakeMailbox) MarkSeen(uid uint32) error {
292 m.seen[uid] = true
293 return nil
294}
295
296// Posted and refused messages alike are marked seen.
297func TestDrainMarksSeen(t *testing.T) {
298 f := setup(t)
299 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
300 1: []byte(f.message(t, "bob@example.test", "posted")),
301 2: []byte(f.message(t, "mallory@example.test", "refused")),
302 3: nil, // too large
303 }}
304 if err := f.p.Drain(mb); err != nil {
305 t.Fatal(err)
306 }
307 for uid := range mb.msgs {
308 if !mb.seen[uid] {
309 t.Errorf("message %d not marked seen", uid)
310 }
311 }
312 if n := len(f.comments(t)); n != 1 {
313 t.Fatalf("%d comments", n)
314 }
315}
316
317// A message that fails for a reason that may pass stays unseen, until it
318// has failed maxTries times.
319func TestDrainRetriesTransientFailure(t *testing.T) {
320 f := setup(t)
321 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
322 1: []byte(f.message(t, "bob@example.test", "hi")),
323 }}
324 f.st.SetKeyring(nil) // Handle reads no key: a retry
325 for i := 1; i < maxTries; i++ {
326 if err := f.p.Drain(mb); err != nil {
327 t.Fatal(err)
328 }
329 if mb.seen[1] {
330 t.Fatalf("marked seen after %d tries", i)
331 }
332 }
333 f.p.Drain(mb)
334 if !mb.seen[1] {
335 t.Fatal("not given up on")
336 }
337}
338
339// A repository id freed by a delete and taken by a later repository does
340// not accept replies meant for the old one.
341func TestReusedRepositoryID(t *testing.T) {
342 f := setup(t)
343 m := []byte(f.message(t, "bob@example.test", "hi"))
344 if err := f.st.DeleteRepo(f.repo.ID); err != nil {
345 t.Fatal(err)
346 }
347 alice, _ := f.st.UserByUsername("alice")
348 id, err := f.st.CreateRepo("user", alice.ID, "other", "public")
349 if err != nil || id != f.repo.ID {
350 t.Fatalf("new repository has id %d (%v), want the freed %d", id, err, f.repo.ID)
351 }
352 f.st.CreateIssue(id, alice.ID, "t", "", "md")
353 // Created after the token, as it would be outside a fast test.
354 f.st.DB.Exec("UPDATE repos SET created_at = ? WHERE id = ?",
355 time.Now().Add(5*time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), id)
356 res := f.p.Handle(m)
357 if res.Posted || !strings.Contains(res.Reason, "repository created after the reply token") {
358 t.Fatalf("result %+v", res)
359 }
360 if !strings.Contains(f.refusalReasons(t), "repository created after") {
361 t.Fatal("refusal not audited")
362 }
363}
364
365func TestReusedUserID(t *testing.T) {
366 f := setup(t)
367 f.st.DB.Exec("UPDATE users SET created_at = ? WHERE id = ?",
368 time.Now().Add(5*time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), f.bob)
369 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi")))
370 if res.Posted || !strings.Contains(res.Reason, "account created after the reply token") {
371 t.Fatalf("result %+v", res)
372 }
373}
374
375// One account's Message-ID does not suppress another account's reply.
376func TestDedupePerAccount(t *testing.T) {
377 f := setup(t)
378 carol, err := f.st.CreateUser("carol", false)
379 if err != nil {
380 t.Fatal(err)
381 }
382 f.st.AddEmail(carol, "carol@example.test", "admin", true)
383 f.st.SetReplyEnabled(carol, true)
384 if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<same@x>", "", "from bob"))); !res.Posted {
385 t.Fatalf("bob: %+v", res)
386 }
387 if res := f.p.Handle([]byte(f.messageAs(t, carol, "carol@example.test", "<same@x>", "", "from carol"))); !res.Posted {
388 t.Fatalf("carol: %+v", res)
389 }
390 if n := len(f.comments(t)); n != 2 {
391 t.Fatalf("%d comments", n)
392 }
393}
394
395func TestEmptyMessage(t *testing.T) {
396 f := setup(t)
397 if res := f.p.Handle([]byte{}); res.Posted || res.Reason != "empty message" {
398 t.Fatalf("result %+v", res)
399 }
400}
401
402// A fetch that keeps failing counts tries for that message alone; the
403// rest of the mailbox is handled, and after maxTries the failing one is
404// marked seen and audited.
405func TestDrainFetchErrors(t *testing.T) {
406 f := setup(t)
407 mb := &fakeMailbox{seen: map[uint32]bool{},
408 msgs: map[uint32][]byte{1: []byte("x"), 2: []byte(f.message(t, "bob@example.test", "hi"))},
409 errs: map[uint32]error{1: &imapc.RefusedError{Text: "NO [UNAVAILABLE] try later"}}}
410 for i := 1; i < maxTries; i++ {
411 if err := f.p.Drain(mb); err != nil {
412 t.Fatal(err)
413 }
414 if mb.seen[1] {
415 t.Fatalf("marked seen after %d tries", i)
416 }
417 if !mb.seen[2] {
418 t.Fatal("the next message was not handled")
419 }
420 }
421 f.p.Drain(mb)
422 if !mb.seen[1] || !strings.Contains(f.refusalReasons(t), "gave up after") {
423 t.Fatal("not given up on and audited")
424 }
425}
426
427// A fetch the server cut off ends the poll; the message is given up on
428// unread once it has cost maxTries polls.
429func TestDrainLimitEndsPoll(t *testing.T) {
430 f := setup(t)
431 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte("x")},
432 errs: map[uint32]error{1: imapc.ErrLimit}}
433 for i := 0; i < maxTries; i++ {
434 if err := f.p.Drain(mb); !errors.Is(err, imapc.ErrLimit) {
435 t.Fatalf("poll %d: %v", i, err)
436 }
437 }
438 if err := f.p.Drain(mb); err != nil || !mb.seen[1] {
439 t.Fatalf("not given up on: %v", err)
440 }
441}
442
443func TestAuthenticationResults(t *testing.T) {
444 const id = "mx.example.net"
445 for _, tc := range []struct {
446 name, headers, from, reason string
447 }{
448 {"dmarc pass",
449 "Authentication-Results: mx.example.net; spf=pass smtp.mailfrom=example.test; dmarc=pass (p=REJECT) header.from=example.test\r\n",
450 "bob@example.test", ""},
451 {"aligned dkim pass",
452 "Authentication-Results: mx.example.net;\r\n dkim=pass header.d=mail.example.test header.s=s1 header.b=abc\r\n",
453 "bob@example.test", ""},
454 {"dkim header.i without header.d",
455 "Authentication-Results: mx.example.net; dkim=pass header.i=@example.test\r\n",
456 "bob@example.test", "sender not authenticated"},
457 {"dmarc fail",
458 "Authentication-Results: mx.example.net; dkim=fail header.d=example.test; dmarc=fail header.from=example.test\r\n",
459 "bob@example.test", "sender not authenticated"},
460 {"missing header", "", "bob@example.test", "no Authentication-Results from mx.example.net"},
461 {"spoofed lower header with the same id",
462 "Authentication-Results: mx.example.net; dmarc=fail header.from=example.test\r\nAuthentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n",
463 "bob@example.test", "sender not authenticated"},
464 {"other authserv only",
465 "Authentication-Results: evil.example; dmarc=pass header.from=example.test\r\n",
466 "bob@example.test", "no Authentication-Results from mx.example.net"},
467 {"misaligned dkim domain",
468 "Authentication-Results: mx.example.net; dkim=pass header.d=attacker.example; dmarc=none header.from=example.test\r\n",
469 "bob@example.test", "sender not authenticated"},
470 {"dmarc pass for another domain",
471 "Authentication-Results: mx.example.net; dmarc=pass header.from=attacker.example\r\n",
472 "bob@example.test", "sender not authenticated"},
473 } {
474 t.Run(tc.name, func(t *testing.T) {
475 f := setup(t)
476 f.p.Cfg.Mail.Inbound.TrustedAuthservID = id
477 res := f.p.Handle([]byte(f.messageAs(t, f.bob, tc.from, "<a@x>", tc.headers, "hi")))
478 if tc.reason == "" {
479 if !res.Posted {
480 t.Fatalf("not posted: %+v", res)
481 }
482 return
483 }
484 if res.Posted || !strings.Contains(res.Reason, tc.reason) {
485 t.Fatalf("result %+v, want %q", res, tc.reason)
486 }
487 if !strings.Contains(f.refusalReasons(t), tc.reason) {
488 t.Fatal("refusal not audited")
489 }
490 })
491 }
492}
493
494// A timeout mid-poll ends the poll and counts no try, neither for the
495// message being fetched nor for the ones after it.
496func TestDrainTimeoutCountsNoTries(t *testing.T) {
497 f := setup(t)
498 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
499 1: []byte(f.message(t, "bob@example.test", "first")),
500 2: []byte("x"),
501 3: []byte(f.message(t, "bob@example.test", "third")),
502 }, errs: map[uint32]error{2: fmt.Errorf("read: %w", os.ErrDeadlineExceeded)}}
503 if err := f.p.Drain(mb); !errors.Is(err, os.ErrDeadlineExceeded) {
504 t.Fatalf("Drain = %v", err)
505 }
506 if !mb.seen[1] || mb.seen[2] || mb.seen[3] {
507 t.Fatalf("seen = %v", mb.seen)
508 }
509 if f.p.tries[2] != 0 || f.p.tries[3] != 0 {
510 t.Fatalf("tries = %v", f.p.tries)
511 }
512}
internal/mailreply/mailreply.go added +171
@@ -0,0 +1,171 @@
1// Package mailreply mints and verifies the token in a notification's
2// Reply-To address, reply+<token>@<domain> (#295). The token names the
3// recipient, the repository and the thread, and an expiry; an HMAC under
4// a key derived from the instance's secret key file binds them, so no
5// row is stored per message.
6package mailreply
7
8import (
9 "crypto/hmac"
10 "crypto/sha256"
11 "encoding/base32"
12 "encoding/binary"
13 "errors"
14 "fmt"
15 "strings"
16 "time"
17)
18
19// Lifetime is how long a notification's reply address is accepted.
20const Lifetime = 30 * 24 * time.Hour
21
22// Purpose is what the MAC key is derived for (seal.Keyring.Derive).
23const Purpose = "gitbay mail reply token v1"
24
25const (
26 version = 1
27 macLen = 12
28)
29
30// Target is the thread a reply posts to, and who it posts as.
31type Target struct {
32 UserID int64
33 RepoID int64
34 Kind string // "issue" or "mr"
35 Number int64
36 // Expires is set by Verify; Mint takes the expiry separately.
37 Expires time.Time
38}
39
40// Issued is when the token was minted: every token is minted to expire
41// Lifetime later. An account or repository created after it is not the
42// one the token named, but a later row that took a freed id.
43func (t Target) Issued() time.Time { return t.Expires.Add(-Lifetime) }
44
45var (
46 ErrMalformed = errors.New("malformed reply token")
47 ErrBadMAC = errors.New("reply token does not verify")
48 ErrExpired = errors.New("reply token expired")
49)
50
51// Mail systems may fold the local part to lower case, so the token is
52// lower-case base32.
53var enc = base32.StdEncoding.WithPadding(base32.NoPadding)
54
55// Mint returns the token for t, valid until expires, authenticated under
56// keys[0]. expires is the mint time plus Lifetime (Target.Issued).
57func Mint(keys [][]byte, t Target, expires time.Time) (string, error) {
58 if len(keys) == 0 {
59 return "", errors.New("no key to mint a reply token under")
60 }
61 var kind byte
62 switch t.Kind {
63 case "issue":
64 kind = 'i'
65 case "mr":
66 kind = 'm'
67 default:
68 return "", fmt.Errorf("reply token: unknown kind %q", t.Kind)
69 }
70 if t.UserID <= 0 || t.RepoID <= 0 || t.Number <= 0 {
71 return "", errors.New("reply token: ids must be positive")
72 }
73 p := []byte{version, kind}
74 p = binary.AppendUvarint(p, uint64(t.UserID))
75 p = binary.AppendUvarint(p, uint64(t.RepoID))
76 p = binary.AppendUvarint(p, uint64(t.Number))
77 p = binary.AppendUvarint(p, uint64(expires.Unix()))
78 p = append(p, mac(keys[0], p)...)
79 return strings.ToLower(enc.EncodeToString(p)), nil
80}
81
82// Verify checks token against every key and returns its target. An
83// expired token that verifies returns its target with ErrExpired, so the
84// refusal can name the account.
85func Verify(keys [][]byte, token string, now time.Time) (Target, error) {
86 up := strings.ToUpper(token)
87 raw, err := enc.DecodeString(up)
88 // Only the canonical encoding is accepted: base32 leaves spare bits
89 // in the last character, and a character past the last byte.
90 if err != nil || len(raw) < 2+4+macLen || enc.EncodeToString(raw) != up {
91 return Target{}, ErrMalformed
92 }
93 p, sum := raw[:len(raw)-macLen], raw[len(raw)-macLen:]
94 ok := false
95 for _, k := range keys {
96 if hmac.Equal(mac(k, p), sum) {
97 ok = true
98 }
99 }
100 if !ok {
101 return Target{}, ErrBadMAC
102 }
103 if p[0] != version {
104 return Target{}, ErrMalformed
105 }
106 var t Target
107 switch p[1] {
108 case 'i':
109 t.Kind = "issue"
110 case 'm':
111 t.Kind = "mr"
112 default:
113 return Target{}, ErrMalformed
114 }
115 rest := p[2:]
116 var v [4]uint64
117 for i := range v {
118 n, w := binary.Uvarint(rest)
119 if w <= 0 || n > 1<<62 {
120 return Target{}, ErrMalformed
121 }
122 v[i], rest = n, rest[w:]
123 }
124 if len(rest) != 0 {
125 return Target{}, ErrMalformed
126 }
127 t.UserID, t.RepoID, t.Number = int64(v[0]), int64(v[1]), int64(v[2])
128 t.Expires = time.Unix(int64(v[3]), 0).UTC()
129 if !now.Before(t.Expires) {
130 return t, ErrExpired
131 }
132 return t, nil
133}
134
135func mac(key, p []byte) []byte {
136 m := hmac.New(sha256.New, key)
137 m.Write(p)
138 return m.Sum(nil)[:macLen]
139}
140
141// Address puts token into base, the configured reply address:
142// reply@example.org becomes reply+<token>@example.org.
143func Address(base, token string) string {
144 local, domain, _ := strings.Cut(base, "@")
145 return local + "+" + token + "@" + domain
146}
147
148// TokenFrom returns the token in addr when addr is base with a token
149// added; the comparison ignores case.
150func TokenFrom(base, addr string) (string, bool) {
151 local, domain, ok := strings.Cut(base, "@")
152 if !ok {
153 return "", false
154 }
155 i := strings.LastIndexByte(addr, '@')
156 if i < 0 || !strings.EqualFold(addr[i+1:], domain) {
157 return "", false
158 }
159 tok, ok := cutPrefixFold(addr[:i], local+"+")
160 if !ok || tok == "" {
161 return "", false
162 }
163 return tok, true
164}
165
166func cutPrefixFold(s, prefix string) (string, bool) {
167 if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
168 return "", false
169 }
170 return s[len(prefix):], true
171}
internal/mailreply/mailreply_test.go added +144
@@ -0,0 +1,144 @@
1package mailreply
2
3import (
4 "errors"
5 "strings"
6 "testing"
7 "time"
8)
9
10var (
11 keyA = []byte("0123456789abcdef0123456789abcdef")
12 keyB = []byte("fedcba9876543210fedcba9876543210")
13 now = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
14)
15
16func mint(t *testing.T, keys [][]byte, tg Target) string {
17 t.Helper()
18 tok, err := Mint(keys, tg, now.Add(Lifetime))
19 if err != nil {
20 t.Fatal(err)
21 }
22 return tok
23}
24
25func TestRoundTrip(t *testing.T) {
26 for _, tg := range []Target{
27 {UserID: 1, RepoID: 2, Kind: "issue", Number: 3},
28 {UserID: 1 << 40, RepoID: 99999, Kind: "mr", Number: 123456},
29 } {
30 tok := mint(t, [][]byte{keyA}, tg)
31 if tok != strings.ToLower(tok) {
32 t.Errorf("token %q is not lower case", tok)
33 }
34 // The address's local part stays within 64 octets.
35 if l := len("reply+" + tok); l > 64 {
36 t.Errorf("local part is %d octets", l)
37 }
38 got, err := Verify([][]byte{keyA}, tok, now)
39 tg.Expires = now.Add(Lifetime)
40 if err != nil || got != tg {
41 t.Errorf("Verify = %+v, %v; want %+v", got, err, tg)
42 }
43 if !got.Issued().Equal(now) {
44 t.Errorf("Issued = %v, want %v", got.Issued(), now)
45 }
46 // A mail system that upper-cases the local part does not break it.
47 if got, err := Verify([][]byte{keyA}, strings.ToUpper(tok), now); err != nil || got != tg {
48 t.Errorf("upper-case Verify = %+v, %v", got, err)
49 }
50 }
51}
52
53// A token minted before a rotation verifies while the old key is in the file.
54func TestRotation(t *testing.T) {
55 tg := Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}
56 tok := mint(t, [][]byte{keyA}, tg)
57 if _, err := Verify([][]byte{keyB, keyA}, tok, now); err != nil {
58 t.Fatal(err)
59 }
60 if _, err := Verify([][]byte{keyB}, tok, now); !errors.Is(err, ErrBadMAC) {
61 t.Fatalf("retired key: %v", err)
62 }
63}
64
65func TestTamper(t *testing.T) {
66 tok := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
67 for i := range tok {
68 c := byte('a')
69 if tok[i] == 'a' {
70 c = 'b'
71 }
72 bad := tok[:i] + string(c) + tok[i+1:]
73 if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
74 t.Fatalf("changed character %d verified", i)
75 }
76 }
77 for _, bad := range []string{"", "x", "!!!!", tok[:len(tok)-1], tok + "a"} {
78 if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
79 t.Errorf("%q verified", bad)
80 }
81 }
82}
83
84func TestExpiry(t *testing.T) {
85 tg := Target{UserID: 1, RepoID: 2, Kind: "mr", Number: 3}
86 tok := mint(t, [][]byte{keyA}, tg)
87 if _, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime-time.Hour)); err != nil {
88 t.Fatalf("before expiry: %v", err)
89 }
90 got, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime+time.Hour))
91 tg.Expires = now.Add(Lifetime)
92 if !errors.Is(err, ErrExpired) || got != tg {
93 t.Fatalf("after expiry: %+v, %v", got, err)
94 }
95}
96
97// Two recipients of one notification get different tokens, and neither
98// verifies as the other.
99func TestCrossUser(t *testing.T) {
100 a := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
101 b := mint(t, [][]byte{keyA}, Target{UserID: 4, RepoID: 2, Kind: "issue", Number: 3})
102 if a == b {
103 t.Fatal("two recipients share a token")
104 }
105 ga, _ := Verify([][]byte{keyA}, a, now)
106 gb, _ := Verify([][]byte{keyA}, b, now)
107 if ga.UserID != 1 || gb.UserID != 4 {
108 t.Fatalf("got users %d and %d", ga.UserID, gb.UserID)
109 }
110}
111
112func TestMintRefuses(t *testing.T) {
113 for _, tg := range []Target{
114 {UserID: 1, RepoID: 2, Kind: "build", Number: 3},
115 {UserID: 0, RepoID: 2, Kind: "issue", Number: 3},
116 } {
117 if _, err := Mint([][]byte{keyA}, tg, now); err == nil {
118 t.Errorf("minted %+v", tg)
119 }
120 }
121 if _, err := Mint(nil, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}, now); err == nil {
122 t.Error("minted with no key")
123 }
124}
125
126func TestAddress(t *testing.T) {
127 a := Address("reply@gitbay.example", "abc")
128 if a != "reply+abc@gitbay.example" {
129 t.Fatalf("Address = %q", a)
130 }
131 for addr, want := range map[string]string{
132 "reply+abc@gitbay.example": "abc",
133 "Reply+ABC@GITBAY.example": "ABC",
134 "reply@gitbay.example": "",
135 "reply+@gitbay.example": "",
136 "reply+abc@other.example": "",
137 "other+abc@gitbay.example": "",
138 } {
139 got, ok := TokenFrom("reply@gitbay.example", addr)
140 if got != want || ok != (want != "") {
141 t.Errorf("TokenFrom(%q) = %q, %v", addr, got, ok)
142 }
143 }
144}
internal/notify/notify.go +1 −1
@@ -50,7 +50,7 @@ func (m *Mailer) Run(ctx context.Context) {
50 continue 50 continue
51 } 51 }
52 for _, q := range due { 52 for _, q := range due {
53 if err := mail.Send(m.Cfg, q.Recipient, q.Subject, q.Body); err != nil { 53 if err := mail.SendReplyTo(m.Cfg, q.Recipient, q.ReplyTo, q.Subject, q.Body); err != nil {
54 attempt := q.Attempts + 1 54 attempt := q.Attempts + 1
55 if attempt >= m.MaxAttempts { 55 if attempt >= m.MaxAttempts {
56 m.St.MarkMailFailed(q.ID, err.Error(), nil) 56 m.St.MarkMailFailed(q.ID, err.Error(), nil)
internal/seal/seal.go +31 −1
@@ -10,7 +10,9 @@ import (
10 "bytes" 10 "bytes"
11 "crypto/aes" 11 "crypto/aes"
12 "crypto/cipher" 12 "crypto/cipher"
13 "crypto/hmac"
13 "crypto/rand" 14 "crypto/rand"
15 "crypto/sha256"
14 "encoding/base64" 16 "encoding/base64"
15 "encoding/hex" 17 "encoding/hex"
16 "errors" 18 "errors"
@@ -180,6 +182,8 @@ type Keyring struct {
180 fi os.FileInfo 182 fi os.FileInfo
181 cur string 183 cur string
182 aead map[string]cipher.AEAD 184 aead map[string]cipher.AEAD
185 // secrets holds every key's secret, the current key's first.
186 secrets [][]byte
183} 187}
184 188
185// Load reads the key file at path and returns a Keyring over it. It 189// Load reads the key file at path and returns a Keyring over it. It
@@ -206,6 +210,10 @@ func (k *Keyring) refresh() error {
206 return err 210 return err
207 } 211 }
208 aead := make(map[string]cipher.AEAD, len(keys)) 212 aead := make(map[string]cipher.AEAD, len(keys))
213 secrets := make([][]byte, 0, len(keys))
214 for i := len(keys) - 1; i >= 0; i-- {
215 secrets = append(secrets, keys[i].Secret)
216 }
209 for _, key := range keys { 217 for _, key := range keys {
210 block, err := aes.NewCipher(key.Secret) 218 block, err := aes.NewCipher(key.Secret)
211 if err != nil { 219 if err != nil {
@@ -217,10 +225,32 @@ func (k *Keyring) refresh() error {
217 } 225 }
218 aead[key.ID] = g 226 aead[key.ID] = g
219 } 227 }
220 k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead 228 k.fi, k.cur, k.aead, k.secrets = fi, keys[len(keys)-1].ID, aead, secrets
221 return nil 229 return nil
222} 230}
223 231
232// Derive returns a 32-byte key for purpose from every key in the file,
233// the current key's first: HMAC-SHA256 of purpose under each secret. A
234// value authenticated under the first still verifies under the others
235// after a rotation, while the retired key stays in the file.
236func (k *Keyring) Derive(purpose string) ([][]byte, error) {
237 if purpose == "" {
238 return nil, errors.New("seal: a purpose is required")
239 }
240 k.mu.Lock()
241 defer k.mu.Unlock()
242 if err := k.refresh(); err != nil {
243 return nil, err
244 }
245 out := make([][]byte, 0, len(k.secrets))
246 for _, s := range k.secrets {
247 m := hmac.New(sha256.New, s)
248 m.Write([]byte(purpose))
249 out = append(out, m.Sum(nil))
250 }
251 return out, nil
252}
253
224// CurrentID is the id of the key that seals new values. 254// CurrentID is the id of the key that seals new values.
225func (k *Keyring) CurrentID() (string, error) { 255func (k *Keyring) CurrentID() (string, error) {
226 k.mu.Lock() 256 k.mu.Lock()
internal/seal/seal_test.go +31
@@ -232,3 +232,34 @@ func TestReadKeysRefusesDuplicatesDirectoriesAndLargeFiles(t *testing.T) {
232 t.Error("read a file over the size limit") 232 t.Error("read a file over the size limit")
233 } 233 }
234} 234}
235
236// Derive lists the current key's derivation first and keeps the retired
237// key's, and differs by purpose.
238func TestDerive(t *testing.T) {
239 old, cur := newKey(t), newKey(t)
240 one, err := Load(keyFile(t, old))
241 if err != nil {
242 t.Fatal(err)
243 }
244 two, err := Load(keyFile(t, old, cur))
245 if err != nil {
246 t.Fatal(err)
247 }
248 a, err := one.Derive("p")
249 if err != nil || len(a) != 1 || len(a[0]) != 32 {
250 t.Fatalf("Derive = %x, %v", a, err)
251 }
252 b, err := two.Derive("p")
253 if err != nil || len(b) != 2 {
254 t.Fatalf("Derive = %x, %v", b, err)
255 }
256 if string(b[1]) != string(a[0]) || string(b[0]) == string(a[0]) {
257 t.Fatal("rotated ring does not list the current key first and the old one after")
258 }
259 if c, _ := one.Derive("q"); string(c[0]) == string(a[0]) {
260 t.Fatal("two purposes derived the same key")
261 }
262 if _, err := one.Derive(""); err == nil {
263 t.Fatal("empty purpose accepted")
264 }
265}
internal/store/mailreply.go added +62
@@ -0,0 +1,62 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "strings"
7 "time"
8)
9
10// CreatedAt is when the account (table "users") or repository ("repos")
11// with id was created.
12func (s *Store) CreatedAt(table string, id int64) (time.Time, error) {
13 if table != "users" && table != "repos" {
14 return time.Time{}, errors.New("CreatedAt: unknown table " + table)
15 }
16 var v string
17 err := s.DB.QueryRow("SELECT created_at FROM "+table+" WHERE id = ?", id).Scan(&v)
18 if errors.Is(err, sql.ErrNoRows) {
19 return time.Time{}, ErrNotFound
20 }
21 if err != nil {
22 return time.Time{}, err
23 }
24 return time.Parse("2006-01-02T15:04:05.000Z", v)
25}
26
27// ClaimMailReply records that the reply identified by key is being
28// posted. False means an earlier fetch of the same message already
29// claimed it.
30func (s *Store) ClaimMailReply(key string) (bool, error) {
31 res, err := s.DB.Exec("INSERT INTO mail_replies (message_key) VALUES (?) ON CONFLICT DO NOTHING", key)
32 if err != nil {
33 return false, err
34 }
35 n, err := res.RowsAffected()
36 return n == 1, err
37}
38
39// ReleaseMailReply drops a claim whose comment was not posted, so the
40// message can be tried again.
41func (s *Store) ReleaseMailReply(key string) error {
42 _, err := s.DB.Exec("DELETE FROM mail_replies WHERE message_key = ?", key)
43 return err
44}
45
46// PruneMailReplies drops claims older than before. A reply older than a
47// reply token's lifetime is refused on its token, so its claim has no
48// work left to do.
49func (s *Store) PruneMailReplies(before time.Time) error {
50 _, err := s.DB.Exec("DELETE FROM mail_replies WHERE created_at < ?", fmtTime(before))
51 return err
52}
53
54// VerifiedEmailOf reports whether address is a verified address of the
55// account, ignoring case.
56func (s *Store) VerifiedEmailOf(userID int64, address string) (bool, error) {
57 var n int
58 err := s.DB.QueryRow(
59 "SELECT COUNT(*) FROM emails WHERE user_id = ? AND verified_at IS NOT NULL AND lower(address) = ?",
60 userID, strings.ToLower(address)).Scan(&n)
61 return n > 0, err
62}
internal/store/migrations/0073_mail_reply.down.sql added +3
@@ -0,0 +1,3 @@
1DROP TABLE mail_replies;
2ALTER TABLE notifications DROP COLUMN reply_to;
3ALTER TABLE users DROP COLUMN notify_reply;
internal/store/migrations/0073_mail_reply.up.sql added +13
@@ -0,0 +1,13 @@
1-- Reply by mail (#295). notify_reply puts a Reply-To carrying a reply
2-- token on the account's issue and merge request mail when the instance
3-- polls a mailbox for replies. notifications.reply_to is that address,
4-- per queued message, blanked once it is sent. mail_replies records each
5-- reply that posted a comment, keyed by account, thread and Message-ID
6-- (or a hash of the message when it has none), so a message fetched
7-- twice posts once.
8ALTER TABLE users ADD COLUMN notify_reply INTEGER NOT NULL DEFAULT 0;
9ALTER TABLE notifications ADD COLUMN reply_to TEXT NOT NULL DEFAULT '';
10CREATE TABLE mail_replies (
11 message_key TEXT PRIMARY KEY,
12 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
13);
internal/store/notify.go +14 −6
@@ -5,21 +5,29 @@ import "time"
5type QueuedMail struct { 5type QueuedMail struct {
6 ID int64 6 ID int64
7 Recipient string 7 Recipient string
8 ReplyTo string // "" for none
8 Subject string 9 Subject string
9 Body string 10 Body string
10 Attempts int 11 Attempts int
11} 12}
12 13
13func (s *Store) EnqueueMail(recipient, subject, body string) error { 14func (s *Store) EnqueueMail(recipient, subject, body string) error {
15 return s.EnqueueMailReplyTo(recipient, "", subject, body)
16}
17
18// EnqueueMailReplyTo queues mail with a Reply-To address. The address
19// carries a reply token, so it is blanked once the row is sent or
20// dead-lettered.
21func (s *Store) EnqueueMailReplyTo(recipient, replyTo, subject, body string) error {
14 _, err := s.DB.Exec( 22 _, err := s.DB.Exec(
15 "INSERT INTO notifications (recipient, subject, body) VALUES (?, ?, ?)", 23 "INSERT INTO notifications (recipient, reply_to, subject, body) VALUES (?, ?, ?, ?)",
16 recipient, subject, body) 24 recipient, replyTo, subject, body)
17 return err 25 return err
18} 26}
19 27
20func (s *Store) DueMail(limit int) ([]QueuedMail, error) { 28func (s *Store) DueMail(limit int) ([]QueuedMail, error) {
21 rows, err := s.DB.Query(` 29 rows, err := s.DB.Query(`
22 SELECT id, recipient, subject, body, attempts FROM notifications 30 SELECT id, recipient, reply_to, subject, body, attempts FROM notifications
23 WHERE sent_at IS NULL AND failed_at IS NULL 31 WHERE sent_at IS NULL AND failed_at IS NULL
24 AND (next_attempt_at IS NULL OR next_attempt_at <= ?) 32 AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
25 ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit) 33 ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit)
@@ -30,7 +38,7 @@ func (s *Store) DueMail(limit int) ([]QueuedMail, error) {
30 var out []QueuedMail 38 var out []QueuedMail
31 for rows.Next() { 39 for rows.Next() {
32 var m QueuedMail 40 var m QueuedMail
33 if err := rows.Scan(&m.ID, &m.Recipient, &m.Subject, &m.Body, &m.Attempts); err != nil { 41 if err := rows.Scan(&m.ID, &m.Recipient, &m.ReplyTo, &m.Subject, &m.Body, &m.Attempts); err != nil {
34 return nil, err 42 return nil, err
35 } 43 }
36 out = append(out, m) 44 out = append(out, m)
@@ -40,14 +48,14 @@ func (s *Store) DueMail(limit int) ([]QueuedMail, error) {
40 48
41func (s *Store) MarkMailSent(id int64) error { 49func (s *Store) MarkMailSent(id int64) error {
42 _, err := s.DB.Exec( 50 _, err := s.DB.Exec(
43 "UPDATE notifications SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id) 51 "UPDATE notifications SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, reply_to = '' WHERE id = ?", id)
44 return err 52 return err
45} 53}
46 54
47func (s *Store) MarkMailFailed(id int64, errMsg string, nextAt *time.Time) error { 55func (s *Store) MarkMailFailed(id int64, errMsg string, nextAt *time.Time) error {
48 if nextAt == nil { 56 if nextAt == nil {
49 _, err := s.DB.Exec( 57 _, err := s.DB.Exec(
50 "UPDATE notifications SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?", 58 "UPDATE notifications SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ?, reply_to = '' WHERE id = ?",
51 errMsg, id) 59 errMsg, id)
52 return err 60 return err
53 } 61 }
internal/store/secrets.go +3
@@ -48,6 +48,9 @@ var secretColumns = []secretColumn{
48// SetKeyring sets the keys the secret columns are sealed under. 48// SetKeyring sets the keys the secret columns are sealed under.
49func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } 49func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k }
50 50
51// Keyring is the loaded key file, nil when none is set.
52func (s *Store) Keyring() *seal.Keyring { return s.secrets }
53
51// sealValue seals v for storage. An empty value stays empty: for 54// sealValue seals v for storage. An empty value stays empty: for
52// webhooks and mirrors it means there is no secret. 55// webhooks and mirrors it means there is no secret.
53func (s *Store) sealValue(aad, v string) (string, error) { 56func (s *Store) sealValue(aad, v string) (string, error) {
internal/store/users.go +20
@@ -230,6 +230,26 @@ func (s *Store) SetMailEnabled(userID int64, on bool) error {
230 return err 230 return err
231} 231}
232 232
233// ReplyEnabled reports whether the account's issue and merge request
234// mail carries a reply address (#295).
235func (s *Store) ReplyEnabled(userID int64) (bool, error) {
236 var on int
237 err := s.DB.QueryRow("SELECT notify_reply FROM users WHERE id = ?", userID).Scan(&on)
238 if errors.Is(err, sql.ErrNoRows) {
239 return false, ErrNotFound
240 }
241 return on != 0, err
242}
243
244func (s *Store) SetReplyEnabled(userID int64, on bool) error {
245 v := 0
246 if on {
247 v = 1
248 }
249 _, err := s.DB.Exec("UPDATE users SET notify_reply = ? WHERE id = ?", v, userID)
250 return err
251}
252
233// WatchEnabled reports whether the account hears about every issue and 253// WatchEnabled reports whether the account hears about every issue and
234// merge request on the repositories it can write to, without a 254// merge request on the repositories it can write to, without a
235// repo_watchers row on each (#194). 255// repo_watchers row on each (#194).
internal/web/templates/account.html +8 −1
@@ -138,7 +138,14 @@ account and where notifications go.</p>
138 <button type="submit" class="btn">Save</button> 138 <button type="submit" class="btn">Save</button>
139</form> 139</form>
140<p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p> 140<p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p>
141<form method="post" action="/settings" class="setform"> 141{{if .ReplyOffered}}<form method="post" action="/settings" class="setform">
142 <input type="hidden" name="field" value="notify-reply">
143 <label for="notify-reply">Reply to mail to comment</label>
144 <div class="check"><input type="checkbox" id="notify-reply" name="reply" value="on"{{if .ReplyOn}} checked{{end}}></div>
145 <button type="submit" class="btn">Save</button>
146</form>
147<p class="meta">Issue and merge request mail gets a reply address. A reply posts a comment as you when it comes from one of your verified addresses; quoted text and signatures are removed.</p>
148{{end}}<form method="post" action="/settings" class="setform">
142 <input type="hidden" name="field" value="notify-watch"> 149 <input type="hidden" name="field" value="notify-watch">
143 <label for="notify-watch">Watch repositories you can write to</label> 150 <label for="notify-watch">Watch repositories you can write to</label>
144 <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div> 151 <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div>