Commit b823096731
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +6
| @@ -74,6 +74,7 @@ func newRoot() *cobra.Command { | |||
| 74 | group("settings", "notification preferences", | 74 | group("settings", "notification preferences", |
| 75 | pass("show", passOpts{server: []string{"notifications", "settings", "show"}}), | 75 | pass("show", passOpts{server: []string{"notifications", "settings", "show"}}), |
| 76 | pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}), | 76 | pass("mail", passOpts{server: []string{"notifications", "settings", "mail"}}), |
| 77 | pass("reply", passOpts{server: []string{"notifications", "settings", "reply"}}), | ||
| 77 | pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}), | 78 | pass("watch", passOpts{server: []string{"notifications", "settings", "watch"}}), |
| 78 | pass("push", passOpts{server: []string{"notifications", "settings", "push"}}), | 79 | pass("push", passOpts{server: []string{"notifications", "settings", "push"}}), |
| 79 | ), | 80 | ), |
| @@ -149,6 +150,11 @@ func newRoot() *cobra.Command { | |||
| 149 | group("symbols", "symbol indexes", | 150 | group("symbols", "symbol indexes", |
| 150 | pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}), | 151 | pass("reindex", passOpts{server: []string{"admin", "symbols", "reindex"}}), |
| 151 | ), | 152 | ), |
| 153 | group("mail", "the instance's mail", | ||
| 154 | group("inbound", "the mailbox replies to notification mail arrive in", | ||
| 155 | pass("check", passOpts{server: []string{"admin", "mail", "inbound", "check"}}), | ||
| 156 | ), | ||
| 157 | ), | ||
| 152 | ), | 158 | ), |
| 153 | manCmd(root), | 159 | manCmd(root), |
| 154 | ) | 160 | ) |
cmd/gitbay/summaries_gen.go +2
| @@ -7,6 +7,7 @@ var summaries = map[string]string{ | |||
| 7 | "account import-bundle": "replay an account bundle (see gitbay migrate)", | 7 | "account import-bundle": "replay an account bundle (see gitbay migrate)", |
| 8 | "admin email verify": "mark an address verified by admin assertion", | 8 | "admin email verify": "mark an address verified by admin assertion", |
| 9 | "admin invite": "issue a registration invite and mail its code", | 9 | "admin invite": "issue a registration invite and mail its code", |
| 10 | "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting", | ||
| 10 | "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", | 11 | "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", |
| 11 | "admin repo archive": "archive any repository (instance admins; audited)", | 12 | "admin repo archive": "archive any repository (instance admins; audited)", |
| 12 | "admin repo delete": "delete any repository (instance admins; audited)", | 13 | "admin repo delete": "delete any repository (instance admins; audited)", |
| @@ -96,6 +97,7 @@ var summaries = map[string]string{ | |||
| 96 | "notifications read": "mark notifications read", | 97 | "notifications read": "mark notifications read", |
| 97 | "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)", | 98 | "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)", |
| 98 | "notifications settings push": "activity on your registered devices as well as the inbox", | 99 | "notifications settings push": "activity on your registered devices as well as the inbox", |
| 100 | "notifications settings reply": "reply to issue and merge request mail to comment", | ||
| 99 | "notifications settings show": "your notification preferences", | 101 | "notifications settings show": "your notification preferences", |
| 100 | "notifications settings watch": "every issue and merge request on repositories you can write to", | 102 | "notifications settings watch": "every issue and merge request on repositories you can write to", |
| 101 | "org create": "create an organization (you become its first admin)", | 103 | "org create": "create an organization (you become its first admin)", |
cmd/gitbayd/main.go +9
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | "gitbay.org/gitbay/internal/gitd" | 30 | "gitbay.org/gitbay/internal/gitd" |
| 31 | "gitbay.org/gitbay/internal/hookd" | 31 | "gitbay.org/gitbay/internal/hookd" |
| 32 | "gitbay.org/gitbay/internal/httpd" | 32 | "gitbay.org/gitbay/internal/httpd" |
| 33 | "gitbay.org/gitbay/internal/mailin" | ||
| 33 | "gitbay.org/gitbay/internal/mirror" | 34 | "gitbay.org/gitbay/internal/mirror" |
| 34 | "gitbay.org/gitbay/internal/notify" | 35 | "gitbay.org/gitbay/internal/notify" |
| 35 | "gitbay.org/gitbay/internal/packlimit" | 36 | "gitbay.org/gitbay/internal/packlimit" |
| @@ -206,6 +207,14 @@ func serveCmd() *cobra.Command { | |||
| 206 | if cfg.Mail.SMTPHost != "" { | 207 | if cfg.Mail.SMTPHost != "" { |
| 207 | go notify.New(st, cfg, retryBase).Run(whCtx) | 208 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 208 | } | 209 | } |
| 210 | if in := cfg.Mail.Inbound; in.Enabled { | ||
| 211 | // An unreadable password file is a misconfiguration: | ||
| 212 | // refuse to start rather than poll and fail every tick. | ||
| 213 | if _, err := in.Password(); err != nil { | ||
| 214 | return err | ||
| 215 | } | ||
| 216 | go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx) | ||
| 217 | } | ||
| 209 | if cfg.Push.Enabled { | 218 | if cfg.Push.Enabled { |
| 210 | p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase) | 219 | p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase) |
| 211 | if err != nil { | 220 | if err != nil { |
e2e/readonly_test.go +1
| @@ -110,6 +110,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 110 | "admin runners": {}, | 110 | "admin runners": {}, |
| 111 | "admin repo list": {}, | 111 | "admin repo list": {}, |
| 112 | "admin stats": {}, | 112 | "admin stats": {}, |
| 113 | "admin mail inbound check": {}, | ||
| 113 | "admin user show": {"alice"}, | 114 | "admin user show": {"alice"}, |
| 114 | "profile show": {"alice"}, | 115 | "profile show": {"alice"}, |
| 115 | "org show": {"theorg"}, | 116 | "org show": {"theorg"}, |
internal/config/config.go +116
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "encoding/pem" | 7 | "encoding/pem" |
| 8 | "errors" | 8 | "errors" |
| 9 | "fmt" | 9 | "fmt" |
| 10 | "io" | ||
| 10 | "math" | 11 | "math" |
| 11 | "net" | 12 | "net" |
| 12 | "os" | 13 | "os" |
| @@ -273,6 +274,117 @@ type Mail struct { | |||
| 273 | // TLS is "starttls" (the default, also when empty) or "implicit": | 274 | // TLS is "starttls" (the default, also when empty) or "implicit": |
| 274 | // TLS from the first byte, as relays on port 465 expect. | 275 | // TLS from the first byte, as relays on port 465 expect. |
| 275 | TLS string `toml:"tls,omitempty"` | 276 | TLS string `toml:"tls,omitempty"` |
| 277 | // Inbound polls a mailbox for replies to notification mail (#295). | ||
| 278 | Inbound MailInbound `toml:"inbound"` | ||
| 279 | } | ||
| 280 | |||
| 281 | // MailInbound is the IMAP mailbox replies to notification mail arrive | ||
| 282 | // in. Off unless enabled. The connection is always encrypted; there is | ||
| 283 | // no setting for plaintext. | ||
| 284 | type MailInbound struct { | ||
| 285 | Enabled bool `toml:"enabled"` | ||
| 286 | // IMAPHost is host:port; the port defaults to 993 with tls = | ||
| 287 | // "implicit" (the default) and 143 with tls = "starttls". | ||
| 288 | IMAPHost string `toml:"imap_host"` | ||
| 289 | TLS string `toml:"tls"` | ||
| 290 | User string `toml:"user"` | ||
| 291 | // PasswordFile holds the mailbox password, read at each connection. | ||
| 292 | // Never inline in this file. | ||
| 293 | PasswordFile string `toml:"password_file"` | ||
| 294 | Mailbox string `toml:"mailbox"` // default INBOX | ||
| 295 | PollInterval string `toml:"poll_interval"` // default 1m | ||
| 296 | // ReplyAddress is the address a notification's Reply-To is built | ||
| 297 | // from: reply@example.org becomes reply+<token>@example.org, so the | ||
| 298 | // mailbox must receive plus-addressed mail for it (or a catch-all). | ||
| 299 | ReplyAddress string `toml:"reply_address"` | ||
| 300 | } | ||
| 301 | |||
| 302 | // DefaultInboundPoll is the poll interval when poll_interval is unset. | ||
| 303 | const DefaultInboundPoll = time.Minute | ||
| 304 | |||
| 305 | // Poll is the configured poll interval. | ||
| 306 | func (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. | ||
| 314 | func (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. | ||
| 325 | func (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. | ||
| 334 | func (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 | |||
| 358 | func (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 | ||
| 276 | } | 388 | } |
| 277 | 389 | ||
| 278 | // TLSRequired reports whether mail must not go to the relay in clear. | 390 | // TLSRequired reports whether mail must not go to the relay in clear. |
| @@ -563,6 +675,10 @@ func (c Config) Validate() error { | |||
| 563 | if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" { | 675 | if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" { |
| 564 | errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t)) | 676 | errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t)) |
| 565 | } | 677 | } |
| 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 | } | ||
| 566 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { | 682 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { |
| 567 | errs = append(errs, fmt.Errorf( | 683 | errs = append(errs, fmt.Errorf( |
| 568 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", | 684 | "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) { | |||
| 411 | t.Fatalf("default: %v, %v", cfg.Backup, err) | 411 | t.Fatalf("default: %v, %v", cfg.Backup, err) |
| 412 | } | 412 | } |
| 413 | } | 413 | } |
| 414 | |||
| 415 | func TestMailInbound(t *testing.T) { | ||
| 416 | const smtp = "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n" | ||
| 417 | const inbound = "[mail.inbound]\nenabled = true\nimap_host = \"imap.example\"\nuser = \"reply@example\"\npassword_file = \"/etc/gitbay/imap.pass\"\nreply_address = \"reply@gitbay.example\"\n" | ||
| 418 | cfg, err := Load(writeConfig(t, minimal+smtp+inbound)) | ||
| 419 | if err != nil { | ||
| 420 | t.Fatal(err) | ||
| 421 | } | ||
| 422 | in := cfg.Mail.Inbound | ||
| 423 | if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll { | ||
| 424 | t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll()) | ||
| 425 | } | ||
| 426 | in.TLS = "starttls" | ||
| 427 | if in.Addr() != "imap.example:143" { | ||
| 428 | t.Fatalf("starttls default port: %q", in.Addr()) | ||
| 429 | } | ||
| 430 | for body, want := range map[string]string{ | ||
| 431 | minimal + inbound: "requires [mail] smtp_host", | ||
| 432 | minimal + smtp + inbound + "tls = \"none\"\n": "IMAP in clear is not supported", | ||
| 433 | minimal + smtp + inbound + "poll_interval = \"1s\"\n": "poll_interval", | ||
| 434 | minimal + smtp + "[mail.inbound]\nenabled = true\n": "mail.inbound.password_file is required", | ||
| 435 | minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "reply+x@gitbay.example", 1): "no + in it", | ||
| 436 | minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "gitbay.example", 1): "bare address", | ||
| 437 | minimal + smtp + inbound + "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 | |||
| 449 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/imapc" | ||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func init() { | ||
| 13 | register(Command{Path: []string{"admin", "mail", "inbound", "check"}, | ||
| 14 | Summary: "connect to the reply mailbox read-only and report what is waiting", | ||
| 15 | Usage: "admin mail inbound check", | ||
| 16 | Examples: []string{"admin mail inbound check"}, | ||
| 17 | ReadOnly: true, Run: runAdminMailInboundCheck}) | ||
| 18 | } | ||
| 19 | |||
| 20 | // 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. | ||
| 23 | func 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 { | |||
| 73 | // User.SignedInAt is when that session signed in. | 73 | // User.SignedInAt is when that session signed in. |
| 74 | const SourceWeb = "web" | 74 | const SourceWeb = "web" |
| 75 | 75 | ||
| 76 | // SourceMail is Ctx.Source for a comment posted by replying to | ||
| 77 | // notification mail (#295). | ||
| 78 | const SourceMail = "mail" | ||
| 79 | |||
| 76 | // ReauthWindow is how long after signing in a browser session may run a | 80 | // ReauthWindow is how long after signing in a browser session may run a |
| 77 | // NeedsRecentSignIn command. A session lasts days and its cookie is a | 81 | // NeedsRecentSignIn command. A session lasts days and its cookie is a |
| 78 | // bearer credential; what it creates or grants must come from a recent | 82 | // bearer credential; what it creates or grants must come from a recent |
internal/control/diffcomment.go +1 −1
| @@ -160,7 +160,7 @@ func runDiffComment(c *Ctx, args []string) int { | |||
| 160 | // not reach anyone's inbox. `mr review` is what says it out loud. | 160 | // not reach anyone's inbox. `mr review` is what says it out loud. |
| 161 | if !pending { | 161 | if !pending { |
| 162 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 162 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 163 | notify(c, parts, notice{repo: repo, kind: "mr", | 163 | notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number, |
| 164 | subject: mrSubject(repo, mr.Number, mr.Title), | 164 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 165 | action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number), | 165 | action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number), |
| 166 | excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 166 | excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
internal/control/issue.go +7 −4
| @@ -14,6 +14,9 @@ import ( | |||
| 14 | 14 | ||
| 15 | const maxBodyBytes = 64 << 10 | 15 | const maxBodyBytes = 64 << 10 |
| 16 | 16 | ||
| 17 | // MaxCommentBytes is the most of a comment body a command reads. | ||
| 18 | const MaxCommentBytes = maxBodyBytes | ||
| 19 | |||
| 17 | func init() { | 20 | func init() { |
| 18 | register(Command{Path: []string{"issue", "create"}, | 21 | register(Command{Path: []string{"issue", "create"}, |
| 19 | Summary: "open an issue", | 22 | Summary: "open an issue", |
| @@ -250,7 +253,7 @@ func runIssueCreate(c *Ctx, args []string) int { | |||
| 250 | } | 253 | } |
| 251 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) | 254 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) |
| 252 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | 255 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { |
| 253 | notify(c, targets, notice{repo: repo, kind: "issue", | 256 | notify(c, targets, notice{repo: repo, kind: "issue", number: n, |
| 254 | subject: issueSubject(repo, n, title), | 257 | subject: issueSubject(repo, n, title), |
| 255 | action: fmt.Sprintf("opened issue #%d", n), | 258 | action: fmt.Sprintf("opened issue #%d", n), |
| 256 | excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) | 259 | excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) |
| @@ -429,7 +432,7 @@ func setIssueState(c *Ctx, args []string, state string) int { | |||
| 429 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) | 432 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) |
| 430 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { | 433 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { |
| 431 | verb := map[string]string{"open": "reopened", "closed": "closed"}[state] | 434 | verb := map[string]string{"open": "reopened", "closed": "closed"}[state] |
| 432 | notify(c, parts, notice{repo: repo, kind: "issue", | 435 | notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number, |
| 433 | subject: issueSubject(repo, issue.Number, issue.Title), | 436 | subject: issueSubject(repo, issue.Number, issue.Title), |
| 434 | action: fmt.Sprintf("%s #%d", verb, issue.Number), | 437 | action: fmt.Sprintf("%s #%d", verb, issue.Number), |
| 435 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | 438 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) |
| @@ -509,7 +512,7 @@ func runIssueEdit(c *Ctx, args []string) int { | |||
| 509 | } | 512 | } |
| 510 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number)) | 513 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number)) |
| 511 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { | 514 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { |
| 512 | notify(c, parts, notice{repo: repo, kind: "issue", | 515 | notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number, |
| 513 | subject: issueSubject(repo, issue.Number, issue.Title), | 516 | subject: issueSubject(repo, issue.Number, issue.Title), |
| 514 | action: fmt.Sprintf("edited #%d", issue.Number), | 517 | action: fmt.Sprintf("edited #%d", issue.Number), |
| 515 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | 518 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) |
| @@ -674,7 +677,7 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto | |||
| 674 | // and widening it to watchers would tell them "assigned you". | 677 | // and widening it to watchers would tell them "assigned you". |
| 675 | // Removals file nothing, and notify drops the actor, so assigning | 678 | // Removals file nothing, and notify drops the actor, so assigning |
| 676 | // yourself is silent. | 679 | // yourself is silent. |
| 677 | notify(c, added, notice{repo: repo, kind: "issue", direct: true, | 680 | notify(c, added, notice{repo: repo, kind: "issue", number: issue.Number, direct: true, |
| 678 | subject: issueSubject(repo, issue.Number, issue.Title), | 681 | subject: issueSubject(repo, issue.Number, issue.Title), |
| 679 | action: fmt.Sprintf("assigned you to #%d", issue.Number), | 682 | action: fmt.Sprintf("assigned you to #%d", issue.Number), |
| 680 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | 683 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) |
internal/control/mr.go +8 −8
| @@ -518,7 +518,7 @@ func runMRCreate(c *Ctx, args []string) int { | |||
| 518 | } | 518 | } |
| 519 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) | 519 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) |
| 520 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | 520 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { |
| 521 | notify(c, targets, notice{repo: repo, kind: "mr", | 521 | notify(c, targets, notice{repo: repo, kind: "mr", number: n, |
| 522 | subject: mrSubject(repo, n, title), | 522 | subject: mrSubject(repo, n, title), |
| 523 | action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), | 523 | action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), |
| 524 | excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)}) | 524 | excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)}) |
| @@ -1081,7 +1081,7 @@ func runMRRetarget(c *Ctx, args []string) int { | |||
| 1081 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", | 1081 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", |
| 1082 | fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) | 1082 | fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) |
| 1083 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1083 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1084 | notify(c, parts, notice{repo: repo, kind: "mr", | 1084 | notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number, |
| 1085 | subject: mrSubject(repo, mr.Number, mr.Title), | 1085 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1086 | action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), | 1086 | action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), |
| 1087 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1087 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
| @@ -1157,7 +1157,7 @@ func runMRReview(c *Ctx, args []string) int { | |||
| 1157 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) | 1157 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) |
| 1158 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | 1158 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) |
| 1159 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1159 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1160 | notify(c, parts, notice{repo: repo, kind: "mr", | 1160 | notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number, |
| 1161 | subject: mrSubject(repo, mr.Number, mr.Title), | 1161 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1162 | action: reviewAction(mr.Number, verdict, published), | 1162 | action: reviewAction(mr.Number, verdict, published), |
| 1163 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1163 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
| @@ -1217,7 +1217,7 @@ func runMRReviewRequest(c *Ctx, args []string) int { | |||
| 1217 | for i, u := range added { | 1217 | for i, u := range added { |
| 1218 | ids[i] = u.ID | 1218 | ids[i] = u.ID |
| 1219 | } | 1219 | } |
| 1220 | notify(c, ids, notice{repo: repo, kind: "mr", | 1220 | notify(c, ids, notice{repo: repo, kind: "mr", number: mr.Number, |
| 1221 | subject: mrSubject(repo, mr.Number, mr.Title), | 1221 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1222 | action: fmt.Sprintf("asked for a review on !%d", mr.Number), | 1222 | action: fmt.Sprintf("asked for a review on !%d", mr.Number), |
| 1223 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1223 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
| @@ -1597,7 +1597,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | |||
| 1597 | } | 1597 | } |
| 1598 | c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number)) | 1598 | c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number)) |
| 1599 | if parts, err := c.Store.MRParticipants(k.ID); err == nil { | 1599 | if parts, err := c.Store.MRParticipants(k.ID); err == nil { |
| 1600 | notify(c, parts, notice{repo: repo, kind: "mr", | 1600 | notify(c, parts, notice{repo: repo, kind: "mr", number: k.Number, |
| 1601 | subject: mrSubject(repo, k.Number, k.Title), | 1601 | subject: mrSubject(repo, k.Number, k.Title), |
| 1602 | action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), | 1602 | action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), |
| 1603 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)}) | 1603 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)}) |
| @@ -1623,7 +1623,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | |||
| 1623 | repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now()) | 1623 | repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now()) |
| 1624 | c.Store.MarkMirrorsDirty(repo.ID, "push") | 1624 | c.Store.MarkMirrorsDirty(repo.ID, "push") |
| 1625 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1625 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1626 | notify(c, parts, notice{repo: repo, kind: "mr", | 1626 | notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number, |
| 1627 | subject: mrSubject(repo, mr.Number, mr.Title), | 1627 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1628 | action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), | 1628 | action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), |
| 1629 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1629 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
| @@ -1907,7 +1907,7 @@ func setMRDraft(c *Ctx, args []string, draft bool) int { | |||
| 1907 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | 1907 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { |
| 1908 | parts, _ := c.Store.MRParticipants(mr.ID) | 1908 | parts, _ := c.Store.MRParticipants(mr.ID) |
| 1909 | reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID) | 1909 | reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID) |
| 1910 | notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", | 1910 | notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", number: mr.Number, |
| 1911 | subject: mrSubject(repo, mr.Number, mr.Title), | 1911 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1912 | action: fmt.Sprintf("marked !%d ready for review", mr.Number), | 1912 | action: fmt.Sprintf("marked !%d ready for review", mr.Number), |
| 1913 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1913 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
| @@ -1959,7 +1959,7 @@ func runMRClose(c *Ctx, args []string) int { | |||
| 1959 | } | 1959 | } |
| 1960 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData) | 1960 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData) |
| 1961 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1961 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1962 | notify(c, parts, notice{repo: repo, kind: "mr", | 1962 | notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number, |
| 1963 | subject: mrSubject(repo, mr.Number, mr.Title), | 1963 | subject: mrSubject(repo, mr.Number, mr.Title), |
| 1964 | action: fmt.Sprintf("closed !%d", mr.Number), | 1964 | action: fmt.Sprintf("closed !%d", mr.Number), |
| 1965 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) | 1965 | path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) |
internal/control/notifications.go +66 −2
| @@ -6,8 +6,10 @@ import ( | |||
| 6 | "io" | 6 | "io" |
| 7 | "strconv" | 7 | "strconv" |
| 8 | "strings" | 8 | "strings" |
| 9 | "time" | ||
| 9 | 10 | ||
| 10 | "gitbay.org/gitbay/internal/autolink" | 11 | "gitbay.org/gitbay/internal/autolink" |
| 12 | "gitbay.org/gitbay/internal/mailreply" | ||
| 11 | "gitbay.org/gitbay/internal/policy" | 13 | "gitbay.org/gitbay/internal/policy" |
| 12 | "gitbay.org/gitbay/internal/protocol" | 14 | "gitbay.org/gitbay/internal/protocol" |
| 13 | "gitbay.org/gitbay/internal/store" | 15 | "gitbay.org/gitbay/internal/store" |
| @@ -42,6 +44,11 @@ func init() { | |||
| 42 | Usage: "notifications settings mail on|off", | 44 | Usage: "notifications settings mail on|off", |
| 43 | Examples: []string{"notifications settings mail on"}, | 45 | Examples: []string{"notifications settings mail on"}, |
| 44 | Run: runNotificationsSettingsMail}) | 46 | Run: runNotificationsSettingsMail}) |
| 47 | register(Command{Path: []string{"notifications", "settings", "reply"}, | ||
| 48 | Summary: "reply to issue and merge request mail to comment", | ||
| 49 | Usage: "notifications settings reply on|off", | ||
| 50 | Examples: []string{"notifications settings reply on"}, | ||
| 51 | Run: runNotificationsSettingsReply}) | ||
| 45 | register(Command{Path: []string{"notifications", "settings", "watch"}, | 52 | register(Command{Path: []string{"notifications", "settings", "watch"}, |
| 46 | Summary: "every issue and merge request on repositories you can write to", | 53 | Summary: "every issue and merge request on repositories you can write to", |
| 47 | Usage: "notifications settings watch on|off", | 54 | Usage: "notifications settings watch on|off", |
| @@ -96,6 +103,7 @@ func init() { | |||
| 96 | type notice struct { | 103 | type notice struct { |
| 97 | repo store.Repo | 104 | repo store.Repo |
| 98 | kind string // issue, mr, or build | 105 | kind string // issue, mr, or build |
| 106 | number int64 // the issue or merge request; 0 for a build | ||
| 99 | subject string // mail subject | 107 | subject string // mail subject |
| 100 | action string // "opened issue #12" — also the inbox summary | 108 | action string // "opened issue #12" — also the inbox summary |
| 101 | excerpt string // quoted into the mail, not the inbox | 109 | excerpt string // quoted into the mail, not the inbox |
| @@ -136,10 +144,43 @@ func notify(c *Ctx, userIDs []int64, n notice) { | |||
| 136 | if err != nil || email == "" { | 144 | if err != nil || email == "" { |
| 137 | continue | 145 | continue |
| 138 | } | 146 | } |
| 147 | if replyTo := replyAddress(c, id, n); replyTo != "" { | ||
| 148 | c.Store.EnqueueMailReplyTo(email, replyTo, n.subject, body+replyFooter) | ||
| 149 | continue | ||
| 150 | } | ||
| 139 | c.Store.EnqueueMail(email, n.subject, body) | 151 | c.Store.EnqueueMail(email, n.subject, body) |
| 140 | } | 152 | } |
| 141 | } | 153 | } |
| 142 | 154 | ||
| 155 | // replyFooter ends mail that carries a reply address. | ||
| 156 | const replyFooter = "\nReply to this mail to comment. Replies are accepted from your verified addresses.\n" | ||
| 157 | |||
| 158 | // replyAddress is the Reply-To for recipient's mail about n (#295): an | ||
| 159 | // address carrying a token for the recipient and the thread, when the | ||
| 160 | // instance polls for replies and the recipient turned replies on. "" | ||
| 161 | // otherwise, or when no token can be minted. | ||
| 162 | func replyAddress(c *Ctx, recipient int64, n notice) string { | ||
| 163 | in := c.Cfg.Mail.Inbound | ||
| 164 | keys := c.Store.Keyring() | ||
| 165 | if !in.Enabled || keys == nil || n.number == 0 || (n.kind != "issue" && n.kind != "mr") { | ||
| 166 | return "" | ||
| 167 | } | ||
| 168 | if on, err := c.Store.ReplyEnabled(recipient); err != nil || !on { | ||
| 169 | return "" | ||
| 170 | } | ||
| 171 | secrets, err := keys.Derive(mailreply.Purpose) | ||
| 172 | if err != nil { | ||
| 173 | return "" | ||
| 174 | } | ||
| 175 | tok, err := mailreply.Mint(secrets, mailreply.Target{ | ||
| 176 | UserID: recipient, RepoID: n.repo.ID, Kind: n.kind, Number: n.number, | ||
| 177 | }, time.Now().Add(mailreply.Lifetime)) | ||
| 178 | if err != nil { | ||
| 179 | return "" | ||
| 180 | } | ||
| 181 | return mailreply.Address(in.ReplyAddress, tok) | ||
| 182 | } | ||
| 183 | |||
| 143 | // notifyMentions files an inbox row for every account text mentions by | 184 | // notifyMentions files an inbox row for every account text mentions by |
| 144 | // @name that can read the repository, and records them as participants | 185 | // @name that can read the repository, and records them as participants |
| 145 | // of the thread so they hear what follows (#202). Mute is honoured by | 186 | // of the thread so they hear what follows (#202). Mute is honoured by |
| @@ -170,7 +211,7 @@ func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, tit | |||
| 170 | return | 211 | return |
| 171 | } | 212 | } |
| 172 | c.Store.AddMentions(repo.ID, t.kind, itemID, ids) | 213 | c.Store.AddMentions(repo.ID, t.kind, itemID, ids) |
| 173 | notify(c, ids, notice{repo: repo, kind: t.kind, direct: true, | 214 | notify(c, ids, notice{repo: repo, kind: t.kind, number: number, direct: true, |
| 174 | subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), | 215 | subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), |
| 175 | action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number), | 216 | action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number), |
| 176 | excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) | 217 | excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) |
| @@ -223,7 +264,11 @@ func emitNotificationSettings(c *Ctx) int { | |||
| 223 | if err != nil { | 264 | if err != nil { |
| 224 | return c.fail(protocol.ExitFailure, "%v", err) | 265 | return c.fail(protocol.ExitFailure, "%v", err) |
| 225 | } | 266 | } |
| 226 | return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) { | 267 | reply, err := c.Store.ReplyEnabled(c.User.ID) |
| 268 | if err != nil { | ||
| 269 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 270 | } | ||
| 271 | return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) { | ||
| 227 | onOff := func(on bool) string { | 272 | onOff := func(on bool) string { |
| 228 | if on { | 273 | if on { |
| 229 | return "on" | 274 | return "on" |
| @@ -233,6 +278,7 @@ func emitNotificationSettings(c *Ctx) int { | |||
| 233 | v := c.view(w) | 278 | v := c.view(w) |
| 234 | v.fields( | 279 | v.fields( |
| 235 | "mail", onOff(mail), | 280 | "mail", onOff(mail), |
| 281 | "reply", onOff(reply), | ||
| 236 | "watch", onOff(watch), | 282 | "watch", onOff(watch), |
| 237 | "push", onOff(push), | 283 | "push", onOff(push), |
| 238 | ) | 284 | ) |
| @@ -258,6 +304,24 @@ func runNotificationsSettingsMail(c *Ctx, args []string) int { | |||
| 258 | return emitNotificationSettings(c) | 304 | return emitNotificationSettings(c) |
| 259 | } | 305 | } |
| 260 | 306 | ||
| 307 | // runNotificationsSettingsReply turns on a Reply-To on the account's | ||
| 308 | // issue and merge request mail (#295). Turning it on is refused where | ||
| 309 | // nothing reads the replies. | ||
| 310 | func runNotificationsSettingsReply(c *Ctx, args []string) int { | ||
| 311 | if len(args) != 1 || (args[0] != "on" && args[0] != "off") { | ||
| 312 | return c.usage() | ||
| 313 | } | ||
| 314 | on := args[0] == "on" | ||
| 315 | if on && !c.Cfg.Mail.Inbound.Enabled { | ||
| 316 | return c.fail(protocol.ExitFailure, | ||
| 317 | "this instance does not read replies to its mail ([mail.inbound] enabled = false); ask an admin") | ||
| 318 | } | ||
| 319 | if err := c.Store.SetReplyEnabled(c.User.ID, on); err != nil { | ||
| 320 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 321 | } | ||
| 322 | return emitNotificationSettings(c) | ||
| 323 | } | ||
| 324 | |||
| 261 | // runNotificationsSettingsWatch is the default watch state for | 325 | // runNotificationsSettingsWatch is the default watch state for |
| 262 | // repositories the account can write to: consulted when a notice is | 326 | // repositories the account can write to: consulted when a notice is |
| 263 | // delivered, so a grant or a revoke needs no watch row of its own (#194). | 327 | // delivered, so a grant or a revoke needs no watch row of its own (#194). |
internal/control/notifications_test.go +91
| @@ -5,9 +5,12 @@ import ( | |||
| 5 | "fmt" | 5 | "fmt" |
| 6 | "strings" | 6 | "strings" |
| 7 | "testing" | 7 | "testing" |
| 8 | "time" | ||
| 8 | 9 | ||
| 9 | "gitbay.org/gitbay/internal/config" | 10 | "gitbay.org/gitbay/internal/config" |
| 11 | "gitbay.org/gitbay/internal/mailreply" | ||
| 10 | "gitbay.org/gitbay/internal/protocol" | 12 | "gitbay.org/gitbay/internal/protocol" |
| 13 | "gitbay.org/gitbay/internal/seal" | ||
| 11 | "gitbay.org/gitbay/internal/store" | 14 | "gitbay.org/gitbay/internal/store" |
| 12 | ) | 15 | ) |
| 13 | 16 | ||
| @@ -310,3 +313,91 @@ func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) { | |||
| 310 | t.Errorf("--all did not show the read notice: %q", out.String()) | 313 | t.Errorf("--all did not show the read notice: %q", out.String()) |
| 311 | } | 314 | } |
| 312 | } | 315 | } |
| 316 | |||
| 317 | // The Reply-To is on issue and merge request mail only when the instance | ||
| 318 | // reads replies and the recipient turned them on (#295). | ||
| 319 | func TestNotifyReplyTo(t *testing.T) { | ||
| 320 | key, err := seal.NewKey() | ||
| 321 | if err != nil { | ||
| 322 | t.Fatal(err) | ||
| 323 | } | ||
| 324 | keyFile := t.TempDir() + "/secret.key" | ||
| 325 | if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil { | ||
| 326 | t.Fatal(err) | ||
| 327 | } | ||
| 328 | ring, err := seal.Load(keyFile) | ||
| 329 | if err != nil { | ||
| 330 | t.Fatal(err) | ||
| 331 | } | ||
| 332 | for _, tc := range []struct { | ||
| 333 | name string | ||
| 334 | instance, user bool | ||
| 335 | kind string | ||
| 336 | number int64 | ||
| 337 | want bool | ||
| 338 | }{ | ||
| 339 | {"both", true, true, "issue", 1, true}, | ||
| 340 | {"merge request", true, true, "mr", 1, true}, | ||
| 341 | {"instance off", false, true, "issue", 1, false}, | ||
| 342 | {"user off", true, false, "issue", 1, false}, | ||
| 343 | {"build", true, true, "build", 0, false}, | ||
| 344 | } { | ||
| 345 | t.Run(tc.name, func(t *testing.T) { | ||
| 346 | c, repo, bob := testRepoWithWatcher(t) | ||
| 347 | c.Store.SetKeyring(ring) | ||
| 348 | c.Cfg.Push.Enabled = false | ||
| 349 | c.Cfg.Mail.SMTPHost, c.Cfg.Mail.From = "mx.example", "gitbay@example.test" | ||
| 350 | c.Cfg.Mail.Inbound = config.MailInbound{Enabled: tc.instance, ReplyAddress: "reply@gitbay.example"} | ||
| 351 | if err := c.Store.AddEmail(bob, "bob@example.test", "admin", true); err != nil { | ||
| 352 | t.Fatal(err) | ||
| 353 | } | ||
| 354 | if err := c.Store.SetReplyEnabled(bob, tc.user); err != nil { | ||
| 355 | t.Fatal(err) | ||
| 356 | } | ||
| 357 | notify(c, []int64{bob}, notice{repo: repo, kind: tc.kind, number: tc.number, | ||
| 358 | subject: "s", action: "commented", path: "alice/app/issues/1"}) | ||
| 359 | due, err := c.Store.DueMail(20) | ||
| 360 | if err != nil || len(due) != 1 { | ||
| 361 | t.Fatalf("DueMail = %v, %v", due, err) | ||
| 362 | } | ||
| 363 | got := due[0].ReplyTo | ||
| 364 | if !tc.want { | ||
| 365 | if got != "" || strings.Contains(due[0].Body, "Reply to this mail") { | ||
| 366 | t.Fatalf("Reply-To %q, body %q", got, due[0].Body) | ||
| 367 | } | ||
| 368 | return | ||
| 369 | } | ||
| 370 | tok, ok := mailreply.TokenFrom("reply@gitbay.example", got) | ||
| 371 | if !ok { | ||
| 372 | t.Fatalf("Reply-To %q", got) | ||
| 373 | } | ||
| 374 | secrets, _ := ring.Derive(mailreply.Purpose) | ||
| 375 | target, err := mailreply.Verify(secrets, tok, time.Now()) | ||
| 376 | want := mailreply.Target{UserID: bob, RepoID: repo.ID, Kind: tc.kind, Number: 1} | ||
| 377 | if err != nil || target != want { | ||
| 378 | t.Fatalf("token names %+v, %v; want %+v", target, err, want) | ||
| 379 | } | ||
| 380 | }) | ||
| 381 | } | ||
| 382 | } | ||
| 383 | |||
| 384 | func TestNotificationsSettingsReply(t *testing.T) { | ||
| 385 | c := notifTestCtx(t, "alice") | ||
| 386 | if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitFailure { | ||
| 387 | t.Fatalf("on without [mail.inbound]: exit %d", code) | ||
| 388 | } | ||
| 389 | c.Cfg.Mail.Inbound.Enabled = true | ||
| 390 | if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitOK { | ||
| 391 | t.Fatalf("on: exit %d: %s", code, c.Stdout) | ||
| 392 | } | ||
| 393 | if on, _ := c.Store.ReplyEnabled(c.User.ID); !on { | ||
| 394 | t.Fatal("reply not on") | ||
| 395 | } | ||
| 396 | c.Cfg.Mail.Inbound.Enabled = false | ||
| 397 | if code := Dispatch(c, []string{"notifications", "settings", "reply", "off"}); code != protocol.ExitOK { | ||
| 398 | t.Fatalf("off: exit %d", code) | ||
| 399 | } | ||
| 400 | if on, _ := c.Store.ReplyEnabled(c.User.ID); on { | ||
| 401 | t.Fatal("reply still on") | ||
| 402 | } | ||
| 403 | } | ||
internal/control/thread.go +1 −1
| @@ -110,7 +110,7 @@ func runComment(c *Ctx, args []string, t thread, noun string, | |||
| 110 | } | 110 | } |
| 111 | c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number)) | 111 | c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number)) |
| 112 | if parts, err := participants(id); err == nil { | 112 | if parts, err := participants(id); err == nil { |
| 113 | notify(c, parts, notice{repo: repo, kind: t.kind, | 113 | notify(c, parts, notice{repo: repo, kind: t.kind, number: number, |
| 114 | subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), | 114 | subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), |
| 115 | action: fmt.Sprintf("commented on %s%d", t.symbol, number), | 115 | action: fmt.Sprintf("commented on %s%d", t.symbol, number), |
| 116 | excerpt: body, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) | 116 | excerpt: body, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) |
internal/httpd/account.go +6 −2
| @@ -95,6 +95,7 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U | |||
| 95 | mailOn, _ := s.st.MailEnabled(u.ID) | 95 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 96 | watchOn, _ := s.st.WatchEnabled(u.ID) | 96 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 97 | pushOn, _ := s.st.PushEnabled(u.ID) | 97 | pushOn, _ := s.st.PushEnabled(u.ID) |
| 98 | replyOn, _ := s.st.ReplyEnabled(u.ID) | ||
| 98 | theme, _ := s.st.Theme(u.ID) | 99 | theme, _ := s.st.Theme(u.ID) |
| 99 | diffPref, _ := s.st.DiffLayout(u.ID) | 100 | diffPref, _ := s.st.DiffLayout(u.ID) |
| 100 | 101 | ||
| @@ -148,6 +149,8 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U | |||
| 148 | MailOn bool | 149 | MailOn bool |
| 149 | WatchOn bool | 150 | WatchOn bool |
| 150 | PushOn bool | 151 | PushOn bool |
| 152 | ReplyOn bool | ||
| 153 | ReplyOffered bool // the instance reads replies to its mail | ||
| 151 | Devices []accountDevice | 154 | Devices []accountDevice |
| 152 | ThemeSetting string // system, light or dark: the form's selected option | 155 | ThemeSetting string // system, light or dark: the form's selected option |
| 153 | DiffSetting string // unified or split: the form's selected option | 156 | DiffSetting string // unified or split: the form's selected option |
| @@ -156,7 +159,8 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U | |||
| 156 | Reauth bool // Notice is the stale-session refusal: link to sign in | 159 | Reauth bool // Notice is the stale-session refusal: link to sign in |
| 157 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), | 160 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 158 | aboutRepo, aboutEdit, s.cfg.SiteHost(), | 161 | aboutRepo, aboutEdit, s.cfg.SiteHost(), |
| 159 | notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, diffPref, | 162 | notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, |
| 163 | replyOn, s.cfg.Mail.Inbound.Enabled, devices, theme, diffPref, | ||
| 160 | tokens, tokenShown, reauth}) | 164 | tokens, tokenShown, reauth}) |
| 161 | } | 165 | } |
| 162 | 166 | ||
| @@ -350,7 +354,7 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 350 | return | 354 | return |
| 351 | } | 355 | } |
| 352 | back("", "diff layout saved") | 356 | back("", "diff layout saved") |
| 353 | case "notify-mail", "notify-watch", "notify-push": | 357 | case "notify-mail", "notify-watch", "notify-push", "notify-reply": |
| 354 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") | 358 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") |
| 355 | state := "off" | 359 | state := "off" |
| 356 | if r.FormValue(pref) == "on" { | 360 | if r.FormValue(pref) == "on" { |
internal/httpd/account_test.go +36
| @@ -541,3 +541,39 @@ func TestNotificationsReadDispatches(t *testing.T) { | |||
| 541 | t.Fatalf("unread after all = %d, want 0", n) | 541 | t.Fatalf("unread after all = %d, want 0", n) |
| 542 | } | 542 | } |
| 543 | } | 543 | } |
| 544 | |||
| 545 | // The reply toggle is offered only where the instance reads replies, and | ||
| 546 | // posting it dispatches notifications settings reply (#295). | ||
| 547 | func TestAccountNotifyReply(t *testing.T) { | ||
| 548 | st, err := store.Open(":memory:") | ||
| 549 | if err != nil { | ||
| 550 | t.Fatal(err) | ||
| 551 | } | ||
| 552 | defer st.Close() | ||
| 553 | if err := st.MigrateUp(); err != nil { | ||
| 554 | t.Fatal(err) | ||
| 555 | } | ||
| 556 | uid, err := st.CreateUser("alice", false) | ||
| 557 | if err != nil { | ||
| 558 | t.Fatal(err) | ||
| 559 | } | ||
| 560 | u := store.User{ID: uid, Username: "alice"} | ||
| 561 | page := func(s *Server) string { | ||
| 562 | rr := httptest.NewRecorder() | ||
| 563 | s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u) | ||
| 564 | return rr.Body.String() | ||
| 565 | } | ||
| 566 | if strings.Contains(page(New(config.Default(), st, nil)), `value="notify-reply"`) { | ||
| 567 | t.Fatal("reply toggle offered without [mail.inbound]") | ||
| 568 | } | ||
| 569 | cfg := config.Default() | ||
| 570 | cfg.Mail.Inbound.Enabled = true | ||
| 571 | s := New(cfg, st, nil) | ||
| 572 | if !strings.Contains(page(s), `value="notify-reply"`) { | ||
| 573 | t.Fatal("no reply toggle") | ||
| 574 | } | ||
| 575 | submitAccountForm(t, s, u, url.Values{"field": {"notify-reply"}, "reply": {"on"}}) | ||
| 576 | if on, err := st.ReplyEnabled(uid); err != nil || !on { | ||
| 577 | t.Fatalf("ReplyEnabled after notify-reply=on: %v %v", on, err) | ||
| 578 | } | ||
| 579 | } | ||
internal/imapc/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. | ||
| 6 | package imapc | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "bufio" | ||
| 10 | "crypto/tls" | ||
| 11 | "crypto/x509" | ||
| 12 | "errors" | ||
| 13 | "fmt" | ||
| 14 | "io" | ||
| 15 | "net" | ||
| 16 | "strconv" | ||
| 17 | "strings" | ||
| 18 | "time" | ||
| 19 | ) | ||
| 20 | |||
| 21 | // MaxMessage is the largest message Fetch returns. A larger one is read | ||
| 22 | // and discarded, and Fetch returns ErrTooLarge. | ||
| 23 | const MaxMessage = 10 << 20 | ||
| 24 | |||
| 25 | // maxLine bounds one response line outside literals. | ||
| 26 | const maxLine = 1 << 20 | ||
| 27 | |||
| 28 | var 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. | ||
| 32 | var rootCAs *x509.CertPool | ||
| 33 | |||
| 34 | // Client is one authenticated IMAP session. | ||
| 35 | type 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. | ||
| 44 | func 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. | ||
| 83 | func 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. | ||
| 88 | func (c *Client) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) } | ||
| 89 | |||
| 90 | func (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. | ||
| 103 | func (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. | ||
| 121 | func (c *Client) Select(mailbox string) (int, error) { return c.open("SELECT", mailbox) } | ||
| 122 | func (c *Client) Examine(mailbox string) (int, error) { return c.open("EXAMINE", mailbox) } | ||
| 123 | |||
| 124 | func (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. | ||
| 144 | func (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. | ||
| 167 | func (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. | ||
| 196 | func (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. | ||
| 204 | func (c *Client) Close() error { | ||
| 205 | c.cmd("LOGOUT") | ||
| 206 | return c.conn.Close() | ||
| 207 | } | ||
| 208 | |||
| 209 | type 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. | ||
| 216 | func (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. | ||
| 249 | func (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 | |||
| 277 | func (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+}"). | ||
| 295 | func 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. | ||
| 311 | func 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 | |||
| 320 | func 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 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 18 | type fakeServer struct { | ||
| 19 | msgs map[uint32]string | ||
| 20 | seen map[uint32]bool | ||
| 21 | cmds []string | ||
| 22 | } | ||
| 23 | |||
| 24 | func (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 | |||
| 81 | var serverTLS *tls.Config | ||
| 82 | |||
| 83 | func 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 | |||
| 94 | func 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 | |||
| 117 | func 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 | |||
| 157 | func 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. | ||
| 175 | func 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 | |||
| 185 | func 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 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "time" | ||
| 5 | |||
| 6 | "gitbay.org/gitbay/internal/config" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // Open connects to the configured mailbox, logs in with the password | ||
| 10 | // file's password, and opens the mailbox: read-only (EXAMINE) or for | ||
| 11 | // setting flags (SELECT). It returns the number of messages in it. | ||
| 12 | func Open(in config.MailInbound, readOnly bool, timeout time.Duration) (*Client, int, error) { | ||
| 13 | pass, err := in.Password() | ||
| 14 | if err != nil { | ||
| 15 | return nil, 0, err | ||
| 16 | } | ||
| 17 | c, err := Dial(in.Addr(), in.TLS == "starttls", timeout) | ||
| 18 | if err != nil { | ||
| 19 | return nil, 0, err | ||
| 20 | } | ||
| 21 | if err := c.Login(in.User, pass); err != nil { | ||
| 22 | c.Close() | ||
| 23 | return nil, 0, err | ||
| 24 | } | ||
| 25 | open := c.Select | ||
| 26 | if readOnly { | ||
| 27 | open = c.Examine | ||
| 28 | } | ||
| 29 | n, err := open(in.MailboxName()) | ||
| 30 | if err != nil { | ||
| 31 | c.Close() | ||
| 32 | return nil, 0, err | ||
| 33 | } | ||
| 34 | return c, n, nil | ||
| 35 | } | ||
internal/mail/mail.go +15 −2
| @@ -22,10 +22,23 @@ var rootCAs *x509.CertPool | |||
| 22 | 22 | ||
| 23 | // Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. | 23 | // Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. |
| 24 | func Send(cfg config.Config, to, subject, body string) error { | 24 | func Send(cfg config.Config, to, subject, body string) error { |
| 25 | return SendReplyTo(cfg, to, "", subject, body) | ||
| 26 | } | ||
| 27 | |||
| 28 | // SendReplyTo is Send with a Reply-To header, left out when replyTo is | ||
| 29 | // empty. | ||
| 30 | func SendReplyTo(cfg config.Config, to, replyTo, subject, body string) error { | ||
| 25 | m := cfg.Mail | 31 | m := cfg.Mail |
| 26 | if m.SMTPHost == "" || m.From == "" { | 32 | if m.SMTPHost == "" || m.From == "" { |
| 27 | return fmt.Errorf("[mail] smtp_host and from must be configured") | 33 | return fmt.Errorf("[mail] smtp_host and from must be configured") |
| 28 | } | 34 | } |
| 35 | if strings.ContainsAny(replyTo, "\r\n") { | ||
| 36 | return fmt.Errorf("reply-to address contains a line break") | ||
| 37 | } | ||
| 38 | header := "" | ||
| 39 | if replyTo != "" { | ||
| 40 | header = "Reply-To: " + replyTo + "\n" | ||
| 41 | } | ||
| 29 | implicit := m.TLS == "implicit" | 42 | implicit := m.TLS == "implicit" |
| 30 | host := m.SMTPHost | 43 | host := m.SMTPHost |
| 31 | if !strings.Contains(host, ":") { | 44 | if !strings.Contains(host, ":") { |
| @@ -39,8 +52,8 @@ func Send(cfg config.Config, to, subject, body string) error { | |||
| 39 | tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs} | 52 | tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs} |
| 40 | 53 | ||
| 41 | msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( | 54 | msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( |
| 42 | "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", | 55 | "From: %s\nTo: %s\n%sSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", |
| 43 | m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) | 56 | m.From, to, header, subject, time.Now().Format(time.RFC1123Z), body)) |
| 44 | 57 | ||
| 45 | c, err := dial(host, hostname, implicit, tlsCfg) | 58 | c, err := dial(host, hostname, implicit, tlsCfg) |
| 46 | if err != nil { | 59 | if err != nil { |
internal/mail/mail_test.go +23
| @@ -140,3 +140,26 @@ func TestImplicitTLS(t *testing.T) { | |||
| 140 | t.Fatalf("delivered %d, want 1", n) | 140 | t.Fatalf("delivered %d, want 1", n) |
| 141 | } | 141 | } |
| 142 | } | 142 | } |
| 143 | |||
| 144 | func TestReplyToHeader(t *testing.T) { | ||
| 145 | relay := startRelay(t, nil) | ||
| 146 | cfg := mailCfg(relay.addr) | ||
| 147 | if err := SendReplyTo(cfg, "a@example.test", "reply+tok@example.test", "subject", "body"); err != nil { | ||
| 148 | t.Fatal(err) | ||
| 149 | } | ||
| 150 | if err := Send(cfg, "a@example.test", "subject", "body"); err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | relay.mu.Lock() | ||
| 154 | with, without := relay.data[0], relay.data[1] | ||
| 155 | relay.mu.Unlock() | ||
| 156 | if !strings.Contains(with, "\nReply-To: reply+tok@example.test\n") { | ||
| 157 | t.Fatalf("no Reply-To:\n%s", with) | ||
| 158 | } | ||
| 159 | if strings.Contains(without, "Reply-To:") { | ||
| 160 | t.Fatalf("Send added a Reply-To:\n%s", without) | ||
| 161 | } | ||
| 162 | if err := SendReplyTo(cfg, "a@example.test", "x@example.test\r\nBcc: b@example.test", "s", "b"); err == nil { | ||
| 163 | t.Fatal("a line break in the reply address was sent") | ||
| 164 | } | ||
| 165 | } | ||
internal/mailin/body.go added +214
| @@ -0,0 +1,214 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "encoding/base64" | ||
| 6 | "errors" | ||
| 7 | "io" | ||
| 8 | "mime" | ||
| 9 | "mime/multipart" | ||
| 10 | "mime/quotedprintable" | ||
| 11 | "net/textproto" | ||
| 12 | "regexp" | ||
| 13 | "strings" | ||
| 14 | "unicode/utf8" | ||
| 15 | |||
| 16 | "golang.org/x/text/encoding/htmlindex" | ||
| 17 | ) | ||
| 18 | |||
| 19 | var errNoText = errors.New("no text/plain part") | ||
| 20 | |||
| 21 | // maxParts and maxDepth bound the walk through a multipart message. | ||
| 22 | const ( | ||
| 23 | maxParts = 64 | ||
| 24 | maxDepth = 5 | ||
| 25 | ) | ||
| 26 | |||
| 27 | // textBody returns the message's first text/plain part that is not an | ||
| 28 | // attachment, decoded to UTF-8. A message with only HTML has none, and | ||
| 29 | // is refused rather than converted. | ||
| 30 | func textBody(h textproto.MIMEHeader, body io.Reader, limit int64) (string, error) { | ||
| 31 | parts := 0 | ||
| 32 | return walk(h, body, limit, 0, &parts) | ||
| 33 | } | ||
| 34 | |||
| 35 | func walk(h textproto.MIMEHeader, body io.Reader, limit int64, depth int, parts *int) (string, error) { | ||
| 36 | ct := h.Get("Content-Type") | ||
| 37 | if ct == "" { | ||
| 38 | ct = "text/plain" | ||
| 39 | } | ||
| 40 | mt, params, err := mime.ParseMediaType(ct) | ||
| 41 | if err != nil { | ||
| 42 | return "", errNoText | ||
| 43 | } | ||
| 44 | switch { | ||
| 45 | case mt == "text/plain": | ||
| 46 | if d, _, _ := mime.ParseMediaType(h.Get("Content-Disposition")); d == "attachment" { | ||
| 47 | return "", errNoText | ||
| 48 | } | ||
| 49 | return decodeText(h.Get("Content-Transfer-Encoding"), params["charset"], body, limit) | ||
| 50 | case strings.HasPrefix(mt, "multipart/") && depth < maxDepth: | ||
| 51 | if params["boundary"] == "" { | ||
| 52 | return "", errNoText | ||
| 53 | } | ||
| 54 | mr := multipart.NewReader(body, params["boundary"]) | ||
| 55 | for { | ||
| 56 | // NextRawPart leaves quoted-printable to decodeText, so every | ||
| 57 | // part is decoded the same way. | ||
| 58 | p, err := mr.NextRawPart() | ||
| 59 | if err == io.EOF { | ||
| 60 | return "", errNoText | ||
| 61 | } | ||
| 62 | if err != nil { | ||
| 63 | return "", err | ||
| 64 | } | ||
| 65 | if *parts++; *parts > maxParts { | ||
| 66 | return "", errNoText | ||
| 67 | } | ||
| 68 | s, err := walk(p.Header, p, limit, depth+1, parts) | ||
| 69 | if err == nil { | ||
| 70 | return s, nil | ||
| 71 | } | ||
| 72 | if !errors.Is(err, errNoText) { | ||
| 73 | return "", err | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | return "", errNoText | ||
| 78 | } | ||
| 79 | |||
| 80 | var errTooLong = errors.New("reply is longer than a comment may be") | ||
| 81 | |||
| 82 | func decodeText(cte, charset string, body io.Reader, limit int64) (string, error) { | ||
| 83 | var r io.Reader = body | ||
| 84 | switch strings.ToLower(strings.TrimSpace(cte)) { | ||
| 85 | case "quoted-printable": | ||
| 86 | r = quotedprintable.NewReader(r) | ||
| 87 | case "base64": | ||
| 88 | r = base64.NewDecoder(base64.StdEncoding, &skipSpace{r: bufio.NewReader(r)}) | ||
| 89 | case "", "7bit", "8bit", "binary": | ||
| 90 | default: | ||
| 91 | return "", errNoText | ||
| 92 | } | ||
| 93 | switch cs := strings.ToLower(strings.TrimSpace(charset)); cs { | ||
| 94 | case "", "utf-8", "utf8", "us-ascii": | ||
| 95 | default: | ||
| 96 | enc, err := htmlindex.Get(cs) | ||
| 97 | if err != nil { | ||
| 98 | return "", errNoText | ||
| 99 | } | ||
| 100 | r = enc.NewDecoder().Reader(r) | ||
| 101 | } | ||
| 102 | // Quoted history is stripped after reading, so the read allows for | ||
| 103 | // a reply several times the size of a comment before refusing it. | ||
| 104 | raw, err := io.ReadAll(io.LimitReader(r, 8*limit+1)) | ||
| 105 | if err != nil { | ||
| 106 | return "", err | ||
| 107 | } | ||
| 108 | if int64(len(raw)) > 8*limit { | ||
| 109 | return "", errTooLong | ||
| 110 | } | ||
| 111 | return strings.ToValidUTF8(string(raw), string(utf8.RuneError)), nil | ||
| 112 | } | ||
| 113 | |||
| 114 | // skipSpace drops the line breaks and spaces base64 bodies are wrapped | ||
| 115 | // with. | ||
| 116 | type skipSpace struct{ r *bufio.Reader } | ||
| 117 | |||
| 118 | func (s *skipSpace) Read(p []byte) (int, error) { | ||
| 119 | n := 0 | ||
| 120 | for n < len(p) { | ||
| 121 | b, err := s.r.ReadByte() | ||
| 122 | if err != nil { | ||
| 123 | if n > 0 { | ||
| 124 | return n, nil | ||
| 125 | } | ||
| 126 | return 0, err | ||
| 127 | } | ||
| 128 | if b == '\r' || b == '\n' || b == ' ' || b == '\t' { | ||
| 129 | continue | ||
| 130 | } | ||
| 131 | p[n] = b | ||
| 132 | n++ | ||
| 133 | } | ||
| 134 | return n, nil | ||
| 135 | } | ||
| 136 | |||
| 137 | var ( | ||
| 138 | // "On Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+…@…> wrote:", which | ||
| 139 | // Gmail, Apple Mail and Thunderbird all put above the quote, and | ||
| 140 | // which Gmail wraps onto a second line when it is long. | ||
| 141 | attribution = regexp.MustCompile(`(?i)^on\s.*\bwrote:\s*$`) | ||
| 142 | // Outlook: "-----Original Message-----", or a rule of underscores | ||
| 143 | // above a From:/Sent: header block. | ||
| 144 | originalMessage = regexp.MustCompile(`(?i)^\s*-{2,}\s*original message\s*-{2,}\s*$`) | ||
| 145 | underscores = regexp.MustCompile(`^\s*_{10,}\s*$`) | ||
| 146 | headerFrom = regexp.MustCompile(`(?i)^\s*\*?from:\*?\s`) | ||
| 147 | headerSentDate = regexp.MustCompile(`(?i)^\s*\*?(sent|date):\*?\s`) | ||
| 148 | mobileSig = regexp.MustCompile(`(?i)^sent from my \S`) | ||
| 149 | ) | ||
| 150 | |||
| 151 | // stripQuoted returns the text a person wrote in a reply: quoted lines | ||
| 152 | // ("> …") dropped wherever they are, and everything from the first | ||
| 153 | // separator a mail client puts above the quoted message, or from the | ||
| 154 | // signature delimiter "-- ", cut off. | ||
| 155 | func stripQuoted(s string) string { | ||
| 156 | s = strings.ReplaceAll(s, "\r\n", "\n") | ||
| 157 | lines := strings.Split(s, "\n") | ||
| 158 | cut := len(lines) | ||
| 159 | for i, l := range lines { | ||
| 160 | t := strings.TrimRight(l, " \t") | ||
| 161 | next := "" | ||
| 162 | if i+1 < len(lines) { | ||
| 163 | next = strings.TrimSpace(lines[i+1]) | ||
| 164 | } | ||
| 165 | switch { | ||
| 166 | case l == "-- " || t == "--": | ||
| 167 | case originalMessage.MatchString(t): | ||
| 168 | case underscores.MatchString(t): | ||
| 169 | case attribution.MatchString(strings.TrimSpace(t)): | ||
| 170 | case strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "on ") && | ||
| 171 | attribution.MatchString(strings.TrimSpace(t)+" "+next): | ||
| 172 | case headerFrom.MatchString(t) && followedByHeader(lines[i+1:]): | ||
| 173 | default: | ||
| 174 | continue | ||
| 175 | } | ||
| 176 | cut = i | ||
| 177 | break | ||
| 178 | } | ||
| 179 | var out []string | ||
| 180 | for _, l := range lines[:cut] { | ||
| 181 | if strings.HasPrefix(strings.TrimLeft(l, " "), ">") { | ||
| 182 | continue | ||
| 183 | } | ||
| 184 | out = append(out, strings.TrimRight(l, " \t")) | ||
| 185 | } | ||
| 186 | // A phone's canned signature, when it is the last thing written. | ||
| 187 | for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { | ||
| 188 | out = out[:len(out)-1] | ||
| 189 | } | ||
| 190 | if len(out) > 0 && mobileSig.MatchString(strings.TrimSpace(out[len(out)-1])) { | ||
| 191 | out = out[:len(out)-1] | ||
| 192 | } | ||
| 193 | return strings.TrimSpace(collapseBlank(strings.Join(out, "\n"))) | ||
| 194 | } | ||
| 195 | |||
| 196 | // followedByHeader reports whether a Sent: or Date: line comes within | ||
| 197 | // the next few lines, as in the header block Outlook quotes. | ||
| 198 | func followedByHeader(rest []string) bool { | ||
| 199 | for i := 0; i < len(rest) && i < 4; i++ { | ||
| 200 | if headerSentDate.MatchString(rest[i]) { | ||
| 201 | return true | ||
| 202 | } | ||
| 203 | } | ||
| 204 | return false | ||
| 205 | } | ||
| 206 | |||
| 207 | // collapseBlank turns runs of blank lines, left where quoted lines were | ||
| 208 | // dropped, into one. | ||
| 209 | func collapseBlank(s string) string { | ||
| 210 | for strings.Contains(s, "\n\n\n") { | ||
| 211 | s = strings.ReplaceAll(s, "\n\n\n", "\n\n") | ||
| 212 | } | ||
| 213 | return s | ||
| 214 | } | ||
internal/mailin/body_test.go added +100
| @@ -0,0 +1,100 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "errors" | ||
| 6 | "net/mail" | ||
| 7 | "net/textproto" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestStripQuoted(t *testing.T) { | ||
| 13 | for _, tc := range []struct{ name, in, want string }{ | ||
| 14 | {"gmail", | ||
| 15 | "Agreed, ship it.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented on #1\n>\n> looks fine\n", | ||
| 16 | "Agreed, ship it."}, | ||
| 17 | {"gmail, attribution wrapped", | ||
| 18 | "Agreed.\n\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <\nreply+abc@gitbay.example> wrote:\n\n> alice commented\n", | ||
| 19 | "Agreed."}, | ||
| 20 | {"apple mail", | ||
| 21 | "Fixed in the next push.\n\nSent from my iPhone\n\n> On Sep 28, 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n> \n> alice commented on #1\n", | ||
| 22 | "Fixed in the next push."}, | ||
| 23 | {"apple mail, unquoted attribution", | ||
| 24 | "Yes.\n\nOn 28 Sep 2026, at 09:00, gitbay <reply+abc@gitbay.example> wrote:\n\n> alice commented\n", | ||
| 25 | "Yes."}, | ||
| 26 | {"outlook", | ||
| 27 | "Will do.\r\n\r\n________________________________\r\nFrom: gitbay <reply+abc@gitbay.example>\r\nSent: Monday, September 28, 2026 9:00 AM\r\nTo: Bob\r\nSubject: [alice/app] #1: title\r\n\r\nalice commented on #1\r\n", | ||
| 28 | "Will do."}, | ||
| 29 | {"outlook, no rule", | ||
| 30 | "Will do.\n\nFrom: gitbay <reply+abc@gitbay.example>\nSent: Monday, September 28, 2026 9:00 AM\nTo: Bob\n\nalice commented on #1\n", | ||
| 31 | "Will do."}, | ||
| 32 | {"outlook, original message", | ||
| 33 | "Noted.\n\n-----Original Message-----\nFrom: gitbay\nalice commented\n", | ||
| 34 | "Noted."}, | ||
| 35 | {"thunderbird", | ||
| 36 | "Thanks, merged.\n\n-- \nBob Example\nExample Corp\n\nOn 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n", | ||
| 37 | "Thanks, merged."}, | ||
| 38 | {"thunderbird, quote first", | ||
| 39 | "On 9/28/26 09:00, gitbay wrote:\n> alice commented on #1\n\nThat was me.\n", | ||
| 40 | ""}, | ||
| 41 | {"inline answers keep the answers", | ||
| 42 | "> does this build?\nYes, on main.\n> and the tests?\nAll green.\n", | ||
| 43 | "Yes, on main.\nAll green."}, | ||
| 44 | {"a From: line in prose is kept", | ||
| 45 | "From: the log it looks like a timeout.\nRetrying.\n", | ||
| 46 | "From: the log it looks like a timeout.\nRetrying."}, | ||
| 47 | {"markdown rule is not a signature", | ||
| 48 | "one\n\n---\n\ntwo\n", | ||
| 49 | "one\n\n---\n\ntwo"}, | ||
| 50 | } { | ||
| 51 | t.Run(tc.name, func(t *testing.T) { | ||
| 52 | if got := stripQuoted(tc.in); got != tc.want { | ||
| 53 | t.Errorf("got %q\nwant %q", got, tc.want) | ||
| 54 | } | ||
| 55 | }) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | func body(t *testing.T, raw string) (string, error) { | ||
| 60 | t.Helper() | ||
| 61 | msg, err := mail.ReadMessage(strings.NewReader(raw)) | ||
| 62 | if err != nil { | ||
| 63 | t.Fatal(err) | ||
| 64 | } | ||
| 65 | return textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16) | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestTextBody(t *testing.T) { | ||
| 69 | for _, tc := range []struct{ name, raw, want string }{ | ||
| 70 | {"plain, no content type", "Subject: x\r\n\r\nhello\r\n", "hello\r\n"}, | ||
| 71 | {"quoted-printable", "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=C3=A9 =\r\nau lait\r\n", "café au lait\r\n"}, | ||
| 72 | {"base64", "Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\naGVs\r\nbG8=\r\n", "hello"}, | ||
| 73 | {"latin-1", "Content-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=E9\r\n", "café\r\n"}, | ||
| 74 | {"alternative prefers plain", | ||
| 75 | "Content-Type: multipart/alternative; boundary=b\r\n\r\n--b\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n--b\r\nContent-Type: text/plain\r\n\r\nplain\r\n--b--\r\n", | ||
| 76 | "plain"}, | ||
| 77 | {"mixed with nested alternative and an attachment", | ||
| 78 | "Content-Type: multipart/mixed; boundary=m\r\n\r\n--m\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=a.txt\r\n\r\nattached\r\n--m\r\nContent-Type: multipart/alternative; boundary=a\r\n\r\n--a\r\nContent-Type: text/plain\r\n\r\nbody\r\n--a--\r\n--m--\r\n", | ||
| 79 | "body"}, | ||
| 80 | } { | ||
| 81 | t.Run(tc.name, func(t *testing.T) { | ||
| 82 | got, err := body(t, tc.raw) | ||
| 83 | if err != nil || got != tc.want { | ||
| 84 | t.Errorf("got %q, %v; want %q", got, err, tc.want) | ||
| 85 | } | ||
| 86 | }) | ||
| 87 | } | ||
| 88 | for name, raw := range map[string]string{ | ||
| 89 | "html only": "Content-Type: text/html\r\n\r\n<p>hi</p>\r\n", | ||
| 90 | "unknown charset": "Content-Type: text/plain; charset=x-nonesuch\r\n\r\nhi\r\n", | ||
| 91 | "unknown encoding": "Content-Type: text/plain\r\nContent-Transfer-Encoding: x-uuencode\r\n\r\nhi\r\n", | ||
| 92 | } { | ||
| 93 | if _, err := body(t, raw); !errors.Is(err, errNoText) { | ||
| 94 | t.Errorf("%s: %v, want errNoText", name, err) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | if _, err := body(t, "Subject: x\r\n\r\n"+string(bytes.Repeat([]byte("a"), 8<<16+1))); !errors.Is(err, errTooLong) { | ||
| 98 | t.Errorf("oversized body: %v", err) | ||
| 99 | } | ||
| 100 | } | ||
internal/mailin/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. | ||
| 10 | package mailin | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "bytes" | ||
| 14 | "context" | ||
| 15 | "crypto/sha256" | ||
| 16 | "encoding/hex" | ||
| 17 | "errors" | ||
| 18 | "fmt" | ||
| 19 | "log/slog" | ||
| 20 | "net/mail" | ||
| 21 | "net/textproto" | ||
| 22 | "strconv" | ||
| 23 | "strings" | ||
| 24 | "time" | ||
| 25 | |||
| 26 | "gitbay.org/gitbay/internal/config" | ||
| 27 | "gitbay.org/gitbay/internal/control" | ||
| 28 | "gitbay.org/gitbay/internal/imapc" | ||
| 29 | "gitbay.org/gitbay/internal/mailreply" | ||
| 30 | "gitbay.org/gitbay/internal/protocol" | ||
| 31 | "gitbay.org/gitbay/internal/store" | ||
| 32 | ) | ||
| 33 | |||
| 34 | // Mailbox is what the processor needs of an IMAP session; imapc.Client | ||
| 35 | // implements it, and tests use a fake. | ||
| 36 | type Mailbox interface { | ||
| 37 | Unseen() ([]uint32, error) | ||
| 38 | Fetch(uid uint32) ([]byte, error) | ||
| 39 | MarkSeen(uid uint32) error | ||
| 40 | } | ||
| 41 | |||
| 42 | // maxTries is how many polls a message that keeps failing is tried in | ||
| 43 | // before it is marked seen and given up on. | ||
| 44 | const maxTries = 5 | ||
| 45 | |||
| 46 | // Processor handles fetched messages. | ||
| 47 | type Processor struct { | ||
| 48 | St *store.Store | ||
| 49 | Cfg config.Config | ||
| 50 | Now func() time.Time | ||
| 51 | |||
| 52 | tries map[uint32]int | ||
| 53 | // A window of refusal rows, bounded because anyone can send mail | ||
| 54 | // to the mailbox. | ||
| 55 | windowStart time.Time | ||
| 56 | windowRows int | ||
| 57 | } | ||
| 58 | |||
| 59 | // refusalsPerMinute bounds the audit rows refusals write. | ||
| 60 | const refusalsPerMinute = 60 | ||
| 61 | |||
| 62 | // Result is what became of one message. | ||
| 63 | type Result struct { | ||
| 64 | Posted bool | ||
| 65 | Retry bool // a failure that may pass; the message is left unseen | ||
| 66 | Reason string // why it was refused or failed; empty when posted | ||
| 67 | } | ||
| 68 | |||
| 69 | func refused(format string, args ...any) Result { | ||
| 70 | return Result{Reason: fmt.Sprintf(format, args...)} | ||
| 71 | } | ||
| 72 | |||
| 73 | // Drain handles every unseen message in mb. It stops at the first | ||
| 74 | // mailbox error. | ||
| 75 | func (p *Processor) Drain(mb Mailbox) error { | ||
| 76 | if p.tries == nil { | ||
| 77 | p.tries = map[uint32]int{} | ||
| 78 | } | ||
| 79 | uids, err := mb.Unseen() | ||
| 80 | if err != nil { | ||
| 81 | return err | ||
| 82 | } | ||
| 83 | for _, uid := range uids { | ||
| 84 | 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. | ||
| 113 | func (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 | |||
| 233 | func (p *Processor) now() time.Time { | ||
| 234 | if p.Now != nil { | ||
| 235 | return p.Now() | ||
| 236 | } | ||
| 237 | return time.Now() | ||
| 238 | } | ||
| 239 | |||
| 240 | func (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. | ||
| 249 | func (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. | ||
| 270 | func 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. | ||
| 283 | func 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. | ||
| 301 | type Poller struct { | ||
| 302 | P *Processor | ||
| 303 | In config.MailInbound | ||
| 304 | } | ||
| 305 | |||
| 306 | // Run polls until ctx is done. | ||
| 307 | func (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. | ||
| 325 | func (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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 16 | const replyBase = "reply@gitbay.example" | ||
| 17 | |||
| 18 | type 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. | ||
| 29 | func 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 | |||
| 90 | func (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 | |||
| 100 | var msgSeq int | ||
| 101 | |||
| 102 | func (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 | |||
| 109 | func (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. | ||
| 119 | func (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 | |||
| 132 | func 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 | |||
| 148 | func 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 | |||
| 225 | func 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. | ||
| 240 | func 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 | |||
| 253 | type fakeMailbox struct { | ||
| 254 | msgs map[uint32][]byte | ||
| 255 | seen map[uint32]bool | ||
| 256 | } | ||
| 257 | |||
| 258 | func (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 | |||
| 268 | func (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 | |||
| 275 | func (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. | ||
| 281 | func 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. | ||
| 303 | func 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. | ||
| 6 | package mailreply | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "crypto/hmac" | ||
| 10 | "crypto/sha256" | ||
| 11 | "encoding/base32" | ||
| 12 | "encoding/binary" | ||
| 13 | "errors" | ||
| 14 | "fmt" | ||
| 15 | "strings" | ||
| 16 | "time" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // Lifetime is how long a notification's reply address is accepted. | ||
| 20 | const Lifetime = 30 * 24 * time.Hour | ||
| 21 | |||
| 22 | // Purpose is what the MAC key is derived for (seal.Keyring.Derive). | ||
| 23 | const Purpose = "gitbay mail reply token v1" | ||
| 24 | |||
| 25 | const ( | ||
| 26 | version = 1 | ||
| 27 | macLen = 12 | ||
| 28 | ) | ||
| 29 | |||
| 30 | // Target is the thread a reply posts to, and who it posts as. | ||
| 31 | type Target struct { | ||
| 32 | UserID int64 | ||
| 33 | RepoID int64 | ||
| 34 | Kind string // "issue" or "mr" | ||
| 35 | Number int64 | ||
| 36 | } | ||
| 37 | |||
| 38 | var ( | ||
| 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. | ||
| 46 | var enc = base32.StdEncoding.WithPadding(base32.NoPadding) | ||
| 47 | |||
| 48 | // Mint returns the token for t, valid until expires, authenticated under | ||
| 49 | // keys[0]. | ||
| 50 | func 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. | ||
| 78 | func 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 | |||
| 127 | func 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. | ||
| 135 | func 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. | ||
| 142 | func 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 | |||
| 158 | func 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 @@ | |||
| 1 | package mailreply | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var ( | ||
| 11 | keyA = []byte("0123456789abcdef0123456789abcdef") | ||
| 12 | keyB = []byte("fedcba9876543210fedcba9876543210") | ||
| 13 | now = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) | ||
| 14 | ) | ||
| 15 | |||
| 16 | func mint(t *testing.T, keys [][]byte, tg Target) string { | ||
| 17 | t.Helper() | ||
| 18 | tok, err := Mint(keys, tg, now.Add(Lifetime)) | ||
| 19 | if err != nil { | ||
| 20 | t.Fatal(err) | ||
| 21 | } | ||
| 22 | return tok | ||
| 23 | } | ||
| 24 | |||
| 25 | func TestRoundTrip(t *testing.T) { | ||
| 26 | for _, tg := range []Target{ | ||
| 27 | {UserID: 1, RepoID: 2, Kind: "issue", Number: 3}, | ||
| 28 | {UserID: 1 << 40, RepoID: 99999, Kind: "mr", Number: 123456}, | ||
| 29 | } { | ||
| 30 | tok := mint(t, [][]byte{keyA}, tg) | ||
| 31 | if tok != strings.ToLower(tok) { | ||
| 32 | t.Errorf("token %q is not lower case", tok) | ||
| 33 | } | ||
| 34 | // The address's local part stays within 64 octets. | ||
| 35 | if l := len("reply+" + tok); l > 64 { | ||
| 36 | t.Errorf("local part is %d octets", l) | ||
| 37 | } | ||
| 38 | got, err := Verify([][]byte{keyA}, tok, now) | ||
| 39 | 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. | ||
| 50 | func 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 | |||
| 61 | func 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 | |||
| 80 | func 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. | ||
| 94 | func 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 | |||
| 107 | func 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 | |||
| 121 | func 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) { | |||
| 50 | continue | 50 | continue |
| 51 | } | 51 | } |
| 52 | for _, q := range due { | 52 | for _, q := range due { |
| 53 | if err := mail.Send(m.Cfg, q.Recipient, q.Subject, q.Body); err != nil { | 53 | if err := mail.SendReplyTo(m.Cfg, q.Recipient, q.ReplyTo, q.Subject, q.Body); err != nil { |
| 54 | attempt := q.Attempts + 1 | 54 | attempt := q.Attempts + 1 |
| 55 | if attempt >= m.MaxAttempts { | 55 | if attempt >= m.MaxAttempts { |
| 56 | m.St.MarkMailFailed(q.ID, err.Error(), nil) | 56 | m.St.MarkMailFailed(q.ID, err.Error(), nil) |
internal/seal/seal.go +31 −1
| @@ -10,7 +10,9 @@ import ( | |||
| 10 | "bytes" | 10 | "bytes" |
| 11 | "crypto/aes" | 11 | "crypto/aes" |
| 12 | "crypto/cipher" | 12 | "crypto/cipher" |
| 13 | "crypto/hmac" | ||
| 13 | "crypto/rand" | 14 | "crypto/rand" |
| 15 | "crypto/sha256" | ||
| 14 | "encoding/base64" | 16 | "encoding/base64" |
| 15 | "encoding/hex" | 17 | "encoding/hex" |
| 16 | "errors" | 18 | "errors" |
| @@ -180,6 +182,8 @@ type Keyring struct { | |||
| 180 | fi os.FileInfo | 182 | fi os.FileInfo |
| 181 | cur string | 183 | cur string |
| 182 | aead map[string]cipher.AEAD | 184 | aead map[string]cipher.AEAD |
| 185 | // secrets holds every key's secret, the current key's first. | ||
| 186 | secrets [][]byte | ||
| 183 | } | 187 | } |
| 184 | 188 | ||
| 185 | // Load reads the key file at path and returns a Keyring over it. It | 189 | // Load reads the key file at path and returns a Keyring over it. It |
| @@ -206,6 +210,10 @@ func (k *Keyring) refresh() error { | |||
| 206 | return err | 210 | return err |
| 207 | } | 211 | } |
| 208 | aead := make(map[string]cipher.AEAD, len(keys)) | 212 | aead := make(map[string]cipher.AEAD, len(keys)) |
| 213 | secrets := make([][]byte, 0, len(keys)) | ||
| 214 | for i := len(keys) - 1; i >= 0; i-- { | ||
| 215 | secrets = append(secrets, keys[i].Secret) | ||
| 216 | } | ||
| 209 | for _, key := range keys { | 217 | for _, key := range keys { |
| 210 | block, err := aes.NewCipher(key.Secret) | 218 | block, err := aes.NewCipher(key.Secret) |
| 211 | if err != nil { | 219 | if err != nil { |
| @@ -217,10 +225,32 @@ func (k *Keyring) refresh() error { | |||
| 217 | } | 225 | } |
| 218 | aead[key.ID] = g | 226 | aead[key.ID] = g |
| 219 | } | 227 | } |
| 220 | k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead | 228 | k.fi, k.cur, k.aead, k.secrets = fi, keys[len(keys)-1].ID, aead, secrets |
| 221 | return nil | 229 | return nil |
| 222 | } | 230 | } |
| 223 | 231 | ||
| 232 | // Derive returns a 32-byte key for purpose from every key in the file, | ||
| 233 | // the current key's first: HMAC-SHA256 of purpose under each secret. A | ||
| 234 | // value authenticated under the first still verifies under the others | ||
| 235 | // after a rotation, while the retired key stays in the file. | ||
| 236 | func (k *Keyring) Derive(purpose string) ([][]byte, error) { | ||
| 237 | if purpose == "" { | ||
| 238 | return nil, errors.New("seal: a purpose is required") | ||
| 239 | } | ||
| 240 | k.mu.Lock() | ||
| 241 | defer k.mu.Unlock() | ||
| 242 | if err := k.refresh(); err != nil { | ||
| 243 | return nil, err | ||
| 244 | } | ||
| 245 | out := make([][]byte, 0, len(k.secrets)) | ||
| 246 | for _, s := range k.secrets { | ||
| 247 | m := hmac.New(sha256.New, s) | ||
| 248 | m.Write([]byte(purpose)) | ||
| 249 | out = append(out, m.Sum(nil)) | ||
| 250 | } | ||
| 251 | return out, nil | ||
| 252 | } | ||
| 253 | |||
| 224 | // CurrentID is the id of the key that seals new values. | 254 | // CurrentID is the id of the key that seals new values. |
| 225 | func (k *Keyring) CurrentID() (string, error) { | 255 | func (k *Keyring) CurrentID() (string, error) { |
| 226 | k.mu.Lock() | 256 | k.mu.Lock() |
internal/seal/seal_test.go +31
| @@ -232,3 +232,34 @@ func TestReadKeysRefusesDuplicatesDirectoriesAndLargeFiles(t *testing.T) { | |||
| 232 | t.Error("read a file over the size limit") | 232 | t.Error("read a file over the size limit") |
| 233 | } | 233 | } |
| 234 | } | 234 | } |
| 235 | |||
| 236 | // Derive lists the current key's derivation first and keeps the retired | ||
| 237 | // key's, and differs by purpose. | ||
| 238 | func TestDerive(t *testing.T) { | ||
| 239 | old, cur := newKey(t), newKey(t) | ||
| 240 | one, err := Load(keyFile(t, old)) | ||
| 241 | if err != nil { | ||
| 242 | t.Fatal(err) | ||
| 243 | } | ||
| 244 | two, err := Load(keyFile(t, old, cur)) | ||
| 245 | if err != nil { | ||
| 246 | t.Fatal(err) | ||
| 247 | } | ||
| 248 | a, err := one.Derive("p") | ||
| 249 | if err != nil || len(a) != 1 || len(a[0]) != 32 { | ||
| 250 | t.Fatalf("Derive = %x, %v", a, err) | ||
| 251 | } | ||
| 252 | b, err := two.Derive("p") | ||
| 253 | if err != nil || len(b) != 2 { | ||
| 254 | t.Fatalf("Derive = %x, %v", b, err) | ||
| 255 | } | ||
| 256 | if string(b[1]) != string(a[0]) || string(b[0]) == string(a[0]) { | ||
| 257 | t.Fatal("rotated ring does not list the current key first and the old one after") | ||
| 258 | } | ||
| 259 | if c, _ := one.Derive("q"); string(c[0]) == string(a[0]) { | ||
| 260 | t.Fatal("two purposes derived the same key") | ||
| 261 | } | ||
| 262 | if _, err := one.Derive(""); err == nil { | ||
| 263 | t.Fatal("empty purpose accepted") | ||
| 264 | } | ||
| 265 | } | ||
internal/store/mailreply.go added +43
| @@ -0,0 +1,43 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | func (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. | ||
| 22 | func (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. | ||
| 30 | func (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. | ||
| 37 | func (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 @@ | |||
| 1 | DROP TABLE mail_replies; | ||
| 2 | ALTER TABLE notifications DROP COLUMN reply_to; | ||
| 3 | ALTER TABLE users DROP COLUMN notify_reply; | ||
internal/store/migrations/0073_mail_reply.up.sql added +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. | ||
| 7 | ALTER TABLE users ADD COLUMN notify_reply INTEGER NOT NULL DEFAULT 0; | ||
| 8 | ALTER TABLE notifications ADD COLUMN reply_to TEXT NOT NULL DEFAULT ''; | ||
| 9 | CREATE 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" | |||
| 5 | type QueuedMail struct { | 5 | type QueuedMail struct { |
| 6 | ID int64 | 6 | ID int64 |
| 7 | Recipient string | 7 | Recipient string |
| 8 | ReplyTo string // "" for none | ||
| 8 | Subject string | 9 | Subject string |
| 9 | Body string | 10 | Body string |
| 10 | Attempts int | 11 | Attempts int |
| 11 | } | 12 | } |
| 12 | 13 | ||
| 13 | func (s *Store) EnqueueMail(recipient, subject, body string) error { | 14 | func (s *Store) EnqueueMail(recipient, subject, body string) error { |
| 15 | return s.EnqueueMailReplyTo(recipient, "", subject, body) | ||
| 16 | } | ||
| 17 | |||
| 18 | // EnqueueMailReplyTo queues mail with a Reply-To address. | ||
| 19 | func (s *Store) EnqueueMailReplyTo(recipient, replyTo, subject, body string) error { | ||
| 14 | _, err := s.DB.Exec( | 20 | _, err := s.DB.Exec( |
| 15 | "INSERT INTO notifications (recipient, subject, body) VALUES (?, ?, ?)", | 21 | "INSERT INTO notifications (recipient, reply_to, subject, body) VALUES (?, ?, ?, ?)", |
| 16 | recipient, subject, body) | 22 | recipient, replyTo, subject, body) |
| 17 | return err | 23 | return err |
| 18 | } | 24 | } |
| 19 | 25 | ||
| 20 | func (s *Store) DueMail(limit int) ([]QueuedMail, error) { | 26 | func (s *Store) DueMail(limit int) ([]QueuedMail, error) { |
| 21 | rows, err := s.DB.Query(` | 27 | 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 |
| 23 | WHERE sent_at IS NULL AND failed_at IS NULL | 29 | WHERE sent_at IS NULL AND failed_at IS NULL |
| 24 | AND (next_attempt_at IS NULL OR next_attempt_at <= ?) | 30 | AND (next_attempt_at IS NULL OR next_attempt_at <= ?) |
| 25 | ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit) | 31 | ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit) |
| @@ -30,7 +36,7 @@ func (s *Store) DueMail(limit int) ([]QueuedMail, error) { | |||
| 30 | var out []QueuedMail | 36 | var out []QueuedMail |
| 31 | for rows.Next() { | 37 | for rows.Next() { |
| 32 | var m QueuedMail | 38 | 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 { |
| 34 | return nil, err | 40 | return nil, err |
| 35 | } | 41 | } |
| 36 | out = append(out, m) | 42 | out = append(out, m) |
internal/store/secrets.go +3
| @@ -48,6 +48,9 @@ var secretColumns = []secretColumn{ | |||
| 48 | // SetKeyring sets the keys the secret columns are sealed under. | 48 | // SetKeyring sets the keys the secret columns are sealed under. |
| 49 | func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } | 49 | func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } |
| 50 | 50 | ||
| 51 | // Keyring is the loaded key file, nil when none is set. | ||
| 52 | func (s *Store) Keyring() *seal.Keyring { return s.secrets } | ||
| 53 | |||
| 51 | // sealValue seals v for storage. An empty value stays empty: for | 54 | // sealValue seals v for storage. An empty value stays empty: for |
| 52 | // webhooks and mirrors it means there is no secret. | 55 | // webhooks and mirrors it means there is no secret. |
| 53 | func (s *Store) sealValue(aad, v string) (string, error) { | 56 | func (s *Store) sealValue(aad, v string) (string, error) { |
internal/store/users.go +20
| @@ -230,6 +230,26 @@ func (s *Store) SetMailEnabled(userID int64, on bool) error { | |||
| 230 | return err | 230 | return err |
| 231 | } | 231 | } |
| 232 | 232 | ||
| 233 | // ReplyEnabled reports whether the account's issue and merge request | ||
| 234 | // mail carries a reply address (#295). | ||
| 235 | func (s *Store) ReplyEnabled(userID int64) (bool, error) { | ||
| 236 | var on int | ||
| 237 | err := s.DB.QueryRow("SELECT notify_reply FROM users WHERE id = ?", userID).Scan(&on) | ||
| 238 | if errors.Is(err, sql.ErrNoRows) { | ||
| 239 | return false, ErrNotFound | ||
| 240 | } | ||
| 241 | return on != 0, err | ||
| 242 | } | ||
| 243 | |||
| 244 | func (s *Store) SetReplyEnabled(userID int64, on bool) error { | ||
| 245 | v := 0 | ||
| 246 | if on { | ||
| 247 | v = 1 | ||
| 248 | } | ||
| 249 | _, err := s.DB.Exec("UPDATE users SET notify_reply = ? WHERE id = ?", v, userID) | ||
| 250 | return err | ||
| 251 | } | ||
| 252 | |||
| 233 | // WatchEnabled reports whether the account hears about every issue and | 253 | // WatchEnabled reports whether the account hears about every issue and |
| 234 | // merge request on the repositories it can write to, without a | 254 | // merge request on the repositories it can write to, without a |
| 235 | // repo_watchers row on each (#194). | 255 | // repo_watchers row on each (#194). |
internal/web/templates/account.html +8 −1
| @@ -138,7 +138,14 @@ account and where notifications go.</p> | |||
| 138 | <button type="submit" class="btn">Save</button> | 138 | <button type="submit" class="btn">Save</button> |
| 139 | </form> | 139 | </form> |
| 140 | <p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p> | 140 | <p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p> |
| 141 | <form method="post" action="/settings" class="setform"> | 141 | {{if .ReplyOffered}}<form method="post" action="/settings" class="setform"> |
| 142 | <input type="hidden" name="field" value="notify-reply"> | ||
| 143 | <label for="notify-reply">Reply to mail to comment</label> | ||
| 144 | <div class="check"><input type="checkbox" id="notify-reply" name="reply" value="on"{{if .ReplyOn}} checked{{end}}></div> | ||
| 145 | <button type="submit" class="btn">Save</button> | ||
| 146 | </form> | ||
| 147 | <p class="meta">Issue and merge request mail gets a reply address. A reply posts a comment as you when it comes from one of your verified addresses; quoted text and signatures are removed.</p> | ||
| 148 | {{end}}<form method="post" action="/settings" class="setform"> | ||
| 142 | <input type="hidden" name="field" value="notify-watch"> | 149 | <input type="hidden" name="field" value="notify-watch"> |
| 143 | <label for="notify-watch">Watch repositories you can write to</label> | 150 | <label for="notify-watch">Watch repositories you can write to</label> |
| 144 | <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div> | 151 | <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div> |