notify: reply to notification mail to comment !534
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] | ||
| 151 | Reply 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. | ||
| 153 | Off unless =enabled=, and it requires =[mail] smtp_host=, since the | ||
| 154 | replies answer mail gitbayd sends. | ||
| 155 | |||
| 156 | #+begin_src toml | ||
| 157 | [mail.inbound] | ||
| 158 | enabled = true | ||
| 159 | imap_host = "imap.example.org" # host:port; 993, or 143 with tls = "starttls" | ||
| 160 | tls = "implicit" # or "starttls"; there is no plaintext setting | ||
| 161 | user = "reply@gitbay.example" | ||
| 162 | password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd's user | ||
| 163 | mailbox = "INBOX" # default | ||
| 164 | poll_interval = "1m" # default; at least 10s | ||
| 165 | reply_address = "reply@gitbay.example" | ||
| 166 | trusted_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 | |||
| 211 | A reply is posted when all of these hold, checked when it is read: | ||
| 212 | |||
| 213 | 1. It is not an automatic reply (=Auto-Submitted=, =Precedence: bulk= | ||
| 214 | and the like). | ||
| 215 | 2. A recipient header carries a reply address whose token verifies | ||
| 216 | and has not expired (thirty days from the mail it came on). | ||
| 217 | 3. 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. | ||
| 221 | 4. =From= is one of that account's verified addresses, and, with | ||
| 222 | =trusted_authserv_id= set, the mail host authenticated it. | ||
| 223 | 5. 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). | ||
| 226 | 6. No earlier copy of the message (same =Message-ID=, account and | ||
| 227 | thread) posted. | ||
| 228 | 7. =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 | |||
| 232 | The 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 | ||
| 234 | reason and the =Message-ID=, never the message's content, at most sixty | ||
| 235 | a minute, and sends nothing back. See Threat-Model for why the token | ||
| 236 | and the sender address are both required. | ||
| 237 | |||
| 150 | ** [push] | 238 | ** [push] |
| 151 | Push notifications to Apple devices, delivered by gitbayd talking to | 239 | Push notifications to Apple devices, delivered by gitbayd talking to |
| 152 | APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an | 240 | APNs 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 | ||
| 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, | 28 | Generation 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 | |||
| 430 | browser has no way to ask Apple for one. =device list= and =device | 431 | browser has no way to ask Apple for one. =device list= and =device |
| 431 | remove= have no such limit and are yes on the web like the rest. | 432 | remove= have no such limit and are yes on the web like the rest. |
| 432 | 433 | ||
| 434 | Replying to issue and merge request mail posts a comment (#295) when | ||
| 435 | the instance reads a reply mailbox (=[mail.inbound]=) and the account | ||
| 436 | ran =notifications settings reply on=. The account page shows the | ||
| 437 | switch only on such an instance. The reply is itself a fourth surface | ||
| 438 | for =issue comment= and =mr comment= and nothing else: it is dispatched | ||
| 439 | as that command, so it cannot do what the command would refuse. | ||
| 440 | =admin mail inbound check= has no page, like the rest of instance | ||
| 441 | administration. | ||
| 442 | |||
| 433 | A login link is requested from the login page by username or verified | 443 | A login link is requested from the login page by username or verified |
| 434 | address, and arrives by mail: it works once and expires in fifteen | 444 | address, and arrives by mail: it works once and expires in fifteen |
| 435 | minutes. The row is =n/a= for the CLI because a terminal with a | 445 | minutes. 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 |
| 479 | filter and cursor, and promote, demote, disable and enable per row; | 490 | filter 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 | ||
| 82 | Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=. | 86 | Run =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. | |||
| 90 | Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies | 94 | Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies |
| 91 | no inbound HMAC. | 95 | no inbound HMAC. |
| 92 | 96 | ||
| 97 | * Reply by mail | ||
| 98 | |||
| 99 | When =[mail.inbound]= is on (#295), anyone can send mail to the reply | ||
| 100 | mailbox, and a posted reply is a comment written as an account. Two | ||
| 101 | things 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 | |||
| 135 | Access is judged when the reply is read, not when the mail was sent: | ||
| 136 | the reply is posted by dispatching =issue comment= or =mr comment= as | ||
| 137 | the account, so a revoked grant, a private repository, an archived | ||
| 138 | repository, a disabled or pending account, or reply by mail turned | ||
| 139 | off all refuse it. A =Message-ID= that already posted to a thread as an | ||
| 140 | account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are | ||
| 141 | refused so an out-of-office responder cannot post. | ||
| 142 | |||
| 143 | Refusals send nothing back: no bounce, no error mail, so the mailbox | ||
| 144 | cannot be used to make the instance mail a forged sender | ||
| 145 | (backscatter). Each refusal is an audit row, =refused mail reply=, with | ||
| 146 | the reason and the =Message-ID= and none of the message's content, | ||
| 147 | bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS | ||
| 148 | with certificate verification; there is no plaintext setting. The | ||
| 149 | mailbox password is read from a 0600 file and never logged. The client | ||
| 150 | bounds what the server can make it hold: 10 MiB a message (refused by | ||
| 151 | size before fetching), about 11 MiB and a thousand responses a | ||
| 152 | command, after which the connection is closed; literals other than | ||
| 153 | the message body are read and discarded. The Reply-To address is | ||
| 154 | blanked 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 | ||
| 95 | Webhook delivery, GitHub-history import =--api-base=, mirror remotes | 158 | Webhook delivery, GitHub-history import =--api-base=, mirror remotes |
.gitbay/wiki/Users.org +16
| @@ -934,6 +934,22 @@ someone else. | |||
| 934 | You are never mailed about your own actions, and only verified primary | 934 | You are never mailed about your own actions, and only verified primary |
| 935 | addresses receive anything. Delivery retries on relay failure. | 935 | addresses receive anything. Delivery retries on relay failure. |
| 936 | 936 | ||
| 937 | =notifications settings reply on= lets you answer that mail: issue and | ||
| 938 | merge request mail then carries a =Reply-To= address, and replying to | ||
| 939 | it posts your reply as a comment on the thread, as you. Off by | ||
| 940 | default; the account page has the switch when the instance reads | ||
| 941 | replies, and turning it on is refused where it does not | ||
| 942 | (=[mail.inbound]= off). A reply is posted only when it comes from one | ||
| 943 | of your verified addresses, you can still comment on the thread, and | ||
| 944 | the mail it answers is less than thirty days old. Quoted text (lines | ||
| 945 | starting with =>=, the "On … wrote:" line and what follows it, the | ||
| 946 | header block Outlook quotes) and everything after a =-- = signature | ||
| 947 | line are removed, and the rest is stored as markdown. HTML-only mail is | ||
| 948 | not accepted; send plain text or the usual plain-and-HTML pair. A | ||
| 949 | refused reply gets no answer: nothing is mailed back. Each reply | ||
| 950 | address names you, so do not forward notification mail you would not | ||
| 951 | want answered from your own address. | ||
| 952 | |||
| 937 | Push is the same activity again, delivered to a phone: the iOS app | 953 | Push is the same activity again, delivered to a phone: the iOS app |
| 938 | registers a device, and =notifications settings push off= silences it | 954 | registers a device, and =notifications settings push off= silences it |
| 939 | the way =mail off= silences mail, without deregistering anything. | 955 | the 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/ | |||
| 16 | go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ | 16 | go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ |
| 17 | go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ | 17 | go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ |
| 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ | 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ |
| 19 | go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/ | ||
| 20 | go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/ | ||
| 21 | go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/ | ||
| 19 | 22 | ||
| 20 | echo "== all clear ==" | 23 | echo "== 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. | ||
| 284 | type 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. | ||
| 308 | const DefaultInboundPoll = time.Minute | ||
| 309 | |||
| 310 | // Poll is the configured poll interval. | ||
| 311 | func (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. | ||
| 319 | func (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. | ||
| 330 | func (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. | ||
| 339 | func (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 | |||
| 363 | func (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 | |||
| 415 | func 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 | |||
| 450 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/imapc" | ||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func 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 | |||
| 20 | const 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. | ||
| 25 | func 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. |
| 74 | const SourceWeb = "web" | 74 | const SourceWeb = "web" |
| 75 | 75 | ||
| 76 | // SourceMail is Ctx.Source for a comment posted by replying to | ||
| 77 | // notification mail (#295). | ||
| 78 | const 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 | ||
| 15 | const maxBodyBytes = 64 << 10 | 15 | const maxBodyBytes = 64 << 10 |
| 16 | 16 | ||
| 17 | // MaxCommentBytes is the most of a comment body a command reads. | ||
| 18 | const MaxCommentBytes = maxBodyBytes | ||
| 19 | |||
| 17 | func init() { | 20 | func 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() { | |||
| 96 | type notice struct { | 103 | type 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. | ||
| 156 | const 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. | ||
| 162 | func 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. | ||
| 310 | func 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). | ||
| 319 | func 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 | |||
| 384 | func 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). | ||
| 547 | func 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 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "bytes" | ||
| 6 | "net" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // FuzzReadResponse feeds server bytes to the response reader, literals | ||
| 11 | // included. | ||
| 12 | func 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. | ||
| 6 | package imapc | ||
| 7 | |||
| 8 | import ( | ||
| 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). | ||
| 23 | const 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. | ||
| 28 | const ( | ||
| 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. | ||
| 36 | const 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. | ||
| 41 | type RefusedError struct{ Text string } | ||
| 42 | |||
| 43 | func (e *RefusedError) Error() string { return e.Text } | ||
| 44 | |||
| 45 | var ( | ||
| 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. | ||
| 52 | var rootCAs *x509.CertPool | ||
| 53 | |||
| 54 | // Client is one authenticated IMAP session. | ||
| 55 | type 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. | ||
| 65 | func 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. | ||
| 104 | func 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. | ||
| 109 | func (c *Client) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) } | ||
| 110 | |||
| 111 | func (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. | ||
| 126 | func (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. | ||
| 144 | func (c *Client) Select(mailbox string) (int, error) { return c.open("SELECT", mailbox) } | ||
| 145 | func (c *Client) Examine(mailbox string) (int, error) { return c.open("EXAMINE", mailbox) } | ||
| 146 | |||
| 147 | func (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. | ||
| 167 | func (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. | ||
| 194 | func (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. | ||
| 234 | func (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. | ||
| 242 | func (c *Client) Close() error { | ||
| 243 | c.cmd("LOGOUT") | ||
| 244 | return c.conn.Close() | ||
| 245 | } | ||
| 246 | |||
| 247 | type 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. | ||
| 255 | func (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. | ||
| 291 | func (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. | ||
| 301 | func (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 | |||
| 338 | func (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+}"). | ||
| 357 | func 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. | ||
| 373 | func 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 | |||
| 382 | func 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 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 19 | type 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 | |||
| 28 | func (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 | |||
| 92 | var serverTLS *tls.Config | ||
| 93 | |||
| 94 | func 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 | |||
| 105 | func 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 | |||
| 128 | func 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 | |||
| 168 | func 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. | ||
| 186 | func 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 | |||
| 196 | func 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. | ||
| 211 | func 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. | ||
| 230 | func 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. | ||
| 252 | func 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 | |||
| 268 | func 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 | |||
| 282 | func 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 | |||
| 301 | func 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 | |||
| 318 | func 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. | ||
| 336 | func 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 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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. |
| 24 | func Send(cfg config.Config, to, subject, body string) error { | 24 | func 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. | ||
| 30 | func 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 | |||
| 144 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 17 | func 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 | |||
| 49 | type tokKind int | ||
| 50 | |||
| 51 | const ( | ||
| 52 | tokAtom tokKind = iota | ||
| 53 | tokQuoted | ||
| 54 | tokEquals | ||
| 55 | tokSemi | ||
| 56 | ) | ||
| 57 | |||
| 58 | type 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. | ||
| 71 | func 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 ";". | ||
| 137 | func 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. | ||
| 154 | func 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. | ||
| 196 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/mail" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func arHeader(values ...string) mail.Header { | ||
| 10 | return mail.Header{"Authentication-Results": values} | ||
| 11 | } | ||
| 12 | |||
| 13 | func 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. | ||
| 57 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 19 | var errNoText = errors.New("no text/plain part") | ||
| 20 | |||
| 21 | // maxParts and maxDepth bound the walk through a multipart message. | ||
| 22 | const ( | ||
| 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. | ||
| 30 | func textBody(h textproto.MIMEHeader, body io.Reader, limit int64) (string, error) { | ||
| 31 | parts := 0 | ||
| 32 | return walk(h, body, limit, 0, &parts) | ||
| 33 | } | ||
| 34 | |||
| 35 | func 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 | |||
| 80 | var errTooLong = errors.New("reply is longer than a comment may be") | ||
| 81 | |||
| 82 | func 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. | ||
| 116 | type skipSpace struct{ r *bufio.Reader } | ||
| 117 | |||
| 118 | func (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 | |||
| 137 | var ( | ||
| 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. | ||
| 155 | func 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. | ||
| 198 | func 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. | ||
| 209 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "errors" | ||
| 6 | "net/mail" | ||
| 7 | "net/textproto" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func 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 | |||
| 59 | func 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 | |||
| 68 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 16 | func 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 | |||
| 35 | var 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. | ||
| 10 | package mailin | ||
| 11 | |||
| 12 | import ( | ||
| 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. | ||
| 36 | type 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. | ||
| 44 | const maxTries = 5 | ||
| 45 | |||
| 46 | // Processor handles fetched messages. | ||
| 47 | type 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. | ||
| 60 | const refusalsPerMinute = 60 | ||
| 61 | |||
| 62 | // Result is what became of one message. | ||
| 63 | type 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 | |||
| 69 | func 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. | ||
| 75 | func (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. | ||
| 135 | func (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. | ||
| 277 | func (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 | |||
| 289 | func (p *Processor) now() time.Time { | ||
| 290 | if p.Now != nil { | ||
| 291 | return p.Now() | ||
| 292 | } | ||
| 293 | return time.Now() | ||
| 294 | } | ||
| 295 | |||
| 296 | func (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. | ||
| 305 | func (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. | ||
| 326 | func 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. | ||
| 339 | func 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. | ||
| 357 | type Poller struct { | ||
| 358 | P *Processor | ||
| 359 | In config.MailInbound | ||
| 360 | } | ||
| 361 | |||
| 362 | // Run polls until ctx is done. | ||
| 363 | func (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. | ||
| 381 | func (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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 19 | const replyBase = "reply@gitbay.example" | ||
| 20 | |||
| 21 | type 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. | ||
| 33 | func 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 | |||
| 94 | func (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 | |||
| 104 | var msgSeq int | ||
| 105 | |||
| 106 | func (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). | ||
| 114 | func (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 | |||
| 120 | func (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. | ||
| 130 | func (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 | |||
| 143 | func 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 | |||
| 159 | func 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 | |||
| 236 | func 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. | ||
| 251 | func 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 | |||
| 264 | type fakeMailbox struct { | ||
| 265 | msgs map[uint32][]byte | ||
| 266 | seen map[uint32]bool | ||
| 267 | errs map[uint32]error | ||
| 268 | } | ||
| 269 | |||
| 270 | func (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 | |||
| 281 | func (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 | |||
| 291 | func (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. | ||
| 297 | func 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. | ||
| 319 | func 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. | ||
| 341 | func 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 | |||
| 365 | func 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. | ||
| 376 | func 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 | |||
| 395 | func 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. | ||
| 405 | func 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. | ||
| 429 | func 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 | |||
| 443 | func 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. | ||
| 496 | func 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. | ||
| 6 | package mailreply | ||
| 7 | |||
| 8 | import ( | ||
| 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. | ||
| 20 | const Lifetime = 30 * 24 * time.Hour | ||
| 21 | |||
| 22 | // Purpose is what the MAC key is derived for (seal.Keyring.Derive). | ||
| 23 | const Purpose = "gitbay mail reply token v1" | ||
| 24 | |||
| 25 | const ( | ||
| 26 | version = 1 | ||
| 27 | macLen = 12 | ||
| 28 | ) | ||
| 29 | |||
| 30 | // Target is the thread a reply posts to, and who it posts as. | ||
| 31 | type 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. | ||
| 43 | func (t Target) Issued() time.Time { return t.Expires.Add(-Lifetime) } | ||
| 44 | |||
| 45 | var ( | ||
| 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. | ||
| 53 | var 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). | ||
| 57 | func 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. | ||
| 85 | func 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 | |||
| 135 | func 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. | ||
| 143 | func 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. | ||
| 150 | func 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 | |||
| 166 | func 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 @@ | |||
| 1 | package mailreply | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var ( | ||
| 11 | keyA = []byte("0123456789abcdef0123456789abcdef") | ||
| 12 | keyB = []byte("fedcba9876543210fedcba9876543210") | ||
| 13 | now = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) | ||
| 14 | ) | ||
| 15 | |||
| 16 | func 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 | |||
| 25 | func 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. | ||
| 54 | func 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 | |||
| 65 | func 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 | |||
| 84 | func 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. | ||
| 99 | func 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 | |||
| 112 | func 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 | |||
| 126 | func 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. | ||
| 236 | func (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. |
| 225 | func (k *Keyring) CurrentID() (string, error) { | 255 | func (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. | ||
| 238 | func 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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func (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. | ||
| 30 | func (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. | ||
| 41 | func (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. | ||
| 49 | func (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. | ||
| 56 | func (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 @@ | |||
| 1 | DROP TABLE mail_replies; | ||
| 2 | ALTER TABLE notifications DROP COLUMN reply_to; | ||
| 3 | ALTER 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. | ||
| 8 | ALTER TABLE users ADD COLUMN notify_reply INTEGER NOT NULL DEFAULT 0; | ||
| 9 | ALTER TABLE notifications ADD COLUMN reply_to TEXT NOT NULL DEFAULT ''; | ||
| 10 | CREATE 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" | |||
| 5 | type QueuedMail struct { | 5 | type 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 | ||
| 13 | func (s *Store) EnqueueMail(recipient, subject, body string) error { | 14 | func (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. | ||
| 21 | func (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 | ||
| 20 | func (s *Store) DueMail(limit int) ([]QueuedMail, error) { | 28 | func (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 | ||
| 41 | func (s *Store) MarkMailSent(id int64) error { | 49 | func (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 | ||
| 47 | func (s *Store) MarkMailFailed(id int64, errMsg string, nextAt *time.Time) error { | 55 | func (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. |
| 49 | func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } | 49 | func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } |
| 50 | 50 | ||
| 51 | // Keyring is the loaded key file, nil when none is set. | ||
| 52 | func (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. |
| 53 | func (s *Store) sealValue(aad, v string) (string, error) { | 56 | func (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). | ||
| 235 | func (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 | |||
| 244 | func (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> |