Commit b823096731

b8230967311e2d61f0c281a752ce420f2767b4c8

parent: c528009533

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:52 UTC

notify, mailin: reply to notification mail to comment

Issue and merge request mail carries Reply-To: reply+<token>@<domain>
when [mail.inbound] is enabled and the recipient ran notifications
settings reply on. The token names recipient, repository, kind and
number with a 30-day expiry, under an HMAC keyed from the secret key
file. gitbayd polls the mailbox over IMAP (TLS or STARTTLS only) and
posts each reply by dispatching issue comment or mr comment as the
account, after checking the token, the account, and that From is one
of its verified addresses. Refusals send nothing and are audited with
the reason. admin mail inbound check tests the mailbox read-only.
Migration 0070.

Ref #295

Layout: unified · split

cmd/gitbay/main.go +6
@@ -74,6 +74,7 @@ func newRoot() *cobra.Command {
7474 group("settings", "notification preferences",
7575 pass("show", passOpts{server: []string{"notifications", "settings", "show"}}),
7676 pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}),
77 pass("reply", passOpts{server: []string{"notifications", "settings", "reply"}}),
7778 pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}),
7879 pass("push", passOpts{server: []string{"notifications", "settings", "push"}}),
7980 ),
@@ -149,6 +150,11 @@ func newRoot() *cobra.Command {
149150 group("symbols", "symbol indexes",
150151 pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}),
151152 ),
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 ),
152158 ),
153159 manCmd(root),
154160 )
cmd/gitbay/summaries_gen.go +2
@@ -7,6 +7,7 @@ var summaries = map[string]string{
77 "account import-bundle": "replay an account bundle (see gitbay migrate)",
88 "admin email verify": "mark an address verified by admin assertion",
99 "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",
1011 "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)",
1112 "admin repo archive": "archive any repository (instance admins; audited)",
1213 "admin repo delete": "delete any repository (instance admins; audited)",
@@ -96,6 +97,7 @@ var summaries = map[string]string{
9697 "notifications read": "mark notifications read",
9798 "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)",
9899 "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",
99101 "notifications settings show": "your notification preferences",
100102 "notifications settings watch": "every issue and merge request on repositories you can write to",
101103 "org create": "create an organization (you become its first admin)",
cmd/gitbayd/main.go +9
@@ -30,6 +30,7 @@ import (
3030 "gitbay.org/gitbay/internal/gitd"
3131 "gitbay.org/gitbay/internal/hookd"
3232 "gitbay.org/gitbay/internal/httpd"
33 "gitbay.org/gitbay/internal/mailin"
3334 "gitbay.org/gitbay/internal/mirror"
3435 "gitbay.org/gitbay/internal/notify"
3536 "gitbay.org/gitbay/internal/packlimit"
@@ -206,6 +207,14 @@ func serveCmd() *cobra.Command {
206207 if cfg.Mail.SMTPHost != "" {
207208 go notify.New(st, cfg, retryBase).Run(whCtx)
208209 }
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 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
217 }
209218 if cfg.Push.Enabled {
210219 p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase)
211220 if err != nil {
e2e/readonly_test.go +1
@@ -110,6 +110,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
110110 "admin runners": {},
111111 "admin repo list": {},
112112 "admin stats": {},
113 "admin mail inbound check": {},
113114 "admin user show": {"alice"},
114115 "profile show": {"alice"},
115116 "org show": {"theorg"},
internal/config/config.go +116
@@ -7,6 +7,7 @@ import (
77 "encoding/pem"
88 "errors"
99 "fmt"
10 "io"
1011 "math"
1112 "net"
1213 "os"
@@ -273,6 +274,117 @@ type Mail struct {
273274 // TLS is "starttls" (the default, also when empty) or "implicit":
274275 // TLS from the first byte, as relays on port 465 expect.
275276 TLS string `toml:"tls,omitempty"`
277 // Inbound polls a mailbox for replies to notification mail (#295).
278 Inbound MailInbound `toml:"inbound"`
279}
280
281// MailInbound is the IMAP mailbox replies to notification mail arrive
282// in. Off unless enabled. The connection is always encrypted; there is
283// no setting for plaintext.
284type MailInbound struct {
285 Enabled bool `toml:"enabled"`
286 // IMAPHost is host:port; the port defaults to 993 with tls =
287 // "implicit" (the default) and 143 with tls = "starttls".
288 IMAPHost string `toml:"imap_host"`
289 TLS string `toml:"tls"`
290 User string `toml:"user"`
291 // PasswordFile holds the mailbox password, read at each connection.
292 // Never inline in this file.
293 PasswordFile string `toml:"password_file"`
294 Mailbox string `toml:"mailbox"` // default INBOX
295 PollInterval string `toml:"poll_interval"` // default 1m
296 // ReplyAddress is the address a notification's Reply-To is built
297 // from: reply@example.org becomes reply+<token>@example.org, so the
298 // mailbox must receive plus-addressed mail for it (or a catch-all).
299 ReplyAddress string `toml:"reply_address"`
300}
301
302// DefaultInboundPoll is the poll interval when poll_interval is unset.
303const DefaultInboundPoll = time.Minute
304
305// Poll is the configured poll interval.
306func (m MailInbound) Poll() time.Duration {
307 if d, err := time.ParseDuration(m.PollInterval); err == nil && d > 0 {
308 return d
309 }
310 return DefaultInboundPoll
311}
312
313// Addr is IMAPHost with the default port filled in.
314func (m MailInbound) Addr() string {
315 if _, _, err := net.SplitHostPort(m.IMAPHost); err == nil {
316 return m.IMAPHost
317 }
318 if m.TLS == "starttls" {
319 return net.JoinHostPort(m.IMAPHost, "143")
320 }
321 return net.JoinHostPort(m.IMAPHost, "993")
322}
323
324// MailboxName is Mailbox, INBOX when unset.
325func (m MailInbound) MailboxName() string {
326 if m.Mailbox == "" {
327 return "INBOX"
328 }
329 return m.Mailbox
330}
331
332// Password reads PasswordFile: its first line, which must be all it
333// holds. The file must be readable by its owner alone.
334func (m MailInbound) Password() (string, error) {
335 f, err := os.Open(m.PasswordFile)
336 if err != nil {
337 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
338 }
339 defer f.Close()
340 fi, err := f.Stat()
341 if err != nil {
342 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
343 }
344 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
345 return "", fmt.Errorf("mail.inbound.password_file %s is mode %04o; it must be readable by its owner alone (0600)", m.PasswordFile, perm)
346 }
347 raw, err := io.ReadAll(io.LimitReader(f, 4097))
348 if err != nil {
349 return "", fmt.Errorf("mail.inbound.password_file: %w", err)
350 }
351 pass := strings.TrimRight(string(raw), "\r\n")
352 if pass == "" || len(raw) > 4096 || strings.ContainsAny(pass, "\r\n") {
353 return "", fmt.Errorf("mail.inbound.password_file %s must hold the password on one line", m.PasswordFile)
354 }
355 return pass, nil
356}
357
358func (m MailInbound) validate() []error {
359 if !m.Enabled {
360 return nil
361 }
362 var errs []error
363 for _, f := range []struct{ name, val string }{
364 {"mail.inbound.imap_host", m.IMAPHost},
365 {"mail.inbound.user", m.User},
366 {"mail.inbound.password_file", m.PasswordFile},
367 {"mail.inbound.reply_address", m.ReplyAddress},
368 } {
369 if f.val == "" {
370 errs = append(errs, fmt.Errorf("%s is required when mail.inbound.enabled", f.name))
371 }
372 }
373 if t := m.TLS; t != "" && t != "implicit" && t != "starttls" {
374 errs = append(errs, fmt.Errorf("mail.inbound.tls must be implicit or starttls, got %q: IMAP in clear is not supported", t))
375 }
376 if m.PollInterval != "" {
377 if d, err := time.ParseDuration(m.PollInterval); err != nil || d < 10*time.Second {
378 errs = append(errs, fmt.Errorf("mail.inbound.poll_interval %q must be a duration of at least 10s", m.PollInterval))
379 }
380 }
381 if a := m.ReplyAddress; a != "" {
382 local, domain, ok := strings.Cut(a, "@")
383 if !ok || local == "" || domain == "" || strings.ContainsAny(a, "+ <>\"\r\n") || strings.Contains(domain, "@") {
384 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))
385 }
386 }
387 return errs
276388}
277389
278390// TLSRequired reports whether mail must not go to the relay in clear.
@@ -563,6 +675,10 @@ func (c Config) Validate() error {
563675 if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" {
564676 errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t))
565677 }
678 errs = append(errs, c.Mail.Inbound.validate()...)
679 if c.Mail.Inbound.Enabled && c.Mail.SMTPHost == "" {
680 errs = append(errs, errors.New("mail.inbound.enabled requires [mail] smtp_host: replies answer notification mail, which is not sent without SMTP"))
681 }
566682 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
567683 errs = append(errs, fmt.Errorf(
568684 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
internal/config/config_test.go +52
@@ -411,3 +411,55 @@ func TestBackupRecipients(t *testing.T) {
411411 t.Fatalf("default: %v, %v", cfg.Backup, err)
412412 }
413413}
414
415func TestMailInbound(t *testing.T) {
416 const smtp = "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n"
417 const inbound = "[mail.inbound]\nenabled = true\nimap_host = \"imap.example\"\nuser = \"reply@example\"\npassword_file = \"/etc/gitbay/imap.pass\"\nreply_address = \"reply@gitbay.example\"\n"
418 cfg, err := Load(writeConfig(t, minimal+smtp+inbound))
419 if err != nil {
420 t.Fatal(err)
421 }
422 in := cfg.Mail.Inbound
423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
425 }
426 in.TLS = "starttls"
427 if in.Addr() != "imap.example:143" {
428 t.Fatalf("starttls default port: %q", in.Addr())
429 }
430 for body, want := range map[string]string{
431 minimal + inbound: "requires [mail] smtp_host",
432 minimal + smtp + inbound + "tls = \"none\"\n": "IMAP in clear is not supported",
433 minimal + smtp + inbound + "poll_interval = \"1s\"\n": "poll_interval",
434 minimal + smtp + "[mail.inbound]\nenabled = true\n": "mail.inbound.password_file is required",
435 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "reply+x@gitbay.example", 1): "no + in it",
436 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "gitbay.example", 1): "bare address",
437 minimal + smtp + inbound + "password = \"x\"\n": "unknown config key",
438 } {
439 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
440 t.Errorf("want %q, got %v\n%s", want, err, body)
441 }
442 }
443 // Off, nothing is required.
444 if _, err := Load(writeConfig(t, minimal+"\n[mail.inbound]\nenabled = false\n")); err != nil {
445 t.Fatal(err)
446 }
447}
448
449func TestMailInboundPassword(t *testing.T) {
450 dir := t.TempDir()
451 in := MailInbound{PasswordFile: dir + "/pass"}
452 os.WriteFile(in.PasswordFile, []byte("hunter2\n"), 0o600)
453 if p, err := in.Password(); err != nil || p != "hunter2" {
454 t.Fatalf("Password = %q, %v", p, err)
455 }
456 os.Chmod(in.PasswordFile, 0o644)
457 if _, err := in.Password(); err == nil || strings.Contains(err.Error(), "hunter2") {
458 t.Fatalf("group-readable file: %v", err)
459 }
460 os.WriteFile(in.PasswordFile, []byte("a\nb\n"), 0o600)
461 os.Chmod(in.PasswordFile, 0o600)
462 if _, err := in.Password(); err == nil {
463 t.Fatal("two lines accepted")
464 }
465}
internal/control/adminmail.go added +61
@@ -0,0 +1,61 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "time"
7
8 "gitbay.org/gitbay/internal/imapc"
9 "gitbay.org/gitbay/internal/protocol"
10)
11
12func init() {
13 register(Command{Path: []string{"admin", "mail", "inbound", "check"},
14 Summary: "connect to the reply mailbox read-only and report what is waiting",
15 Usage: "admin mail inbound check",
16 Examples: []string{"admin mail inbound check"},
17 ReadOnly: true, Run: runAdminMailInboundCheck})
18}
19
20// runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and
21// opens it with EXAMINE, which changes no flag, so a check never marks a
22// reply seen before the poller reads it.
23func runAdminMailInboundCheck(c *Ctx, args []string) int {
24 if code := requireInstanceAdmin(c); code >= 0 {
25 return code
26 }
27 if len(args) != 0 {
28 return c.usage()
29 }
30 in := c.Cfg.Mail.Inbound
31 type out struct {
32 Enabled bool `json:"enabled"`
33 Server string `json:"server,omitempty"`
34 Mailbox string `json:"mailbox,omitempty"`
35 Messages int `json:"messages"`
36 Unseen int `json:"unseen"`
37 }
38 if !in.Enabled {
39 return c.emit(out{}, func(w io.Writer) {
40 fmt.Fprintln(w, "inbound mail is off ([mail.inbound] enabled = false)")
41 })
42 }
43 cl, n, err := imapc.Open(in, true, 30*time.Second)
44 if err != nil {
45 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
46 }
47 defer cl.Close()
48 unseen, err := cl.Unseen()
49 if err != nil {
50 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
51 }
52 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen)}
53 return c.emit(d, func(w io.Writer) {
54 c.view(w).fields(
55 "server", d.Server,
56 "mailbox", d.Mailbox,
57 "messages", fmt.Sprintf("%d", d.Messages),
58 "unseen", fmt.Sprintf("%d", d.Unseen),
59 )
60 })
61}
internal/control/control.go +4
@@ -73,6 +73,10 @@ type Ctx struct {
7373// User.SignedInAt is when that session signed in.
7474const SourceWeb = "web"
7575
76// SourceMail is Ctx.Source for a comment posted by replying to
77// notification mail (#295).
78const SourceMail = "mail"
79
7680// ReauthWindow is how long after signing in a browser session may run a
7781// NeedsRecentSignIn command. A session lasts days and its cookie is a
7882// 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 {
160160 // not reach anyone's inbox. `mr review` is what says it out loud.
161161 if !pending {
162162 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,
164164 subject: mrSubject(repo, mr.Number, mr.Title),
165165 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
166166 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
internal/control/issue.go +7 −4
@@ -14,6 +14,9 @@ import (
1414
1515const maxBodyBytes = 64 << 10
1616
17// MaxCommentBytes is the most of a comment body a command reads.
18const MaxCommentBytes = maxBodyBytes
19
1720func init() {
1821 register(Command{Path: []string{"issue", "create"},
1922 Summary: "open an issue",
@@ -250,7 +253,7 @@ func runIssueCreate(c *Ctx, args []string) int {
250253 }
251254 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
252255 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,
254257 subject: issueSubject(repo, n, title),
255258 action: fmt.Sprintf("opened issue #%d", n),
256259 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
@@ -429,7 +432,7 @@ func setIssueState(c *Ctx, args []string, state string) int {
429432 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
430433 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
431434 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,
433436 subject: issueSubject(repo, issue.Number, issue.Title),
434437 action: fmt.Sprintf("%s #%d", verb, issue.Number),
435438 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
@@ -509,7 +512,7 @@ func runIssueEdit(c *Ctx, args []string) int {
509512 }
510513 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
511514 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,
513516 subject: issueSubject(repo, issue.Number, issue.Title),
514517 action: fmt.Sprintf("edited #%d", issue.Number),
515518 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
674677 // and widening it to watchers would tell them "assigned you".
675678 // Removals file nothing, and notify drops the actor, so assigning
676679 // 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,
678681 subject: issueSubject(repo, issue.Number, issue.Title),
679682 action: fmt.Sprintf("assigned you to #%d", issue.Number),
680683 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 {
518518 }
519519 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
520520 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,
522522 subject: mrSubject(repo, n, title),
523523 action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target),
524524 excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)})
@@ -1081,7 +1081,7 @@ func runMRRetarget(c *Ctx, args []string) int {
10811081 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
10821082 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
10831083 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,
10851085 subject: mrSubject(repo, mr.Number, mr.Title),
10861086 action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target),
10871087 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1157,7 +1157,7 @@ func runMRReview(c *Ctx, args []string) int {
11571157 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
11581158 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
11591159 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,
11611161 subject: mrSubject(repo, mr.Number, mr.Title),
11621162 action: reviewAction(mr.Number, verdict, published),
11631163 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1217,7 +1217,7 @@ func runMRReviewRequest(c *Ctx, args []string) int {
12171217 for i, u := range added {
12181218 ids[i] = u.ID
12191219 }
1220 notify(c, ids, notice{repo: repo, kind: "mr",
1220 notify(c, ids, notice{repo: repo, kind: "mr", number: mr.Number,
12211221 subject: mrSubject(repo, mr.Number, mr.Title),
12221222 action: fmt.Sprintf("asked for a review on !%d", mr.Number),
12231223 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 {
15971597 }
15981598 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
15991599 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,
16011601 subject: mrSubject(repo, k.Number, k.Title),
16021602 action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number),
16031603 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 {
16231623 repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now())
16241624 c.Store.MarkMirrorsDirty(repo.ID, "push")
16251625 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,
16271627 subject: mrSubject(repo, mr.Number, mr.Title),
16281628 action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy),
16291629 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1907,7 +1907,7 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
19071907 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
19081908 parts, _ := c.Store.MRParticipants(mr.ID)
19091909 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,
19111911 subject: mrSubject(repo, mr.Number, mr.Title),
19121912 action: fmt.Sprintf("marked !%d ready for review", mr.Number),
19131913 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
@@ -1959,7 +1959,7 @@ func runMRClose(c *Ctx, args []string) int {
19591959 }
19601960 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData)
19611961 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,
19631963 subject: mrSubject(repo, mr.Number, mr.Title),
19641964 action: fmt.Sprintf("closed !%d", mr.Number),
19651965 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
internal/control/notifications.go +66 −2
@@ -6,8 +6,10 @@ import (
66 "io"
77 "strconv"
88 "strings"
9 "time"
910
1011 "gitbay.org/gitbay/internal/autolink"
12 "gitbay.org/gitbay/internal/mailreply"
1113 "gitbay.org/gitbay/internal/policy"
1214 "gitbay.org/gitbay/internal/protocol"
1315 "gitbay.org/gitbay/internal/store"
@@ -42,6 +44,11 @@ func init() {
4244 Usage: "notifications settings mail on|off",
4345 Examples: []string{"notifications settings mail on"},
4446 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})
4552 register(Command{Path: []string{"notifications", "settings", "watch"},
4653 Summary: "every issue and merge request on repositories you can write to",
4754 Usage: "notifications settings watch on|off",
@@ -96,6 +103,7 @@ func init() {
96103type notice struct {
97104 repo store.Repo
98105 kind string // issue, mr, or build
106 number int64 // the issue or merge request; 0 for a build
99107 subject string // mail subject
100108 action string // "opened issue #12" — also the inbox summary
101109 excerpt string // quoted into the mail, not the inbox
@@ -136,10 +144,43 @@ func notify(c *Ctx, userIDs []int64, n notice) {
136144 if err != nil || email == "" {
137145 continue
138146 }
147 if replyTo := replyAddress(c, id, n); replyTo != "" {
148 c.Store.EnqueueMailReplyTo(email, replyTo, n.subject, body+replyFooter)
149 continue
150 }
139151 c.Store.EnqueueMail(email, n.subject, body)
140152 }
141153}
142154
155// replyFooter ends mail that carries a reply address.
156const replyFooter = "\nReply to this mail to comment. Replies are accepted from your verified addresses.\n"
157
158// replyAddress is the Reply-To for recipient's mail about n (#295): an
159// address carrying a token for the recipient and the thread, when the
160// instance polls for replies and the recipient turned replies on. ""
161// otherwise, or when no token can be minted.
162func replyAddress(c *Ctx, recipient int64, n notice) string {
163 in := c.Cfg.Mail.Inbound
164 keys := c.Store.Keyring()
165 if !in.Enabled || keys == nil || n.number == 0 || (n.kind != "issue" && n.kind != "mr") {
166 return ""
167 }
168 if on, err := c.Store.ReplyEnabled(recipient); err != nil || !on {
169 return ""
170 }
171 secrets, err := keys.Derive(mailreply.Purpose)
172 if err != nil {
173 return ""
174 }
175 tok, err := mailreply.Mint(secrets, mailreply.Target{
176 UserID: recipient, RepoID: n.repo.ID, Kind: n.kind, Number: n.number,
177 }, time.Now().Add(mailreply.Lifetime))
178 if err != nil {
179 return ""
180 }
181 return mailreply.Address(in.ReplyAddress, tok)
182}
183
143184// notifyMentions files an inbox row for every account text mentions by
144185// @name that can read the repository, and records them as participants
145186// 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
170211 return
171212 }
172213 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,
174215 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
175216 action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
176217 excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
@@ -223,7 +264,11 @@ func emitNotificationSettings(c *Ctx) int {
223264 if err != nil {
224265 return c.fail(protocol.ExitFailure, "%v", err)
225266 }
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) {
227272 onOff := func(on bool) string {
228273 if on {
229274 return "on"
@@ -233,6 +278,7 @@ func emitNotificationSettings(c *Ctx) int {
233278 v := c.view(w)
234279 v.fields(
235280 "mail", onOff(mail),
281 "reply", onOff(reply),
236282 "watch", onOff(watch),
237283 "push", onOff(push),
238284 )
@@ -258,6 +304,24 @@ func runNotificationsSettingsMail(c *Ctx, args []string) int {
258304 return emitNotificationSettings(c)
259305}
260306
307// runNotificationsSettingsReply turns on a Reply-To on the account's
308// issue and merge request mail (#295). Turning it on is refused where
309// nothing reads the replies.
310func runNotificationsSettingsReply(c *Ctx, args []string) int {
311 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
312 return c.usage()
313 }
314 on := args[0] == "on"
315 if on && !c.Cfg.Mail.Inbound.Enabled {
316 return c.fail(protocol.ExitFailure,
317 "this instance does not read replies to its mail ([mail.inbound] enabled = false); ask an admin")
318 }
319 if err := c.Store.SetReplyEnabled(c.User.ID, on); err != nil {
320 return c.fail(protocol.ExitFailure, "%v", err)
321 }
322 return emitNotificationSettings(c)
323}
324
261325// runNotificationsSettingsWatch is the default watch state for
262326// repositories the account can write to: consulted when a notice is
263327// 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 (
55 "fmt"
66 "strings"
77 "testing"
8 "time"
89
910 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/mailreply"
1012 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/seal"
1114 "gitbay.org/gitbay/internal/store"
1215)
1316
@@ -310,3 +313,91 @@ func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
310313 t.Errorf("--all did not show the read notice: %q", out.String())
311314 }
312315}
316
317// The Reply-To is on issue and merge request mail only when the instance
318// reads replies and the recipient turned them on (#295).
319func TestNotifyReplyTo(t *testing.T) {
320 key, err := seal.NewKey()
321 if err != nil {
322 t.Fatal(err)
323 }
324 keyFile := t.TempDir() + "/secret.key"
325 if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
326 t.Fatal(err)
327 }
328 ring, err := seal.Load(keyFile)
329 if err != nil {
330 t.Fatal(err)
331 }
332 for _, tc := range []struct {
333 name string
334 instance, user bool
335 kind string
336 number int64
337 want bool
338 }{
339 {"both", true, true, "issue", 1, true},
340 {"merge request", true, true, "mr", 1, true},
341 {"instance off", false, true, "issue", 1, false},
342 {"user off", true, false, "issue", 1, false},
343 {"build", true, true, "build", 0, false},
344 } {
345 t.Run(tc.name, func(t *testing.T) {
346 c, repo, bob := testRepoWithWatcher(t)
347 c.Store.SetKeyring(ring)
348 c.Cfg.Push.Enabled = false
349 c.Cfg.Mail.SMTPHost, c.Cfg.Mail.From = "mx.example", "gitbay@example.test"
350 c.Cfg.Mail.Inbound = config.MailInbound{Enabled: tc.instance, ReplyAddress: "reply@gitbay.example"}
351 if err := c.Store.AddEmail(bob, "bob@example.test", "admin", true); err != nil {
352 t.Fatal(err)
353 }
354 if err := c.Store.SetReplyEnabled(bob, tc.user); err != nil {
355 t.Fatal(err)
356 }
357 notify(c, []int64{bob}, notice{repo: repo, kind: tc.kind, number: tc.number,
358 subject: "s", action: "commented", path: "alice/app/issues/1"})
359 due, err := c.Store.DueMail(20)
360 if err != nil || len(due) != 1 {
361 t.Fatalf("DueMail = %v, %v", due, err)
362 }
363 got := due[0].ReplyTo
364 if !tc.want {
365 if got != "" || strings.Contains(due[0].Body, "Reply to this mail") {
366 t.Fatalf("Reply-To %q, body %q", got, due[0].Body)
367 }
368 return
369 }
370 tok, ok := mailreply.TokenFrom("reply@gitbay.example", got)
371 if !ok {
372 t.Fatalf("Reply-To %q", got)
373 }
374 secrets, _ := ring.Derive(mailreply.Purpose)
375 target, err := mailreply.Verify(secrets, tok, time.Now())
376 want := mailreply.Target{UserID: bob, RepoID: repo.ID, Kind: tc.kind, Number: 1}
377 if err != nil || target != want {
378 t.Fatalf("token names %+v, %v; want %+v", target, err, want)
379 }
380 })
381 }
382}
383
384func TestNotificationsSettingsReply(t *testing.T) {
385 c := notifTestCtx(t, "alice")
386 if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitFailure {
387 t.Fatalf("on without [mail.inbound]: exit %d", code)
388 }
389 c.Cfg.Mail.Inbound.Enabled = true
390 if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitOK {
391 t.Fatalf("on: exit %d: %s", code, c.Stdout)
392 }
393 if on, _ := c.Store.ReplyEnabled(c.User.ID); !on {
394 t.Fatal("reply not on")
395 }
396 c.Cfg.Mail.Inbound.Enabled = false
397 if code := Dispatch(c, []string{"notifications", "settings", "reply", "off"}); code != protocol.ExitOK {
398 t.Fatalf("off: exit %d", code)
399 }
400 if on, _ := c.Store.ReplyEnabled(c.User.ID); on {
401 t.Fatal("reply still on")
402 }
403}
internal/control/thread.go +1 −1
@@ -110,7 +110,7 @@ func runComment(c *Ctx, args []string, t thread, noun string,
110110 }
111111 c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number))
112112 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,
114114 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
115115 action: fmt.Sprintf("commented on %s%d", t.symbol, number),
116116 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
9595 mailOn, _ := s.st.MailEnabled(u.ID)
9696 watchOn, _ := s.st.WatchEnabled(u.ID)
9797 pushOn, _ := s.st.PushEnabled(u.ID)
98 replyOn, _ := s.st.ReplyEnabled(u.ID)
9899 theme, _ := s.st.Theme(u.ID)
99100 diffPref, _ := s.st.DiffLayout(u.ID)
100101
@@ -148,6 +149,8 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
148149 MailOn bool
149150 WatchOn bool
150151 PushOn bool
152 ReplyOn bool
153 ReplyOffered bool // the instance reads replies to its mail
151154 Devices []accountDevice
152155 ThemeSetting string // system, light or dark: the form's selected option
153156 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
156159 Reauth bool // Notice is the stale-session refusal: link to sign in
157160 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
158161 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,
160164 tokens, tokenShown, reauth})
161165}
162166
@@ -350,7 +354,7 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
350354 return
351355 }
352356 back("", "diff layout saved")
353 case "notify-mail", "notify-watch", "notify-push":
357 case "notify-mail", "notify-watch", "notify-push", "notify-reply":
354358 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
355359 state := "off"
356360 if r.FormValue(pref) == "on" {
internal/httpd/account_test.go +36
@@ -541,3 +541,39 @@ func TestNotificationsReadDispatches(t *testing.T) {
541541 t.Fatalf("unread after all = %d, want 0", n)
542542 }
543543}
544
545// The reply toggle is offered only where the instance reads replies, and
546// posting it dispatches notifications settings reply (#295).
547func TestAccountNotifyReply(t *testing.T) {
548 st, err := store.Open(":memory:")
549 if err != nil {
550 t.Fatal(err)
551 }
552 defer st.Close()
553 if err := st.MigrateUp(); err != nil {
554 t.Fatal(err)
555 }
556 uid, err := st.CreateUser("alice", false)
557 if err != nil {
558 t.Fatal(err)
559 }
560 u := store.User{ID: uid, Username: "alice"}
561 page := func(s *Server) string {
562 rr := httptest.NewRecorder()
563 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
564 return rr.Body.String()
565 }
566 if strings.Contains(page(New(config.Default(), st, nil)), `value="notify-reply"`) {
567 t.Fatal("reply toggle offered without [mail.inbound]")
568 }
569 cfg := config.Default()
570 cfg.Mail.Inbound.Enabled = true
571 s := New(cfg, st, nil)
572 if !strings.Contains(page(s), `value="notify-reply"`) {
573 t.Fatal("no reply toggle")
574 }
575 submitAccountForm(t, s, u, url.Values{"field": {"notify-reply"}, "reply": {"on"}})
576 if on, err := st.ReplyEnabled(uid); err != nil || !on {
577 t.Fatalf("ReplyEnabled after notify-reply=on: %v %v", on, err)
578 }
579}
internal/imapc/imapc.go added +325
@@ -0,0 +1,325 @@
1// Package imapc is the IMAP4rev1 client the reply-by-mail poller needs
2// (#295): LOGIN, SELECT or EXAMINE, UID SEARCH UNSEEN, UID FETCH
3// BODY.PEEK[], UID STORE +FLAGS (\Seen), LOGOUT. Nothing else. The
4// connection is TLS from the first byte or upgraded with STARTTLS
5// before LOGIN; there is no plaintext mode.
6package imapc
7
8import (
9 "bufio"
10 "crypto/tls"
11 "crypto/x509"
12 "errors"
13 "fmt"
14 "io"
15 "net"
16 "strconv"
17 "strings"
18 "time"
19)
20
21// MaxMessage is the largest message Fetch returns. A larger one is read
22// and discarded, and Fetch returns ErrTooLarge.
23const MaxMessage = 10 << 20
24
25// maxLine bounds one response line outside literals.
26const maxLine = 1 << 20
27
28var ErrTooLarge = errors.New("message larger than the fetch limit")
29
30// rootCAs verifies the server's certificate; nil is the system pool.
31// Tests set it.
32var rootCAs *x509.CertPool
33
34// Client is one authenticated IMAP session.
35type Client struct {
36 conn net.Conn
37 r *bufio.Reader
38 tag int
39}
40
41// Dial connects to addr (host:port) and reads the greeting. With
42// starttls it upgrades the connection before returning; otherwise TLS
43// runs from the first byte.
44func Dial(addr string, starttls bool, timeout time.Duration) (*Client, error) {
45 host, _, err := net.SplitHostPort(addr)
46 if err != nil {
47 return nil, err
48 }
49 tlsCfg := &tls.Config{ServerName: host, RootCAs: rootCAs, MinVersion: tls.VersionTLS12}
50 d := &net.Dialer{Timeout: timeout}
51 var conn net.Conn
52 if starttls {
53 conn, err = d.Dial("tcp", addr)
54 } else {
55 conn, err = tls.DialWithDialer(d, "tcp", addr, tlsCfg)
56 }
57 if err != nil {
58 return nil, err
59 }
60 c := New(conn)
61 conn.SetDeadline(time.Now().Add(timeout))
62 if err := c.greeting(); err != nil {
63 conn.Close()
64 return nil, err
65 }
66 if starttls {
67 if _, err := c.cmd("STARTTLS"); err != nil {
68 conn.Close()
69 return nil, fmt.Errorf("STARTTLS: %w", err)
70 }
71 tc := tls.Client(conn, tlsCfg)
72 if err := tc.Handshake(); err != nil {
73 conn.Close()
74 return nil, fmt.Errorf("STARTTLS: %w", err)
75 }
76 c.conn, c.r = tc, bufio.NewReader(tc)
77 }
78 return c, nil
79}
80
81// New wraps a connection that is already past its TLS handshake, or a
82// test's pipe. The greeting has not been read.
83func New(conn net.Conn) *Client {
84 return &Client{conn: conn, r: bufio.NewReader(conn)}
85}
86
87// SetDeadline bounds the session's remaining I/O.
88func (c *Client) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) }
89
90func (c *Client) greeting() error {
91 line, _, err := c.readResponse()
92 if err != nil {
93 return err
94 }
95 if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
96 return fmt.Errorf("unexpected greeting %q", clip(line))
97 }
98 return nil
99}
100
101// Login authenticates. The password goes as a quoted string, so it may
102// not hold a line break or a byte outside printable ASCII.
103func (c *Client) Login(user, pass string) error {
104 u, err := quote(user)
105 if err != nil {
106 return fmt.Errorf("user: %w", err)
107 }
108 p, err := quote(pass)
109 if err != nil {
110 return fmt.Errorf("password: %w", err)
111 }
112 if _, err := c.cmd("LOGIN " + u + " " + p); err != nil {
113 // The server's text is not echoed: some quote the command.
114 return errors.New("LOGIN refused")
115 }
116 return nil
117}
118
119// Select opens mailbox for reading and writing flags; Examine opens it
120// read-only. Both return the number of messages in it.
121func (c *Client) Select(mailbox string) (int, error) { return c.open("SELECT", mailbox) }
122func (c *Client) Examine(mailbox string) (int, error) { return c.open("EXAMINE", mailbox) }
123
124func (c *Client) open(verb, mailbox string) (int, error) {
125 m, err := quote(mailbox)
126 if err != nil {
127 return 0, err
128 }
129 untagged, err := c.cmd(verb + " " + m)
130 if err != nil {
131 return 0, fmt.Errorf("%s %s: %w", verb, mailbox, err)
132 }
133 exists := 0
134 for _, u := range untagged {
135 f := strings.Fields(u.line)
136 if len(f) >= 3 && strings.EqualFold(f[2], "EXISTS") {
137 exists, _ = strconv.Atoi(f[1])
138 }
139 }
140 return exists, nil
141}
142
143// Unseen returns the UIDs of messages without \Seen.
144func (c *Client) Unseen() ([]uint32, error) {
145 untagged, err := c.cmd("UID SEARCH UNSEEN")
146 if err != nil {
147 return nil, fmt.Errorf("UID SEARCH: %w", err)
148 }
149 var uids []uint32
150 for _, u := range untagged {
151 f := strings.Fields(u.line)
152 if len(f) < 2 || !strings.EqualFold(f[1], "SEARCH") {
153 continue
154 }
155 for _, s := range f[2:] {
156 n, err := strconv.ParseUint(s, 10, 32)
157 if err != nil {
158 return nil, fmt.Errorf("UID SEARCH: bad uid %q", clip(s))
159 }
160 uids = append(uids, uint32(n))
161 }
162 }
163 return uids, nil
164}
165
166// Fetch returns the whole message without setting \Seen.
167func (c *Client) Fetch(uid uint32) ([]byte, error) {
168 untagged, err := c.cmd(fmt.Sprintf("UID FETCH %d BODY.PEEK[]", uid))
169 if err != nil {
170 return nil, fmt.Errorf("UID FETCH: %w", err)
171 }
172 for _, u := range untagged {
173 f := strings.Fields(u.line)
174 if len(f) < 3 || !strings.EqualFold(f[2], "FETCH") {
175 continue
176 }
177 // The literal is the one after BODY[]; a server may send other
178 // items (FLAGS, UID) around it.
179 i := strings.Index(strings.ToUpper(u.line), "BODY[] {")
180 if i < 0 {
181 continue
182 }
183 idx := strings.Count(u.line[:i], "{")
184 if idx >= len(u.literals) {
185 continue
186 }
187 if u.literals[idx] == nil {
188 return nil, ErrTooLarge
189 }
190 return u.literals[idx], nil
191 }
192 return nil, fmt.Errorf("UID FETCH %d: no message body in the response", uid)
193}
194
195// MarkSeen sets \Seen.
196func (c *Client) MarkSeen(uid uint32) error {
197 if _, err := c.cmd(fmt.Sprintf("UID STORE %d +FLAGS.SILENT (\\Seen)", uid)); err != nil {
198 return fmt.Errorf("UID STORE: %w", err)
199 }
200 return nil
201}
202
203// Close logs out and closes the connection.
204func (c *Client) Close() error {
205 c.cmd("LOGOUT")
206 return c.conn.Close()
207}
208
209type response struct {
210 line string // the response with each literal's bytes left out
211 literals [][]byte // nil for a literal over MaxMessage
212}
213
214// cmd sends one tagged command and collects the untagged responses up to
215// its completion. A NO or BAD completion is an error.
216func (c *Client) cmd(command string) ([]response, error) {
217 c.tag++
218 tag := "g" + strconv.Itoa(c.tag)
219 if _, err := io.WriteString(c.conn, tag+" "+command+"\r\n"); err != nil {
220 return nil, err
221 }
222 var untagged []response
223 for {
224 line, lits, err := c.readResponse()
225 if err != nil {
226 return nil, err
227 }
228 if rest, ok := strings.CutPrefix(line, tag+" "); ok {
229 status, _, _ := strings.Cut(rest, " ")
230 if strings.EqualFold(status, "OK") {
231 return untagged, nil
232 }
233 return nil, fmt.Errorf("%s", clip(rest))
234 }
235 if strings.HasPrefix(line, "* BYE") && command != "LOGOUT" {
236 return nil, fmt.Errorf("server closed the session: %s", clip(line))
237 }
238 if strings.HasPrefix(line, "*") {
239 untagged = append(untagged, response{line, lits})
240 }
241 // A "+" continuation is not expected: no command here sends a
242 // literal.
243 }
244}
245
246// readResponse reads one response: a line, and for each literal it
247// announces ("{n}" at the end of a line) the n bytes and the rest of the
248// response after them.
249func (c *Client) readResponse() (string, [][]byte, error) {
250 var b strings.Builder
251 var lits [][]byte
252 for {
253 line, err := c.readLine()
254 if err != nil {
255 return "", nil, err
256 }
257 b.WriteString(line)
258 n, ok := literalSize(line)
259 if !ok {
260 return b.String(), lits, nil
261 }
262 if n > MaxMessage {
263 if _, err := io.CopyN(io.Discard, c.r, n); err != nil {
264 return "", nil, err
265 }
266 lits = append(lits, nil)
267 continue
268 }
269 buf := make([]byte, n)
270 if _, err := io.ReadFull(c.r, buf); err != nil {
271 return "", nil, err
272 }
273 lits = append(lits, buf)
274 }
275}
276
277func (c *Client) readLine() (string, error) {
278 var b []byte
279 for {
280 chunk, isPrefix, err := c.r.ReadLine()
281 if err != nil {
282 return "", err
283 }
284 b = append(b, chunk...)
285 if len(b) > maxLine {
286 return "", errors.New("response line too long")
287 }
288 if !isPrefix {
289 return string(b), nil
290 }
291 }
292}
293
294// literalSize reads a trailing "{n}" (or "{n+}").
295func literalSize(line string) (int64, bool) {
296 if !strings.HasSuffix(line, "}") {
297 return 0, false
298 }
299 i := strings.LastIndexByte(line, '{')
300 if i < 0 {
301 return 0, false
302 }
303 n, err := strconv.ParseInt(strings.TrimSuffix(line[i+1:len(line)-1], "+"), 10, 64)
304 if err != nil || n < 0 {
305 return 0, false
306 }
307 return n, true
308}
309
310// quote renders s as an IMAP quoted string.
311func quote(s string) (string, error) {
312 for i := 0; i < len(s); i++ {
313 if s[i] < 0x20 || s[i] > 0x7e {
314 return "", errors.New("only printable ASCII can be sent")
315 }
316 }
317 return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`, nil
318}
319
320func clip(s string) string {
321 if len(s) > 200 {
322 return s[:200] + "…"
323 }
324 return s
325}
internal/imapc/imapc_test.go added +197
@@ -0,0 +1,197 @@
1package imapc
2
3import (
4 "bufio"
5 "crypto/tls"
6 "crypto/x509"
7 "fmt"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13 "time"
14)
15
16// fakeServer answers the commands this client sends from a map of UID to
17// message. It records the commands it saw.
18type fakeServer struct {
19 msgs map[uint32]string
20 seen map[uint32]bool
21 cmds []string
22}
23
24func (f *fakeServer) serve(conn net.Conn) {
25 defer conn.Close()
26 r := bufio.NewReader(conn)
27 fmt.Fprint(conn, "* OK fake ready\r\n")
28 for {
29 line, err := r.ReadString('\n')
30 if err != nil {
31 return
32 }
33 line = strings.TrimRight(line, "\r\n")
34 tag, cmd, _ := strings.Cut(line, " ")
35 f.cmds = append(f.cmds, cmd)
36 up := strings.ToUpper(cmd)
37 switch {
38 case strings.HasPrefix(up, "STARTTLS"):
39 fmt.Fprintf(conn, "%s OK begin\r\n", tag)
40 tc := tls.Server(conn, serverTLS)
41 if err := tc.Handshake(); err != nil {
42 return
43 }
44 conn, r = tc, bufio.NewReader(tc)
45 case strings.HasPrefix(up, "LOGIN"):
46 if cmd != `LOGIN "u" "p\"w"` {
47 fmt.Fprintf(conn, "%s NO [AUTHENTICATIONFAILED] %s\r\n", tag, cmd)
48 continue
49 }
50 fmt.Fprintf(conn, "* CAPABILITY IMAP4rev1\r\n%s OK logged in\r\n", tag)
51 case strings.HasPrefix(up, "SELECT"), strings.HasPrefix(up, "EXAMINE"):
52 fmt.Fprintf(conn, "* %d EXISTS\r\n* 0 RECENT\r\n%s OK done\r\n", len(f.msgs), tag)
53 case up == "UID SEARCH UNSEEN":
54 var ids []string
55 for uid := range f.msgs {
56 if !f.seen[uid] {
57 ids = append(ids, fmt.Sprint(uid))
58 }
59 }
60 fmt.Fprintf(conn, "* SEARCH %s\r\n%s OK done\r\n", strings.Join(ids, " "), tag)
61 case strings.HasPrefix(up, "UID FETCH"):
62 var uid uint32
63 fmt.Sscanf(cmd, "UID FETCH %d", &uid)
64 m := f.msgs[uid]
65 // FLAGS ahead of the body and UID after it, as some servers order them.
66 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)
67 case strings.HasPrefix(up, "UID STORE"):
68 var uid uint32
69 fmt.Sscanf(cmd, "UID STORE %d", &uid)
70 f.seen[uid] = true
71 fmt.Fprintf(conn, "%s OK done\r\n", tag)
72 case up == "LOGOUT":
73 fmt.Fprintf(conn, "* BYE\r\n%s OK bye\r\n", tag)
74 return
75 default:
76 fmt.Fprintf(conn, "%s BAD unknown\r\n", tag)
77 }
78 }
79}
80
81var serverTLS *tls.Config
82
83func setupTLS(t *testing.T) {
84 ts := httptest.NewTLSServer(http.NotFoundHandler())
85 t.Cleanup(ts.Close)
86 pool := x509.NewCertPool()
87 pool.AddCert(ts.Certificate())
88 prev := rootCAs
89 rootCAs = pool
90 t.Cleanup(func() { rootCAs = prev })
91 serverTLS = &tls.Config{Certificates: ts.TLS.Certificates}
92}
93
94func listen(t *testing.T, f *fakeServer, implicit bool) string {
95 t.Helper()
96 ln, err := net.Listen("tcp", "127.0.0.1:0")
97 if err != nil {
98 t.Fatal(err)
99 }
100 if implicit {
101 ln = tls.NewListener(ln, serverTLS)
102 }
103 t.Cleanup(func() { ln.Close() })
104 go func() {
105 for {
106 conn, err := ln.Accept()
107 if err != nil {
108 return
109 }
110 go f.serve(conn)
111 }
112 }()
113 // The test certificate is for example.com and 127.0.0.1.
114 return ln.Addr().String()
115}
116
117func TestSession(t *testing.T) {
118 setupTLS(t)
119 body := "From: a@example.test\r\nSubject: x\r\n\r\nhello {3}\r\n"
120 for _, starttls := range []bool{false, true} {
121 f := &fakeServer{msgs: map[uint32]string{7: body, 9: "other"}, seen: map[uint32]bool{9: true}}
122 c, err := Dial(listen(t, f, !starttls), starttls, 5*time.Second)
123 if err != nil {
124 t.Fatal(err)
125 }
126 if err := c.Login("u", `p"w`); err != nil {
127 t.Fatal(err)
128 }
129 n, err := c.Select("INBOX")
130 if err != nil || n != 2 {
131 t.Fatalf("Select = %d, %v", n, err)
132 }
133 uids, err := c.Unseen()
134 if err != nil || len(uids) != 1 || uids[0] != 7 {
135 t.Fatalf("Unseen = %v, %v", uids, err)
136 }
137 got, err := c.Fetch(7)
138 if err != nil || string(got) != body {
139 t.Fatalf("Fetch = %q, %v", got, err)
140 }
141 if err := c.MarkSeen(7); err != nil {
142 t.Fatal(err)
143 }
144 if uids, _ := c.Unseen(); len(uids) != 0 {
145 t.Fatalf("still unseen: %v", uids)
146 }
147 c.Close()
148 if !strings.Contains(strings.Join(f.cmds, "\n"), "UID FETCH 7 BODY.PEEK[]") {
149 t.Fatalf("fetch did not peek: %v", f.cmds)
150 }
151 if starttls && f.cmds[0] != "STARTTLS" {
152 t.Fatalf("first command %q, want STARTTLS before LOGIN", f.cmds[0])
153 }
154 }
155}
156
157func TestLoginRefusedHidesServerText(t *testing.T) {
158 setupTLS(t)
159 f := &fakeServer{msgs: map[uint32]string{}, seen: map[uint32]bool{}}
160 c, err := Dial(listen(t, f, true), false, 5*time.Second)
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer c.Close()
165 err = c.Login("u", "secret")
166 if err == nil || strings.Contains(err.Error(), "secret") {
167 t.Fatalf("Login = %v", err)
168 }
169 if err := c.Login("u", "bad\r\npass"); err == nil {
170 t.Fatal("a line break in the password was sent")
171 }
172}
173
174// A server with a certificate the client does not trust is refused.
175func TestUntrustedCertificate(t *testing.T) {
176 setupTLS(t)
177 f := &fakeServer{msgs: map[uint32]string{}, seen: map[uint32]bool{}}
178 addr := listen(t, f, true)
179 rootCAs = x509.NewCertPool()
180 if _, err := Dial(addr, false, 5*time.Second); err == nil {
181 t.Fatal("dialled a server with an untrusted certificate")
182 }
183}
184
185func TestLiteralSize(t *testing.T) {
186 for line, want := range map[string]int64{
187 "* 1 FETCH (BODY[] {12}": 12,
188 "* 1 FETCH (BODY[] {5+}": 5,
189 "* OK {x}": -1,
190 "* OK done": -1,
191 } {
192 n, ok := literalSize(line)
193 if (want < 0) == ok || (ok && n != want) {
194 t.Errorf("literalSize(%q) = %d, %v", line, n, ok)
195 }
196 }
197}
internal/imapc/open.go added +35
@@ -0,0 +1,35 @@
1package imapc
2
3import (
4 "time"
5
6 "gitbay.org/gitbay/internal/config"
7)
8
9// Open connects to the configured mailbox, logs in with the password
10// file's password, and opens the mailbox: read-only (EXAMINE) or for
11// setting flags (SELECT). It returns the number of messages in it.
12func Open(in config.MailInbound, readOnly bool, timeout time.Duration) (*Client, int, error) {
13 pass, err := in.Password()
14 if err != nil {
15 return nil, 0, err
16 }
17 c, err := Dial(in.Addr(), in.TLS == "starttls", timeout)
18 if err != nil {
19 return nil, 0, err
20 }
21 if err := c.Login(in.User, pass); err != nil {
22 c.Close()
23 return nil, 0, err
24 }
25 open := c.Select
26 if readOnly {
27 open = c.Examine
28 }
29 n, err := open(in.MailboxName())
30 if err != nil {
31 c.Close()
32 return nil, 0, err
33 }
34 return c, n, nil
35}
internal/mail/mail.go +15 −2
@@ -22,10 +22,23 @@ var rootCAs *x509.CertPool
2222
2323// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port.
2424func Send(cfg config.Config, to, subject, body string) error {
25 return SendReplyTo(cfg, to, "", subject, body)
26}
27
28// SendReplyTo is Send with a Reply-To header, left out when replyTo is
29// empty.
30func SendReplyTo(cfg config.Config, to, replyTo, subject, body string) error {
2531 m := cfg.Mail
2632 if m.SMTPHost == "" || m.From == "" {
2733 return fmt.Errorf("[mail] smtp_host and from must be configured")
2834 }
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 }
2942 implicit := m.TLS == "implicit"
3043 host := m.SMTPHost
3144 if !strings.Contains(host, ":") {
@@ -39,8 +52,8 @@ func Send(cfg config.Config, to, subject, body string) error {
3952 tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs}
4053
4154 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",
43 m.From, to, subject, time.Now().Format(time.RFC1123Z), body))
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",
56 m.From, to, header, subject, time.Now().Format(time.RFC1123Z), body))
4457
4558 c, err := dial(host, hostname, implicit, tlsCfg)
4659 if err != nil {
internal/mail/mail_test.go +23
@@ -140,3 +140,26 @@ func TestImplicitTLS(t *testing.T) {
140140 t.Fatalf("delivered %d, want 1", n)
141141 }
142142}
143
144func TestReplyToHeader(t *testing.T) {
145 relay := startRelay(t, nil)
146 cfg := mailCfg(relay.addr)
147 if err := SendReplyTo(cfg, "a@example.test", "reply+tok@example.test", "subject", "body"); err != nil {
148 t.Fatal(err)
149 }
150 if err := Send(cfg, "a@example.test", "subject", "body"); err != nil {
151 t.Fatal(err)
152 }
153 relay.mu.Lock()
154 with, without := relay.data[0], relay.data[1]
155 relay.mu.Unlock()
156 if !strings.Contains(with, "\nReply-To: reply+tok@example.test\n") {
157 t.Fatalf("no Reply-To:\n%s", with)
158 }
159 if strings.Contains(without, "Reply-To:") {
160 t.Fatalf("Send added a Reply-To:\n%s", without)
161 }
162 if err := SendReplyTo(cfg, "a@example.test", "x@example.test\r\nBcc: b@example.test", "s", "b"); err == nil {
163 t.Fatal("a line break in the reply address was sent")
164 }
165}
internal/mailin/body.go added +214
@@ -0,0 +1,214 @@
1package mailin
2
3import (
4 "bufio"
5 "encoding/base64"
6 "errors"
7 "io"
8 "mime"
9 "mime/multipart"
10 "mime/quotedprintable"
11 "net/textproto"
12 "regexp"
13 "strings"
14 "unicode/utf8"
15
16 "golang.org/x/text/encoding/htmlindex"
17)
18
19var errNoText = errors.New("no text/plain part")
20
21// maxParts and maxDepth bound the walk through a multipart message.
22const (
23 maxParts = 64
24 maxDepth = 5
25)
26
27// textBody returns the message's first text/plain part that is not an
28// attachment, decoded to UTF-8. A message with only HTML has none, and
29// is refused rather than converted.
30func textBody(h textproto.MIMEHeader, body io.Reader, limit int64) (string, error) {
31 parts := 0
32 return walk(h, body, limit, 0, &parts)
33}
34
35func walk(h textproto.MIMEHeader, body io.Reader, limit int64, depth int, parts *int) (string, error) {
36 ct := h.Get("Content-Type")
37 if ct == "" {
38 ct = "text/plain"
39 }
40 mt, params, err := mime.ParseMediaType(ct)
41 if err != nil {
42 return "", errNoText
43 }
44 switch {
45 case mt == "text/plain":
46 if d, _, _ := mime.ParseMediaType(h.Get("Content-Disposition")); d == "attachment" {
47 return "", errNoText
48 }
49 return decodeText(h.Get("Content-Transfer-Encoding"), params["charset"], body, limit)
50 case strings.HasPrefix(mt, "multipart/") && depth < maxDepth:
51 if params["boundary"] == "" {
52 return "", errNoText
53 }
54 mr := multipart.NewReader(body, params["boundary"])
55 for {
56 // NextRawPart leaves quoted-printable to decodeText, so every
57 // part is decoded the same way.
58 p, err := mr.NextRawPart()
59 if err == io.EOF {
60 return "", errNoText
61 }
62 if err != nil {
63 return "", err
64 }
65 if *parts++; *parts > maxParts {
66 return "", errNoText
67 }
68 s, err := walk(p.Header, p, limit, depth+1, parts)
69 if err == nil {
70 return s, nil
71 }
72 if !errors.Is(err, errNoText) {
73 return "", err
74 }
75 }
76 }
77 return "", errNoText
78}
79
80var errTooLong = errors.New("reply is longer than a comment may be")
81
82func decodeText(cte, charset string, body io.Reader, limit int64) (string, error) {
83 var r io.Reader = body
84 switch strings.ToLower(strings.TrimSpace(cte)) {
85 case "quoted-printable":
86 r = quotedprintable.NewReader(r)
87 case "base64":
88 r = base64.NewDecoder(base64.StdEncoding, &skipSpace{r: bufio.NewReader(r)})
89 case "", "7bit", "8bit", "binary":
90 default:
91 return "", errNoText
92 }
93 switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
94 case "", "utf-8", "utf8", "us-ascii":
95 default:
96 enc, err := htmlindex.Get(cs)
97 if err != nil {
98 return "", errNoText
99 }
100 r = enc.NewDecoder().Reader(r)
101 }
102 // Quoted history is stripped after reading, so the read allows for
103 // a reply several times the size of a comment before refusing it.
104 raw, err := io.ReadAll(io.LimitReader(r, 8*limit+1))
105 if err != nil {
106 return "", err
107 }
108 if int64(len(raw)) > 8*limit {
109 return "", errTooLong
110 }
111 return strings.ToValidUTF8(string(raw), string(utf8.RuneError)), nil
112}
113
114// skipSpace drops the line breaks and spaces base64 bodies are wrapped
115// with.
116type skipSpace struct{ r *bufio.Reader }
117
118func (s *skipSpace) Read(p []byte) (int, error) {
119 n := 0
120 for n < len(p) {
121 b, err := s.r.ReadByte()
122 if err != nil {
123 if n > 0 {
124 return n, nil
125 }
126 return 0, err
127 }
128 if b == '\r' || b == '\n' || b == ' ' || b == '\t' {
129 continue
130 }
131 p[n] = b
132 n++
133 }
134 return n, nil
135}
136
137var (
138 // "On Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+…@…> wrote:", which
139 // Gmail, Apple Mail and Thunderbird all put above the quote, and
140 // which Gmail wraps onto a second line when it is long.
141 attribution = regexp.MustCompile(`(?i)^on\s.*\bwrote:\s*$`)
142 // Outlook: "-----Original Message-----", or a rule of underscores
143 // above a From:/Sent: header block.
144 originalMessage = regexp.MustCompile(`(?i)^\s*-{2,}\s*original message\s*-{2,}\s*$`)
145 underscores = regexp.MustCompile(`^\s*_{10,}\s*$`)
146 headerFrom = regexp.MustCompile(`(?i)^\s*\*?from:\*?\s`)
147 headerSentDate = regexp.MustCompile(`(?i)^\s*\*?(sent|date):\*?\s`)
148 mobileSig = regexp.MustCompile(`(?i)^sent from my \S`)
149)
150
151// stripQuoted returns the text a person wrote in a reply: quoted lines
152// ("> …") dropped wherever they are, and everything from the first
153// separator a mail client puts above the quoted message, or from the
154// signature delimiter "-- ", cut off.
155func stripQuoted(s string) string {
156 s = strings.ReplaceAll(s, "\r\n", "\n")
157 lines := strings.Split(s, "\n")
158 cut := len(lines)
159 for i, l := range lines {
160 t := strings.TrimRight(l, " \t")
161 next := ""
162 if i+1 < len(lines) {
163 next = strings.TrimSpace(lines[i+1])
164 }
165 switch {
166 case l == "-- " || t == "--":
167 case originalMessage.MatchString(t):
168 case underscores.MatchString(t):
169 case attribution.MatchString(strings.TrimSpace(t)):
170 case strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "on ") &&
171 attribution.MatchString(strings.TrimSpace(t)+" "+next):
172 case headerFrom.MatchString(t) && followedByHeader(lines[i+1:]):
173 default:
174 continue
175 }
176 cut = i
177 break
178 }
179 var out []string
180 for _, l := range lines[:cut] {
181 if strings.HasPrefix(strings.TrimLeft(l, " "), ">") {
182 continue
183 }
184 out = append(out, strings.TrimRight(l, " \t"))
185 }
186 // A phone's canned signature, when it is the last thing written.
187 for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
188 out = out[:len(out)-1]
189 }
190 if len(out) > 0 && mobileSig.MatchString(strings.TrimSpace(out[len(out)-1])) {
191 out = out[:len(out)-1]
192 }
193 return strings.TrimSpace(collapseBlank(strings.Join(out, "\n")))
194}
195
196// followedByHeader reports whether a Sent: or Date: line comes within
197// the next few lines, as in the header block Outlook quotes.
198func followedByHeader(rest []string) bool {
199 for i := 0; i < len(rest) && i < 4; i++ {
200 if headerSentDate.MatchString(rest[i]) {
201 return true
202 }
203 }
204 return false
205}
206
207// collapseBlank turns runs of blank lines, left where quoted lines were
208// dropped, into one.
209func collapseBlank(s string) string {
210 for strings.Contains(s, "\n\n\n") {
211 s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
212 }
213 return s
214}
internal/mailin/body_test.go added +100
@@ -0,0 +1,100 @@
1package mailin
2
3import (
4 "bytes"
5 "errors"
6 "net/mail"
7 "net/textproto"
8 "strings"
9 "testing"
10)
11
12func TestStripQuoted(t *testing.T) {
13 for _, tc := range []struct{ name, in, want string }{
14 {"gmail",
15 "Agreed, ship it.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented on #1\n>\n> looks fine\n",
16 "Agreed, ship it."},
17 {"gmail, attribution wrapped",
18 "Agreed.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <\nreply+abc@gitbay.example> wrote:\n\n> alice commented\n",
19 "Agreed."},
20 {"apple mail",
21 "Fixed in the next push.\n\nSent from my iPhone\n\n> On Sep 28, 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n> \n> alice commented on #1\n",
22 "Fixed in the next push."},
23 {"apple mail, unquoted attribution",
24 "Yes.\n\nOn 28 Sep 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented\n",
25 "Yes."},
26 {"outlook",
27 "Will do.\r\n\r\n________________________________\r\nFrom: gitbay <reply+abc@gitbay.example>\r\nSent: Monday, September 28, 2026 9:00 AM\r\nTo: Bob\r\nSubject: [alice/app] #1: title\r\n\r\nalice commented on #1\r\n",
28 "Will do."},
29 {"outlook, no rule",
30 "Will do.\n\nFrom: gitbay <reply+abc@gitbay.example>\nSent: Monday, September 28, 2026 9:00 AM\nTo: Bob\n\nalice commented on #1\n",
31 "Will do."},
32 {"outlook, original message",
33 "Noted.\n\n-----Original Message-----\nFrom: gitbay\nalice commented\n",
34 "Noted."},
35 {"thunderbird",
36 "Thanks, merged.\n\n-- \nBob Example\nExample Corp\n\nOn 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n",
37 "Thanks, merged."},
38 {"thunderbird, quote first",
39 "On 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n\nThat was me.\n",
40 ""},
41 {"inline answers keep the answers",
42 "> does this build?\nYes, on main.\n> and the tests?\nAll green.\n",
43 "Yes, on main.\nAll green."},
44 {"a From: line in prose is kept",
45 "From: the log it looks like a timeout.\nRetrying.\n",
46 "From: the log it looks like a timeout.\nRetrying."},
47 {"markdown rule is not a signature",
48 "one\n\n---\n\ntwo\n",
49 "one\n\n---\n\ntwo"},
50 } {
51 t.Run(tc.name, func(t *testing.T) {
52 if got := stripQuoted(tc.in); got != tc.want {
53 t.Errorf("got %q\nwant %q", got, tc.want)
54 }
55 })
56 }
57}
58
59func body(t *testing.T, raw string) (string, error) {
60 t.Helper()
61 msg, err := mail.ReadMessage(strings.NewReader(raw))
62 if err != nil {
63 t.Fatal(err)
64 }
65 return textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16)
66}
67
68func TestTextBody(t *testing.T) {
69 for _, tc := range []struct{ name, raw, want string }{
70 {"plain, no content type", "Subject: x\r\n\r\nhello\r\n", "hello\r\n"},
71 {"quoted-printable", "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=C3=A9 =\r\nau lait\r\n", "café au lait\r\n"},
72 {"base64", "Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\naGVs\r\nbG8=\r\n", "hello"},
73 {"latin-1", "Content-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=E9\r\n", "café\r\n"},
74 {"alternative prefers plain",
75 "Content-Type: multipart/alternative; boundary=b\r\n\r\n--b\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n--b\r\nContent-Type: text/plain\r\n\r\nplain\r\n--b--\r\n",
76 "plain"},
77 {"mixed with nested alternative and an attachment",
78 "Content-Type: multipart/mixed; boundary=m\r\n\r\n--m\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=a.txt\r\n\r\nattached\r\n--m\r\nContent-Type: multipart/alternative; boundary=a\r\n\r\n--a\r\nContent-Type: text/plain\r\n\r\nbody\r\n--a--\r\n--m--\r\n",
79 "body"},
80 } {
81 t.Run(tc.name, func(t *testing.T) {
82 got, err := body(t, tc.raw)
83 if err != nil || got != tc.want {
84 t.Errorf("got %q, %v; want %q", got, err, tc.want)
85 }
86 })
87 }
88 for name, raw := range map[string]string{
89 "html only": "Content-Type: text/html\r\n\r\n<p>hi</p>\r\n",
90 "unknown charset": "Content-Type: text/plain; charset=x-nonesuch\r\n\r\nhi\r\n",
91 "unknown encoding": "Content-Type: text/plain\r\nContent-Transfer-Encoding: x-uuencode\r\n\r\nhi\r\n",
92 } {
93 if _, err := body(t, raw); !errors.Is(err, errNoText) {
94 t.Errorf("%s: %v, want errNoText", name, err)
95 }
96 }
97 if _, err := body(t, "Subject: x\r\n\r\n"+string(bytes.Repeat([]byte("a"), 8<<16+1))); !errors.Is(err, errTooLong) {
98 t.Errorf("oversized body: %v", err)
99 }
100}
internal/mailin/mailin.go added +336
@@ -0,0 +1,336 @@
1// Package mailin turns replies to notification mail into comments
2// (#295). A poller reads a mailbox over IMAP; each unseen message
3// addressed to reply+<token>@<domain> is checked (token, account, sender
4// address, the account's access to the thread now) and posted by
5// dispatching issue comment or mr comment as that account. A refusal
6// sends nothing back and is written to the audit log with its reason,
7// never the message's content. Every message is marked seen once it is
8// handled, posted or refused; only a failure that may pass (the
9// database busy) leaves it for the next poll.
10package mailin
11
12import (
13 "bytes"
14 "context"
15 "crypto/sha256"
16 "encoding/hex"
17 "errors"
18 "fmt"
19 "log/slog"
20 "net/mail"
21 "net/textproto"
22 "strconv"
23 "strings"
24 "time"
25
26 "gitbay.org/gitbay/internal/config"
27 "gitbay.org/gitbay/internal/control"
28 "gitbay.org/gitbay/internal/imapc"
29 "gitbay.org/gitbay/internal/mailreply"
30 "gitbay.org/gitbay/internal/protocol"
31 "gitbay.org/gitbay/internal/store"
32)
33
34// Mailbox is what the processor needs of an IMAP session; imapc.Client
35// implements it, and tests use a fake.
36type Mailbox interface {
37 Unseen() ([]uint32, error)
38 Fetch(uid uint32) ([]byte, error)
39 MarkSeen(uid uint32) error
40}
41
42// maxTries is how many polls a message that keeps failing is tried in
43// before it is marked seen and given up on.
44const maxTries = 5
45
46// Processor handles fetched messages.
47type Processor struct {
48 St *store.Store
49 Cfg config.Config
50 Now func() time.Time
51
52 tries map[uint32]int
53 // A window of refusal rows, bounded because anyone can send mail
54 // to the mailbox.
55 windowStart time.Time
56 windowRows int
57}
58
59// refusalsPerMinute bounds the audit rows refusals write.
60const refusalsPerMinute = 60
61
62// Result is what became of one message.
63type Result struct {
64 Posted bool
65 Retry bool // a failure that may pass; the message is left unseen
66 Reason string // why it was refused or failed; empty when posted
67}
68
69func refused(format string, args ...any) Result {
70 return Result{Reason: fmt.Sprintf(format, args...)}
71}
72
73// Drain handles every unseen message in mb. It stops at the first
74// mailbox error.
75func (p *Processor) Drain(mb Mailbox) error {
76 if p.tries == nil {
77 p.tries = map[uint32]int{}
78 }
79 uids, err := mb.Unseen()
80 if err != nil {
81 return err
82 }
83 for _, uid := range uids {
84 raw, err := mb.Fetch(uid)
85 var res Result
86 switch {
87 case errors.Is(err, imapc.ErrTooLarge):
88 res = refused("message larger than %d bytes", imapc.MaxMessage)
89 p.audit(0, "", res.Reason)
90 case err != nil:
91 return err
92 default:
93 res = p.Handle(raw)
94 }
95 if res.Retry {
96 p.tries[uid]++
97 if p.tries[uid] < maxTries {
98 slog.Warn("mail reply: will retry", "uid", uid, "err", res.Reason)
99 continue
100 }
101 p.audit(0, "", "gave up after "+strconv.Itoa(maxTries)+" tries: "+res.Reason)
102 }
103 delete(p.tries, uid)
104 if err := mb.MarkSeen(uid); err != nil {
105 return err
106 }
107 }
108 return nil
109}
110
111// Handle checks one message and posts it when every check passes.
112// Refusals are audited here.
113func (p *Processor) Handle(raw []byte) Result {
114 msg, err := mail.ReadMessage(bytes.NewReader(raw))
115 if err != nil {
116 return p.refuse(0, "", "unreadable message")
117 }
118 msgID := strings.TrimSpace(msg.Header.Get("Message-Id"))
119 if len(msgID) > 200 {
120 msgID = msgID[:200]
121 }
122 if automatic(msg.Header) {
123 return p.refuse(0, msgID, "automatic reply")
124 }
125 in := p.Cfg.Mail.Inbound
126 token := findToken(msg.Header, in.ReplyAddress)
127 if token == "" {
128 return p.refuse(0, msgID, "not addressed to a reply address")
129 }
130 keys := p.St.Keyring()
131 if keys == nil {
132 return Result{Retry: true, Reason: "no secret key loaded"}
133 }
134 secrets, err := keys.Derive(mailreply.Purpose)
135 if err != nil {
136 return Result{Retry: true, Reason: "secret key: " + err.Error()}
137 }
138 target, err := mailreply.Verify(secrets, token, p.now())
139 switch {
140 case errors.Is(err, mailreply.ErrExpired):
141 return p.refuse(target.UserID, msgID, "reply token expired")
142 case err != nil:
143 return p.refuse(0, msgID, err.Error())
144 }
145
146 u, err := p.St.UserByID(target.UserID)
147 switch {
148 case errors.Is(err, store.ErrNotFound):
149 return p.refuse(0, msgID, "account no longer exists")
150 case err != nil:
151 return Result{Retry: true, Reason: err.Error()}
152 case u.Disabled:
153 return p.refuse(u.ID, msgID, "account disabled")
154 case u.Pending:
155 return p.refuse(u.ID, msgID, "account not active")
156 }
157 // The token alone is not enough: the reply must come from one of
158 // the account's verified addresses.
159 from, err := msg.Header.AddressList("From")
160 if err != nil || len(from) != 1 {
161 return p.refuse(u.ID, msgID, "no single From address")
162 }
163 ok, err := p.St.VerifiedEmailOf(u.ID, from[0].Address)
164 if err != nil {
165 return Result{Retry: true, Reason: err.Error()}
166 }
167 if !ok {
168 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
169 }
170 if on, err := p.St.ReplyEnabled(u.ID); err != nil {
171 return Result{Retry: true, Reason: err.Error()}
172 } else if !on {
173 return p.refuse(u.ID, msgID, "reply by mail is off for the account")
174 }
175
176 text, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, control.MaxCommentBytes)
177 switch {
178 case errors.Is(err, errNoText):
179 return p.refuse(u.ID, msgID, "no text/plain part")
180 case errors.Is(err, errTooLong):
181 return p.refuse(u.ID, msgID, "reply too long")
182 case err != nil:
183 return p.refuse(u.ID, msgID, "unreadable body")
184 }
185 text = stripQuoted(text)
186 if text == "" {
187 return p.refuse(u.ID, msgID, "empty reply")
188 }
189 if len(text) > control.MaxCommentBytes {
190 return p.refuse(u.ID, msgID, "reply too long")
191 }
192
193 repo, err := p.St.RepoByID(target.RepoID)
194 switch {
195 case errors.Is(err, store.ErrNotFound):
196 return p.refuse(u.ID, msgID, "repository no longer exists")
197 case err != nil:
198 return Result{Retry: true, Reason: err.Error()}
199 }
200
201 key := msgID
202 if key == "" {
203 sum := sha256.Sum256(raw)
204 key = "sha256:" + hex.EncodeToString(sum[:])
205 }
206 claimed, err := p.St.ClaimMailReply(key)
207 if err != nil {
208 return Result{Retry: true, Reason: err.Error()}
209 }
210 if !claimed {
211 return p.refuse(u.ID, msgID, "already posted")
212 }
213
214 var stdout, stderr bytes.Buffer
215 c := &control.Ctx{User: u, Scope: "full", Store: p.St, Cfg: p.Cfg,
216 Stdin: strings.NewReader(text), Stdout: &stdout, Stderr: &stderr,
217 Source: control.SourceMail}
218 code := control.Dispatch(c, []string{target.Kind, "comment", repo.Path(),
219 strconv.FormatInt(target.Number, 10), "--file", "-"})
220 if code == protocol.ExitOK {
221 return Result{Posted: true}
222 }
223 p.St.ReleaseMailReply(key)
224 reason := strings.TrimSpace(stderr.String())
225 if code == protocol.ExitFailure {
226 return Result{Retry: true, Reason: reason}
227 }
228 // Dispatch has audited a denied or not-found refusal already; this
229 // row says it came by mail and why.
230 return p.refuse(u.ID, msgID, "comment refused: "+reason)
231}
232
233func (p *Processor) now() time.Time {
234 if p.Now != nil {
235 return p.Now()
236 }
237 return time.Now()
238}
239
240func (p *Processor) refuse(actor int64, msgID, reason string) Result {
241 p.audit(actor, msgID, reason)
242 return Result{Reason: reason}
243}
244
245// audit records a refusal: the reason and the Message-ID, never content
246// from the message. Past refusalsPerMinute rows in a minute, the rest of
247// that minute's refusals are counted in one row, written with the first
248// refusal after it.
249func (p *Processor) audit(actor int64, msgID, reason string) {
250 now := p.now()
251 if now.Sub(p.windowStart) >= time.Minute {
252 if over := p.windowRows - refusalsPerMinute; over > 0 {
253 p.St.Audit(0, "refused mail reply", map[string]any{"reason": "throttled", "dropped": over, "source": control.SourceMail})
254 }
255 p.windowStart, p.windowRows = now, 0
256 }
257 p.windowRows++
258 if p.windowRows > refusalsPerMinute {
259 return
260 }
261 data := map[string]any{"reason": reason, "source": control.SourceMail}
262 if msgID != "" {
263 data["message_id"] = msgID
264 }
265 p.St.Audit(actor, "refused mail reply", data)
266}
267
268// automatic reports an auto-responder's message (RFC 3834, and the
269// headers older responders use), which must not post a comment.
270func automatic(h mail.Header) bool {
271 if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
272 return true
273 }
274 switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
275 case "bulk", "junk", "list", "auto_reply":
276 return true
277 }
278 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != ""
279}
280
281// findToken returns the reply token from the first recipient header
282// that carries one.
283func findToken(h mail.Header, base string) string {
284 for _, name := range []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} {
285 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] {
286 addrs, err := mail.ParseAddressList(v)
287 if err != nil {
288 continue
289 }
290 for _, a := range addrs {
291 if tok, ok := mailreply.TokenFrom(base, a.Address); ok {
292 return tok
293 }
294 }
295 }
296 }
297 return ""
298}
299
300// Poller reads the configured mailbox every poll interval.
301type Poller struct {
302 P *Processor
303 In config.MailInbound
304}
305
306// Run polls until ctx is done.
307func (pl *Poller) Run(ctx context.Context) {
308 t := time.NewTicker(pl.In.Poll())
309 defer t.Stop()
310 for {
311 if err := pl.Once(); err != nil {
312 // The error names the server and the IMAP failure; the
313 // password never reaches it (imapc.Client.Login).
314 slog.Warn("mail reply: poll failed", "server", pl.In.Addr(), "err", err)
315 }
316 select {
317 case <-ctx.Done():
318 return
319 case <-t.C:
320 }
321 }
322}
323
324// Once connects, handles what is waiting, and disconnects.
325func (pl *Poller) Once() error {
326 c, _, err := imapc.Open(pl.In, false, time.Minute)
327 if err != nil {
328 return err
329 }
330 defer c.Close()
331 c.SetDeadline(time.Now().Add(10 * time.Minute))
332 if err := pl.P.Drain(c); err != nil {
333 return err
334 }
335 return pl.P.St.PruneMailReplies(pl.P.now().Add(-mailreply.Lifetime - 24*time.Hour))
336}
internal/mailin/mailin_test.go added +321
@@ -0,0 +1,321 @@
1package mailin
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/imapc"
11 "gitbay.org/gitbay/internal/mailreply"
12 "gitbay.org/gitbay/internal/seal"
13 "gitbay.org/gitbay/internal/store"
14)
15
16const replyBase = "reply@gitbay.example"
17
18type fixture struct {
19 p *Processor
20 st *store.Store
21 repo store.Repo
22 issueID int64
23 bob int64
24 secrets [][]byte
25}
26
27// setup is alice's public repository alice/app with issue #1, and bob,
28// who has a verified address and reply by mail on.
29func setup(t *testing.T) *fixture {
30 t.Helper()
31 st, err := store.Open(":memory:")
32 if err != nil {
33 t.Fatal(err)
34 }
35 t.Cleanup(func() { st.Close() })
36 if err := st.MigrateUp(); err != nil {
37 t.Fatal(err)
38 }
39 key, err := seal.NewKey()
40 if err != nil {
41 t.Fatal(err)
42 }
43 keyFile := t.TempDir() + "/secret.key"
44 if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
45 t.Fatal(err)
46 }
47 ring, err := seal.Load(keyFile)
48 if err != nil {
49 t.Fatal(err)
50 }
51 st.SetKeyring(ring)
52 alice, err := st.CreateUser("alice", false)
53 if err != nil {
54 t.Fatal(err)
55 }
56 bob, err := st.CreateUser("bob", false)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if err := st.AddEmail(bob, "Bob@Example.test", "admin", true); err != nil {
61 t.Fatal(err)
62 }
63 if err := st.AddEmail(bob, "old@example.test", "", false); err != nil {
64 t.Fatal(err)
65 }
66 st.SetReplyEnabled(bob, true)
67 repoID, err := st.CreateRepo("user", alice, "app", "public")
68 if err != nil {
69 t.Fatal(err)
70 }
71 repo, err := st.RepoByID(repoID)
72 if err != nil {
73 t.Fatal(err)
74 }
75 issueID, err := st.CreateIssue(repo.ID, alice, "title", "", "md")
76 if err != nil {
77 t.Fatal(err)
78 }
79 var cfg config.Config
80 cfg.Server.SiteURL = "https://gitbay.example"
81 cfg.Mail.Inbound = config.MailInbound{Enabled: true, ReplyAddress: replyBase}
82 secrets, err := ring.Derive(mailreply.Purpose)
83 if err != nil {
84 t.Fatal(err)
85 }
86 return &fixture{p: &Processor{St: st, Cfg: cfg}, st: st, repo: repo,
87 issueID: issueID, bob: bob, secrets: secrets}
88}
89
90func (f *fixture) token(t *testing.T, user int64) string {
91 t.Helper()
92 tok, err := mailreply.Mint(f.secrets, mailreply.Target{UserID: user, RepoID: f.repo.ID, Kind: "issue", Number: 1},
93 time.Now().Add(mailreply.Lifetime))
94 if err != nil {
95 t.Fatal(err)
96 }
97 return tok
98}
99
100var msgSeq int
101
102func (f *fixture) message(t *testing.T, from, body string) string {
103 t.Helper()
104 msgSeq++
105 return fmt.Sprintf("From: Bob <%s>\r\nTo: gitbay <%s>\r\nSubject: Re: [alice/app] #1: title\r\nMessage-ID: <m%d@example.test>\r\n"+
106 "Content-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", from, mailreply.Address(replyBase, f.token(t, f.bob)), msgSeq, body)
107}
108
109func (f *fixture) comments(t *testing.T) []store.IssueComment {
110 t.Helper()
111 cs, err := f.st.ListIssueComments(f.issueID)
112 if err != nil {
113 t.Fatal(err)
114 }
115 return cs
116}
117
118// refusalReasons reads back the audit rows the processor wrote.
119func (f *fixture) refusalReasons(t *testing.T) string {
120 t.Helper()
121 entries, err := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "refused mail reply", Limit: 100})
122 if err != nil {
123 t.Fatal(err)
124 }
125 var b strings.Builder
126 for _, e := range entries {
127 b.WriteString(e.Data + "\n")
128 }
129 return b.String()
130}
131
132func TestReplyPostsComment(t *testing.T) {
133 f := setup(t)
134 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")))
135 if !res.Posted {
136 t.Fatalf("not posted: %+v", res)
137 }
138 cs := f.comments(t)
139 if len(cs) != 1 || cs[0].Body != "Looks good." || cs[0].Author != "bob" {
140 t.Fatalf("comments = %+v", cs)
141 }
142 entries, _ := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "cmd issue comment", Limit: 10})
143 if len(entries) != 1 || !strings.Contains(entries[0].Data, `"source":"mail"`) {
144 t.Fatalf("audit = %+v", entries)
145 }
146}
147
148func TestReplyRefusals(t *testing.T) {
149 for _, tc := range []struct {
150 name string
151 prep func(t *testing.T, f *fixture) string // returns the message
152 reason string
153 }{
154 {"wrong From", func(t *testing.T, f *fixture) string {
155 return f.message(t, "mallory@example.test", "hi")
156 }, "From is not a verified address"},
157 {"unverified From", func(t *testing.T, f *fixture) string {
158 return f.message(t, "old@example.test", "hi")
159 }, "From is not a verified address"},
160 {"revoked access", func(t *testing.T, f *fixture) string {
161 f.st.SetRepoVisibility(f.repo.ID, "private")
162 return f.message(t, "bob@example.test", "hi")
163 }, "comment refused: repository alice/app not found"},
164 {"disabled account", func(t *testing.T, f *fixture) string {
165 f.st.SetUserDisabled(f.bob, true)
166 return f.message(t, "bob@example.test", "hi")
167 }, "account disabled"},
168 {"archived repository", func(t *testing.T, f *fixture) string {
169 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
170 return f.message(t, "bob@example.test", "hi")
171 }, "archived"},
172 {"expired token", func(t *testing.T, f *fixture) string {
173 f.p.Now = func() time.Time { return time.Now().Add(mailreply.Lifetime + time.Hour) }
174 return f.message(t, "bob@example.test", "hi")
175 }, "reply token expired"},
176 {"reply turned off", func(t *testing.T, f *fixture) string {
177 f.st.SetReplyEnabled(f.bob, false)
178 return f.message(t, "bob@example.test", "hi")
179 }, "reply by mail is off"},
180 {"forged token", func(t *testing.T, f *fixture) string {
181 m := f.message(t, "bob@example.test", "hi")
182 tok := f.token(t, f.bob)
183 c := "a"
184 if tok[0] == 'a' {
185 c = "b"
186 }
187 return strings.Replace(m, tok, c+tok[1:], 1)
188 }, "does not verify"},
189 {"no reply address", func(t *testing.T, f *fixture) string {
190 return strings.Replace(f.message(t, "bob@example.test", "hi"), "reply+", "other+", 1)
191 }, "not addressed to a reply address"},
192 {"empty after stripping", func(t *testing.T, f *fixture) string {
193 return f.message(t, "bob@example.test", "> quoted only\r\n-- \r\nBob")
194 }, "empty reply"},
195 {"automatic reply", func(t *testing.T, f *fixture) string {
196 return "Auto-Submitted: auto-replied\r\n" + f.message(t, "bob@example.test", "I am away")
197 }, "automatic reply"},
198 {"html only", func(t *testing.T, f *fixture) string {
199 return strings.Replace(f.message(t, "bob@example.test", "<p>hi</p>"), "text/plain", "text/html", 1)
200 }, "no text/plain part"},
201 {"too long", func(t *testing.T, f *fixture) string {
202 return f.message(t, "bob@example.test", strings.Repeat("a", 70<<10))
203 }, "reply too long"},
204 } {
205 t.Run(tc.name, func(t *testing.T) {
206 f := setup(t)
207 res := f.p.Handle([]byte(tc.prep(t, f)))
208 if res.Posted || res.Retry || !strings.Contains(res.Reason, tc.reason) {
209 t.Fatalf("result %+v, want refusal %q", res, tc.reason)
210 }
211 if n := len(f.comments(t)); n != 0 {
212 t.Fatalf("%d comments posted", n)
213 }
214 audit := f.refusalReasons(t)
215 if !strings.Contains(audit, tc.reason) {
216 t.Fatalf("audit does not name the reason:\n%s", audit)
217 }
218 if strings.Contains(audit, "I am away") || strings.Contains(audit, "<p>hi") {
219 t.Fatalf("audit carries message content:\n%s", audit)
220 }
221 })
222 }
223}
224
225func TestDuplicateMessageID(t *testing.T) {
226 f := setup(t)
227 m := []byte(f.message(t, "bob@example.test", "once"))
228 if res := f.p.Handle(m); !res.Posted {
229 t.Fatalf("first: %+v", res)
230 }
231 if res := f.p.Handle(m); res.Posted || !strings.Contains(res.Reason, "already posted") {
232 t.Fatalf("second: %+v", res)
233 }
234 if n := len(f.comments(t)); n != 1 {
235 t.Fatalf("%d comments", n)
236 }
237}
238
239// A refused reply leaves no claim, so the same message is judged afresh.
240func TestRefusalLeavesNoClaim(t *testing.T) {
241 f := setup(t)
242 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
243 m := []byte(f.message(t, "bob@example.test", "hi"))
244 if res := f.p.Handle(m); res.Posted {
245 t.Fatal("posted to an archived repository")
246 }
247 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = false })
248 if res := f.p.Handle(m); !res.Posted {
249 t.Fatalf("after unarchive: %+v", res)
250 }
251}
252
253type fakeMailbox struct {
254 msgs map[uint32][]byte
255 seen map[uint32]bool
256}
257
258func (m *fakeMailbox) Unseen() ([]uint32, error) {
259 var out []uint32
260 for uid := range m.msgs {
261 if !m.seen[uid] {
262 out = append(out, uid)
263 }
264 }
265 return out, nil
266}
267
268func (m *fakeMailbox) Fetch(uid uint32) ([]byte, error) {
269 if m.msgs[uid] == nil {
270 return nil, imapc.ErrTooLarge
271 }
272 return m.msgs[uid], nil
273}
274
275func (m *fakeMailbox) MarkSeen(uid uint32) error {
276 m.seen[uid] = true
277 return nil
278}
279
280// Posted and refused messages alike are marked seen.
281func TestDrainMarksSeen(t *testing.T) {
282 f := setup(t)
283 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
284 1: []byte(f.message(t, "bob@example.test", "posted")),
285 2: []byte(f.message(t, "mallory@example.test", "refused")),
286 3: nil, // too large
287 }}
288 if err := f.p.Drain(mb); err != nil {
289 t.Fatal(err)
290 }
291 for uid := range mb.msgs {
292 if !mb.seen[uid] {
293 t.Errorf("message %d not marked seen", uid)
294 }
295 }
296 if n := len(f.comments(t)); n != 1 {
297 t.Fatalf("%d comments", n)
298 }
299}
300
301// A message that fails for a reason that may pass stays unseen, until it
302// has failed maxTries times.
303func TestDrainRetriesTransientFailure(t *testing.T) {
304 f := setup(t)
305 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
306 1: []byte(f.message(t, "bob@example.test", "hi")),
307 }}
308 f.st.SetKeyring(nil) // Handle reads no key: a retry
309 for i := 1; i < maxTries; i++ {
310 if err := f.p.Drain(mb); err != nil {
311 t.Fatal(err)
312 }
313 if mb.seen[1] {
314 t.Fatalf("marked seen after %d tries", i)
315 }
316 }
317 f.p.Drain(mb)
318 if !mb.seen[1] {
319 t.Fatal("not given up on")
320 }
321}
internal/mailreply/mailreply.go added +163
@@ -0,0 +1,163 @@
1// Package mailreply mints and verifies the token in a notification's
2// Reply-To address, reply+<token>@<domain> (#295). The token names the
3// recipient, the repository and the thread, and an expiry; an HMAC under
4// a key derived from the instance's secret key file binds them, so no
5// row is stored per message.
6package mailreply
7
8import (
9 "crypto/hmac"
10 "crypto/sha256"
11 "encoding/base32"
12 "encoding/binary"
13 "errors"
14 "fmt"
15 "strings"
16 "time"
17)
18
19// Lifetime is how long a notification's reply address is accepted.
20const Lifetime = 30 * 24 * time.Hour
21
22// Purpose is what the MAC key is derived for (seal.Keyring.Derive).
23const Purpose = "gitbay mail reply token v1"
24
25const (
26 version = 1
27 macLen = 12
28)
29
30// Target is the thread a reply posts to, and who it posts as.
31type Target struct {
32 UserID int64
33 RepoID int64
34 Kind string // "issue" or "mr"
35 Number int64
36}
37
38var (
39 ErrMalformed = errors.New("malformed reply token")
40 ErrBadMAC = errors.New("reply token does not verify")
41 ErrExpired = errors.New("reply token expired")
42)
43
44// Mail systems may fold the local part to lower case, so the token is
45// lower-case base32.
46var enc = base32.StdEncoding.WithPadding(base32.NoPadding)
47
48// Mint returns the token for t, valid until expires, authenticated under
49// keys[0].
50func Mint(keys [][]byte, t Target, expires time.Time) (string, error) {
51 if len(keys) == 0 {
52 return "", errors.New("no key to mint a reply token under")
53 }
54 var kind byte
55 switch t.Kind {
56 case "issue":
57 kind = 'i'
58 case "mr":
59 kind = 'm'
60 default:
61 return "", fmt.Errorf("reply token: unknown kind %q", t.Kind)
62 }
63 if t.UserID <= 0 || t.RepoID <= 0 || t.Number <= 0 {
64 return "", errors.New("reply token: ids must be positive")
65 }
66 p := []byte{version, kind}
67 p = binary.AppendUvarint(p, uint64(t.UserID))
68 p = binary.AppendUvarint(p, uint64(t.RepoID))
69 p = binary.AppendUvarint(p, uint64(t.Number))
70 p = binary.AppendUvarint(p, uint64(expires.Unix()/3600))
71 p = append(p, mac(keys[0], p)...)
72 return strings.ToLower(enc.EncodeToString(p)), nil
73}
74
75// Verify checks token against every key and returns its target. An
76// expired token that verifies returns its target with ErrExpired, so the
77// refusal can name the account.
78func Verify(keys [][]byte, token string, now time.Time) (Target, error) {
79 up := strings.ToUpper(token)
80 raw, err := enc.DecodeString(up)
81 // Only the canonical encoding is accepted: base32 leaves spare bits
82 // in the last character, and a character past the last byte.
83 if err != nil || len(raw) < 2+4+macLen || enc.EncodeToString(raw) != up {
84 return Target{}, ErrMalformed
85 }
86 p, sum := raw[:len(raw)-macLen], raw[len(raw)-macLen:]
87 ok := false
88 for _, k := range keys {
89 if hmac.Equal(mac(k, p), sum) {
90 ok = true
91 }
92 }
93 if !ok {
94 return Target{}, ErrBadMAC
95 }
96 if p[0] != version {
97 return Target{}, ErrMalformed
98 }
99 var t Target
100 switch p[1] {
101 case 'i':
102 t.Kind = "issue"
103 case 'm':
104 t.Kind = "mr"
105 default:
106 return Target{}, ErrMalformed
107 }
108 rest := p[2:]
109 var v [4]uint64
110 for i := range v {
111 n, w := binary.Uvarint(rest)
112 if w <= 0 || n > 1<<62 {
113 return Target{}, ErrMalformed
114 }
115 v[i], rest = n, rest[w:]
116 }
117 if len(rest) != 0 {
118 return Target{}, ErrMalformed
119 }
120 t.UserID, t.RepoID, t.Number = int64(v[0]), int64(v[1]), int64(v[2])
121 if !now.Before(time.Unix(int64(v[3])*3600, 0)) {
122 return t, ErrExpired
123 }
124 return t, nil
125}
126
127func mac(key, p []byte) []byte {
128 m := hmac.New(sha256.New, key)
129 m.Write(p)
130 return m.Sum(nil)[:macLen]
131}
132
133// Address puts token into base, the configured reply address:
134// reply@example.org becomes reply+<token>@example.org.
135func Address(base, token string) string {
136 local, domain, _ := strings.Cut(base, "@")
137 return local + "+" + token + "@" + domain
138}
139
140// TokenFrom returns the token in addr when addr is base with a token
141// added; the comparison ignores case.
142func TokenFrom(base, addr string) (string, bool) {
143 local, domain, ok := strings.Cut(base, "@")
144 if !ok {
145 return "", false
146 }
147 i := strings.LastIndexByte(addr, '@')
148 if i < 0 || !strings.EqualFold(addr[i+1:], domain) {
149 return "", false
150 }
151 tok, ok := cutPrefixFold(addr[:i], local+"+")
152 if !ok || tok == "" {
153 return "", false
154 }
155 return tok, true
156}
157
158func cutPrefixFold(s, prefix string) (string, bool) {
159 if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
160 return "", false
161 }
162 return s[len(prefix):], true
163}
internal/mailreply/mailreply_test.go added +139
@@ -0,0 +1,139 @@
1package mailreply
2
3import (
4 "errors"
5 "strings"
6 "testing"
7 "time"
8)
9
10var (
11 keyA = []byte("0123456789abcdef0123456789abcdef")
12 keyB = []byte("fedcba9876543210fedcba9876543210")
13 now = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
14)
15
16func mint(t *testing.T, keys [][]byte, tg Target) string {
17 t.Helper()
18 tok, err := Mint(keys, tg, now.Add(Lifetime))
19 if err != nil {
20 t.Fatal(err)
21 }
22 return tok
23}
24
25func TestRoundTrip(t *testing.T) {
26 for _, tg := range []Target{
27 {UserID: 1, RepoID: 2, Kind: "issue", Number: 3},
28 {UserID: 1 << 40, RepoID: 99999, Kind: "mr", Number: 123456},
29 } {
30 tok := mint(t, [][]byte{keyA}, tg)
31 if tok != strings.ToLower(tok) {
32 t.Errorf("token %q is not lower case", tok)
33 }
34 // The address's local part stays within 64 octets.
35 if l := len("reply+" + tok); l > 64 {
36 t.Errorf("local part is %d octets", l)
37 }
38 got, err := Verify([][]byte{keyA}, tok, now)
39 if err != nil || got != tg {
40 t.Errorf("Verify = %+v, %v; want %+v", got, err, tg)
41 }
42 // A mail system that upper-cases the local part does not break it.
43 if got, err := Verify([][]byte{keyA}, strings.ToUpper(tok), now); err != nil || got != tg {
44 t.Errorf("upper-case Verify = %+v, %v", got, err)
45 }
46 }
47}
48
49// A token minted before a rotation verifies while the old key is in the file.
50func TestRotation(t *testing.T) {
51 tg := Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}
52 tok := mint(t, [][]byte{keyA}, tg)
53 if _, err := Verify([][]byte{keyB, keyA}, tok, now); err != nil {
54 t.Fatal(err)
55 }
56 if _, err := Verify([][]byte{keyB}, tok, now); !errors.Is(err, ErrBadMAC) {
57 t.Fatalf("retired key: %v", err)
58 }
59}
60
61func TestTamper(t *testing.T) {
62 tok := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
63 for i := range tok {
64 c := byte('a')
65 if tok[i] == 'a' {
66 c = 'b'
67 }
68 bad := tok[:i] + string(c) + tok[i+1:]
69 if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
70 t.Fatalf("changed character %d verified", i)
71 }
72 }
73 for _, bad := range []string{"", "x", "!!!!", tok[:len(tok)-1], tok + "a"} {
74 if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
75 t.Errorf("%q verified", bad)
76 }
77 }
78}
79
80func TestExpiry(t *testing.T) {
81 tg := Target{UserID: 1, RepoID: 2, Kind: "mr", Number: 3}
82 tok := mint(t, [][]byte{keyA}, tg)
83 if _, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime-time.Hour)); err != nil {
84 t.Fatalf("before expiry: %v", err)
85 }
86 got, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime+time.Hour))
87 if !errors.Is(err, ErrExpired) || got != tg {
88 t.Fatalf("after expiry: %+v, %v", got, err)
89 }
90}
91
92// Two recipients of one notification get different tokens, and neither
93// verifies as the other.
94func TestCrossUser(t *testing.T) {
95 a := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
96 b := mint(t, [][]byte{keyA}, Target{UserID: 4, RepoID: 2, Kind: "issue", Number: 3})
97 if a == b {
98 t.Fatal("two recipients share a token")
99 }
100 ga, _ := Verify([][]byte{keyA}, a, now)
101 gb, _ := Verify([][]byte{keyA}, b, now)
102 if ga.UserID != 1 || gb.UserID != 4 {
103 t.Fatalf("got users %d and %d", ga.UserID, gb.UserID)
104 }
105}
106
107func TestMintRefuses(t *testing.T) {
108 for _, tg := range []Target{
109 {UserID: 1, RepoID: 2, Kind: "build", Number: 3},
110 {UserID: 0, RepoID: 2, Kind: "issue", Number: 3},
111 } {
112 if _, err := Mint([][]byte{keyA}, tg, now); err == nil {
113 t.Errorf("minted %+v", tg)
114 }
115 }
116 if _, err := Mint(nil, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}, now); err == nil {
117 t.Error("minted with no key")
118 }
119}
120
121func TestAddress(t *testing.T) {
122 a := Address("reply@gitbay.example", "abc")
123 if a != "reply+abc@gitbay.example" {
124 t.Fatalf("Address = %q", a)
125 }
126 for addr, want := range map[string]string{
127 "reply+abc@gitbay.example": "abc",
128 "Reply+ABC@GITBAY.example": "ABC",
129 "reply@gitbay.example": "",
130 "reply+@gitbay.example": "",
131 "reply+abc@other.example": "",
132 "other+abc@gitbay.example": "",
133 } {
134 got, ok := TokenFrom("reply@gitbay.example", addr)
135 if got != want || ok != (want != "") {
136 t.Errorf("TokenFrom(%q) = %q, %v", addr, got, ok)
137 }
138 }
139}
internal/notify/notify.go +1 −1
@@ -50,7 +50,7 @@ func (m *Mailer) Run(ctx context.Context) {
5050 continue
5151 }
5252 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 {
5454 attempt := q.Attempts + 1
5555 if attempt >= m.MaxAttempts {
5656 m.St.MarkMailFailed(q.ID, err.Error(), nil)
internal/seal/seal.go +31 −1
@@ -10,7 +10,9 @@ import (
1010 "bytes"
1111 "crypto/aes"
1212 "crypto/cipher"
13 "crypto/hmac"
1314 "crypto/rand"
15 "crypto/sha256"
1416 "encoding/base64"
1517 "encoding/hex"
1618 "errors"
@@ -180,6 +182,8 @@ type Keyring struct {
180182 fi os.FileInfo
181183 cur string
182184 aead map[string]cipher.AEAD
185 // secrets holds every key's secret, the current key's first.
186 secrets [][]byte
183187}
184188
185189// Load reads the key file at path and returns a Keyring over it. It
@@ -206,6 +210,10 @@ func (k *Keyring) refresh() error {
206210 return err
207211 }
208212 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 }
209217 for _, key := range keys {
210218 block, err := aes.NewCipher(key.Secret)
211219 if err != nil {
@@ -217,10 +225,32 @@ func (k *Keyring) refresh() error {
217225 }
218226 aead[key.ID] = g
219227 }
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
221229 return nil
222230}
223231
232// Derive returns a 32-byte key for purpose from every key in the file,
233// the current key's first: HMAC-SHA256 of purpose under each secret. A
234// value authenticated under the first still verifies under the others
235// after a rotation, while the retired key stays in the file.
236func (k *Keyring) Derive(purpose string) ([][]byte, error) {
237 if purpose == "" {
238 return nil, errors.New("seal: a purpose is required")
239 }
240 k.mu.Lock()
241 defer k.mu.Unlock()
242 if err := k.refresh(); err != nil {
243 return nil, err
244 }
245 out := make([][]byte, 0, len(k.secrets))
246 for _, s := range k.secrets {
247 m := hmac.New(sha256.New, s)
248 m.Write([]byte(purpose))
249 out = append(out, m.Sum(nil))
250 }
251 return out, nil
252}
253
224254// CurrentID is the id of the key that seals new values.
225255func (k *Keyring) CurrentID() (string, error) {
226256 k.mu.Lock()
internal/seal/seal_test.go +31
@@ -232,3 +232,34 @@ func TestReadKeysRefusesDuplicatesDirectoriesAndLargeFiles(t *testing.T) {
232232 t.Error("read a file over the size limit")
233233 }
234234}
235
236// Derive lists the current key's derivation first and keeps the retired
237// key's, and differs by purpose.
238func TestDerive(t *testing.T) {
239 old, cur := newKey(t), newKey(t)
240 one, err := Load(keyFile(t, old))
241 if err != nil {
242 t.Fatal(err)
243 }
244 two, err := Load(keyFile(t, old, cur))
245 if err != nil {
246 t.Fatal(err)
247 }
248 a, err := one.Derive("p")
249 if err != nil || len(a) != 1 || len(a[0]) != 32 {
250 t.Fatalf("Derive = %x, %v", a, err)
251 }
252 b, err := two.Derive("p")
253 if err != nil || len(b) != 2 {
254 t.Fatalf("Derive = %x, %v", b, err)
255 }
256 if string(b[1]) != string(a[0]) || string(b[0]) == string(a[0]) {
257 t.Fatal("rotated ring does not list the current key first and the old one after")
258 }
259 if c, _ := one.Derive("q"); string(c[0]) == string(a[0]) {
260 t.Fatal("two purposes derived the same key")
261 }
262 if _, err := one.Derive(""); err == nil {
263 t.Fatal("empty purpose accepted")
264 }
265}
internal/store/mailreply.go added +43
@@ -0,0 +1,43 @@
1package store
2
3import (
4 "strings"
5 "time"
6)
7
8// ClaimMailReply records that the reply identified by key is being
9// posted. False means an earlier fetch of the same message already
10// claimed it.
11func (s *Store) ClaimMailReply(key string) (bool, error) {
12 res, err := s.DB.Exec("INSERT INTO mail_replies (message_key) VALUES (?) ON CONFLICT DO NOTHING", key)
13 if err != nil {
14 return false, err
15 }
16 n, err := res.RowsAffected()
17 return n == 1, err
18}
19
20// ReleaseMailReply drops a claim whose comment was not posted, so the
21// message can be tried again.
22func (s *Store) ReleaseMailReply(key string) error {
23 _, err := s.DB.Exec("DELETE FROM mail_replies WHERE message_key = ?", key)
24 return err
25}
26
27// PruneMailReplies drops claims older than before. A reply older than a
28// reply token's lifetime is refused on its token, so its claim has no
29// work left to do.
30func (s *Store) PruneMailReplies(before time.Time) error {
31 _, err := s.DB.Exec("DELETE FROM mail_replies WHERE created_at < ?", fmtTime(before))
32 return err
33}
34
35// VerifiedEmailOf reports whether address is a verified address of the
36// account, ignoring case.
37func (s *Store) VerifiedEmailOf(userID int64, address string) (bool, error) {
38 var n int
39 err := s.DB.QueryRow(
40 "SELECT COUNT(*) FROM emails WHERE user_id = ? AND verified_at IS NOT NULL AND lower(address) = ?",
41 userID, strings.ToLower(address)).Scan(&n)
42 return n > 0, err
43}
internal/store/migrations/0073_mail_reply.down.sql added +3
@@ -0,0 +1,3 @@
1DROP TABLE mail_replies;
2ALTER TABLE notifications DROP COLUMN reply_to;
3ALTER TABLE users DROP COLUMN notify_reply;
internal/store/migrations/0073_mail_reply.up.sql added +12
@@ -0,0 +1,12 @@
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. mail_replies records each reply that posted a
5-- comment, keyed by its Message-ID (or a hash of the message when it has
6-- none), so a message fetched twice posts once.
7ALTER TABLE users ADD COLUMN notify_reply INTEGER NOT NULL DEFAULT 0;
8ALTER TABLE notifications ADD COLUMN reply_to TEXT NOT NULL DEFAULT '';
9CREATE TABLE mail_replies (
10 message_key TEXT PRIMARY KEY,
11 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
12);
internal/store/notify.go +10 −4
@@ -5,21 +5,27 @@ import "time"
55type QueuedMail struct {
66 ID int64
77 Recipient string
8 ReplyTo string // "" for none
89 Subject string
910 Body string
1011 Attempts int
1112}
1213
1314func (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.
19func (s *Store) EnqueueMailReplyTo(recipient, replyTo, subject, body string) error {
1420 _, err := s.DB.Exec(
15 "INSERT INTO notifications (recipient, subject, body) VALUES (?, ?, ?)",
16 recipient, subject, body)
21 "INSERT INTO notifications (recipient, reply_to, subject, body) VALUES (?, ?, ?, ?)",
22 recipient, replyTo, subject, body)
1723 return err
1824}
1925
2026func (s *Store) DueMail(limit int) ([]QueuedMail, error) {
2127 rows, err := s.DB.Query(`
22 SELECT id, recipient, subject, body, attempts FROM notifications
28 SELECT id, recipient, reply_to, subject, body, attempts FROM notifications
2329 WHERE sent_at IS NULL AND failed_at IS NULL
2430 AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
2531 ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit)
@@ -30,7 +36,7 @@ func (s *Store) DueMail(limit int) ([]QueuedMail, error) {
3036 var out []QueuedMail
3137 for rows.Next() {
3238 var m QueuedMail
33 if err := rows.Scan(&m.ID, &m.Recipient, &m.Subject, &m.Body, &m.Attempts); err != nil {
39 if err := rows.Scan(&m.ID, &m.Recipient, &m.ReplyTo, &m.Subject, &m.Body, &m.Attempts); err != nil {
3440 return nil, err
3541 }
3642 out = append(out, m)
internal/store/secrets.go +3
@@ -48,6 +48,9 @@ var secretColumns = []secretColumn{
4848// SetKeyring sets the keys the secret columns are sealed under.
4949func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k }
5050
51// Keyring is the loaded key file, nil when none is set.
52func (s *Store) Keyring() *seal.Keyring { return s.secrets }
53
5154// sealValue seals v for storage. An empty value stays empty: for
5255// webhooks and mirrors it means there is no secret.
5356func (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 {
230230 return err
231231}
232232
233// ReplyEnabled reports whether the account's issue and merge request
234// mail carries a reply address (#295).
235func (s *Store) ReplyEnabled(userID int64) (bool, error) {
236 var on int
237 err := s.DB.QueryRow("SELECT notify_reply FROM users WHERE id = ?", userID).Scan(&on)
238 if errors.Is(err, sql.ErrNoRows) {
239 return false, ErrNotFound
240 }
241 return on != 0, err
242}
243
244func (s *Store) SetReplyEnabled(userID int64, on bool) error {
245 v := 0
246 if on {
247 v = 1
248 }
249 _, err := s.DB.Exec("UPDATE users SET notify_reply = ? WHERE id = ?", v, userID)
250 return err
251}
252
233253// WatchEnabled reports whether the account hears about every issue and
234254// merge request on the repositories it can write to, without a
235255// repo_watchers row on each (#194).
internal/web/templates/account.html +8 −1
@@ -138,7 +138,14 @@ account and where notifications go.</p>
138138 <button type="submit" class="btn">Save</button>
139139</form>
140140<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">
142149 <input type="hidden" name="field" value="notify-watch">
143150 <label for="notify-watch">Watch repositories you can write to</label>
144151 <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div>