aboutsummaryrefslogtreecommitdiff
path: root/mail.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /mail.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
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.go280
1 files changed, 280 insertions, 0 deletions
diff --git a/mail.go b/mail.go
new file mode 100644
index 0000000..d6495be
--- /dev/null
+++ b/mail.go
@@ -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)
+}