diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /mail.go | |
| download | git-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz | |
A small self-hosted git forge: HTTP push and pull, cgit browsing,
local and OIDC accounts, TOTP, scoped push tokens, per-repo
permissions, issues, a wiki, and outbound mail.
It does not reimplement git. git-http-backend and cgit run as CGI
behind its own listener, which authorises every request before
exec'ing either. One binary, one data directory, one listener: no
database, no JavaScript, and state is JSON in a git repo with a
working tree.
Diffstat (limited to 'mail.go')
| -rw-r--r-- | mail.go | 280 |
1 files changed, 280 insertions, 0 deletions
@@ -0,0 +1,280 @@ +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) +} |