package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" ) // Mail is outbound only, plain text. Unsubscribe says the message respects the // notify toggle, and puts List-Unsubscribe on it. type Message struct { To string `json:"to"` Subject string `json:"subject"` Body string `json:"body"` Unsubscribe bool `json:"unsubscribe,omitempty"` Attempts int `json:"attempts,omitempty"` } type Mailer interface { Send(m Message) error } const ( mailIdle = 60 * time.Second // how often to look when nothing woke Drain mailBackoff = 5 * time.Minute // the ceiling on backing off after a failure mailAttempts = 6 ) // Mail is the spool and its drainer: a directory, where adding and removing // are atomic. type Mail struct { dir string driver Mailer wake chan struct{} } func newMail(cfg *Config, dir string) *Mail { var driver Mailer = dropMailer{} switch cfg.Mail.Driver { case "log": driver = logMailer{from: cfg.Mail.From} case "mailgun": driver = &mailgunMailer{ from: cfg.Mail.From, base: cfg.Mail.Mailgun.BaseURL, domain: cfg.Mail.Mailgun.Domain, key: cfg.Mail.Mailgun.APIKey, list: cfg.BaseURL + "/settings", client: &http.Client{Timeout: 30 * time.Second}, } } return &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)} } // Enqueue never fails the request that caused it: mail is a side effect. func (m *Mail) Enqueue(msg Message) { if msg.To == "" { return } data, err := marshalJSON(msg) if err != nil { log.Printf("mail: %v", err) return } // A sortable timestamp plus a random suffix, so oldest first is a // directory sort. name := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + randomToken(4) + ".json" if err := writeFile(filepath.Join(m.dir, name), data, 0600); err != nil { log.Printf("mail: %v", err) return } select { case m.wake <- struct{}{}: default: } } // Drain is the only background goroutine. Delivery is at-least-once: a crash // between sending and deleting resends once. func (m *Mail) Drain(ctx context.Context) { backoff := time.Second for { failed := m.drainOnce() wait := mailIdle if failed { wait = backoff if backoff *= 2; backoff > mailBackoff { backoff = mailBackoff } } else { backoff = time.Second } // After a failure a wake would only retry the head early. A nil // channel never fires. wake := m.wake if failed { wake = nil } select { case <-ctx.Done(): return case <-wake: case <-time.After(wait): } } } // drainOnce reports whether it stopped on a failure; it stops rather than // skipping ahead, to keep oldest first. func (m *Mail) drainOnce() bool { entries, err := os.ReadDir(m.dir) if err != nil { log.Printf("mail: %v", err) return true } names := make([]string, 0, len(entries)) for _, entry := range entries { if strings.HasSuffix(entry.Name(), ".json") { names = append(names, entry.Name()) } } sort.Strings(names) for _, name := range names { path := filepath.Join(m.dir, name) data, err := os.ReadFile(path) if err != nil { log.Printf("mail: %s: %v", name, err) return true } var msg Message if err := json.Unmarshal(data, &msg); err != nil { log.Printf("mail: %s does not parse, dropping it: %v", name, err) os.Remove(path) continue } if err := m.driver.Send(msg); err != nil { msg.Attempts++ if msg.Attempts >= mailAttempts { // Never the body: it may carry a set-password link. log.Printf("mail: giving up on %q to %s after %d attempts: %v", msg.Subject, msg.To, msg.Attempts, err) os.Remove(path) return true } log.Printf("mail: %q to %s failed, attempt %d: %v", msg.Subject, msg.To, msg.Attempts, err) // Unrecorded, the attempt would repeat for ever. again, err := marshalJSON(msg) if err == nil { err = writeFile(path, again, 0600) } if err != nil { log.Printf("mail: cannot record the attempt on %q to %s, dropping it: %v", msg.Subject, msg.To, err) os.Remove(path) } return true } if err := os.Remove(path); err != nil { // Or it is sent again on every drain, silently. log.Printf("mail: %q to %s was sent but is still spooled: %v", msg.Subject, msg.To, err) return true } } return false } // dropMailer is the none driver, and the default: nothing is sent, and the log // names what was not, never the body. type dropMailer struct{} func (dropMailer) Send(m Message) error { log.Printf("mail: none, dropped %q to %s", m.Subject, m.To) return nil } // logMailer writes whole messages to the log, for development: the one driver // that puts a set-password link there. type logMailer struct{ from string } func (l logMailer) Send(m Message) error { log.Printf("mail: from %s to %s\nSubject: %s\n\n%s", l.from, m.To, m.Subject, m.Body) return nil } // mailgunMailer is one form-encoded POST, the whole API surface in use. type mailgunMailer struct { from, base, domain, key, list string client *http.Client } func (g *mailgunMailer) Send(m Message) error { form := url.Values{ "from": {g.from}, "to": {m.To}, "subject": {m.Subject}, "text": {m.Body}, } if m.Unsubscribe { form.Set("h:List-Unsubscribe", "<"+g.list+">") } endpoint := g.base + "/" + g.domain + "/messages" req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return err } req.SetBasicAuth("api", g.key) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := g.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("mailgun: %s", resp.Status) } return nil } // The notification rules. Mail is sent for these events and no others. // mailAccount tells the account holder about their own account: it ignores // the notify toggle, and still needs a proved address. func (f *Forge) mailAccount(u User, subject, body string) { if !u.EmailConfirmed { return } f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body}) } // mailLink carries a capability to an address confirmed or not: following it // is what confirms it. func (f *Forge) mailLink(to, subject, body string) { f.mail.Enqueue(Message{To: to, Subject: f.subject(subject), Body: body}) } // mailRepo is a message to everyone holding any grant on a repository, // respecting the toggle and never to whoever caused the event. func (f *Forge) mailRepo(repo Repo, except, subject, body string) { for name := range repo.Grants { if name == except { continue } if u, ok := f.store.User(name); ok && u.Notify && u.EmailConfirmed { f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true}) } } } // mailAdmins is a message to every global admin, respecting the toggle. func (f *Forge) mailAdmins(except, subject, body string) { for name, u := range f.store.Users() { if name == except || !u.Admin || !u.Notify || !u.EmailConfirmed { continue } f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true}) } } func (f *Forge) subject(s string) string { return "[" + f.siteName() + "] " + s } func (f *Forge) url(path string) string { return f.cfg.BaseURL + path } // notifyIssue tells everyone with a grant that an issue moved. func (f *Forge) notifyIssue(path string, repo Repo, issue Issue, actor, verb string) { subject := fmt.Sprintf("%s#%d %s: %s", path, issue.ID, verb, issue.Title) body := fmt.Sprintf("%s %s %s#%d.\n\n%s\n\n%s\n", actor, verb, path, issue.ID, issue.Title, f.url(fmt.Sprintf("/%s/issues/%d", path, issue.ID))) f.mailRepo(repo, actor, subject, body) }