package deliberi import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "html/template" "log/slog" "slices" "strings" gotemplate "text/template" "time" "tangled.org/core/deliberi/config" deldb "tangled.org/core/deliberi/db" "tangled.org/core/deliberi/emailtemplate" "tangled.org/core/deliberi/mailer" "tangled.org/core/deliberi/models" "tangled.org/core/idresolver" ) const threadTextTmpl = `Hi {{.RecipientHandle}}, New activity on {{wrap 70 "" .Title}}{{if .Repo}} in {{.Repo}}{{end}}: {{range .Events}}- {{wrap 70 " " .Header}} {{end}} View it on Tangled: {{.URL}} --- Manage notification settings: {{.SettingsURL}} ` const threadBodyHTMLTmpl = `

Hi {{.RecipientHandle}},

New activity on {{.Title}}{{if .Repo}} in {{.Repo}}{{end}}:

{{range .Events}} {{end}}

{{.Actor}} {{.Phrase}}

View it on Tangled

Manage notification settings

` type threadEvent struct { IconURL string Actor string Phrase string Header string } type threadData struct { RecipientHandle string Title string Repo string Events []threadEvent URL string SettingsURL string } type threadEmail struct { subject, text, html string headers map[string]string } func notifPhrase(n *models.Notification) string { switch n.Type { case models.NotificationTypeIssueCreated: return "opened this issue" case models.NotificationTypePullCreated: return "opened this pull request" case models.NotificationTypeIssueCommented, models.NotificationTypePullCommented: return "commented" case models.NotificationTypeIssueClosed: return "closed this issue" case models.NotificationTypeIssueReopen: return "reopened this issue" case models.NotificationTypePullMerged: return "merged this pull request" case models.NotificationTypePullClosed: return "closed this pull request" case models.NotificationTypePullReopen: return "reopened this pull request" case models.NotificationTypeUserMentioned: return "mentioned you" default: return "updated this" } } func wordwrap(width int, indent, text string) string { words := strings.Fields(text) if len(words) == 0 { return text } var b strings.Builder col := 0 for i, w := range words { if i == 0 { b.WriteString(w) col = len(w) continue } if col+1+len(w) > width { b.WriteString("\n" + indent) b.WriteString(w) col = len(indent) + len(w) } else { b.WriteByte(' ') b.WriteString(w) col += 1 + len(w) } } return b.String() } // every email about one issue or pull points at the same root id, which mail // clients thread on. the root itself is never sent, so no id is ever reused func threadRoot(entityAt, domain string) string { sum := sha256.Sum256([]byte(entityAt)) return "" } type emailSender interface { Send(mailer.Message) error Domain() string } type Dispatcher struct { db *deldb.DB sender emailSender baseURL string assetsURL string resolver *idresolver.Resolver logger *slog.Logger batchWait time.Duration interval time.Duration textTmpl *gotemplate.Template htmlTmpl *template.Template } func NewDispatcher(database *deldb.DB, sender emailSender, resend config.ResendConfig, baseURL string, resolver *idresolver.Resolver, logger *slog.Logger, dev bool) *Dispatcher { batchWait := 10 * time.Minute interval := 5 * time.Minute if dev { batchWait = 30 * time.Second interval = 15 * time.Second } return &Dispatcher{ db: database, sender: sender, baseURL: strings.TrimRight(baseURL, "/"), assetsURL: strings.TrimRight(resend.AssetsURL, "/") + "/", resolver: resolver, logger: logger, batchWait: batchWait, interval: interval, textTmpl: gotemplate.Must(gotemplate.New("thread-text").Funcs(gotemplate.FuncMap{"wrap": wordwrap}).Parse(threadTextTmpl)), htmlTmpl: template.Must(template.New("thread-body").Parse(threadBodyHTMLTmpl)), } } func (d *Dispatcher) Start(ctx context.Context) { d.logger.Info("email dispatcher started", "interval", d.interval, "batchWait", d.batchWait) ticker := time.NewTicker(d.interval) defer ticker.Stop() for { select { case <-ticker.C: d.dispatch(ctx) case <-ctx.Done(): d.logger.Info("email dispatcher stopped") return } } } func (d *Dispatcher) dispatch(ctx context.Context) { cutoff := time.Now().Add(-d.batchWait) recipients, err := deldb.GetPendingEmailDigestRecipients(d.db, cutoff) if err != nil { d.logger.Error("email dispatcher: failed to get recipients", "err", err) return } for _, did := range recipients { if err := d.sendPending(ctx, did, cutoff); err != nil { d.logger.Error("email dispatcher: failed to send", "did", did, "err", err) } } } func (d *Dispatcher) sendPending(ctx context.Context, recipientDid string, cutoff time.Time) error { em, err := deldb.GetPrimaryEmail(d.db, recipientDid) if err != nil || !em.Verified { return nil } notifs, err := deldb.GetPendingNotificationsForEmailDigest(d.db, recipientDid, cutoff) if err != nil { return fmt.Errorf("get pending notifications: %w", err) } if len(notifs) == 0 { return nil } handle := d.handle(ctx, recipientDid) var order []string byEntity := map[string][]*models.Notification{} for _, n := range notifs { if _, ok := byEntity[n.EntityAt]; !ok { order = append(order, n.EntityAt) } byEntity[n.EntityAt] = append(byEntity[n.EntityAt], n) } var failed error for _, entity := range order { group := byEntity[entity] slices.SortStableFunc(group, func(a, b *models.Notification) int { return a.Created.Compare(b.Created) }) mail, err := d.renderThread(ctx, handle, group) if err != nil { failed = fmt.Errorf("render %s: %w", entity, err) continue } if err := d.sender.Send(mailer.Message{To: em.Address, Subject: mail.subject, Text: mail.text, Html: mail.html, Headers: mail.headers}); err != nil { failed = fmt.Errorf("send %s: %w", entity, err) continue } d.logger.Info("email dispatcher: sent", "did", recipientDid, "entity", entity, "notifications", len(group)) ids := make([]int64, len(group)) for i, n := range group { ids[i] = n.ID } if err := deldb.MarkEmailed(d.db, ids); err != nil { d.logger.Error("email dispatcher: failed to mark emailed", "did", recipientDid, "err", err) } } return failed } func (d *Dispatcher) handle(ctx context.Context, did string) string { if id, err := d.resolver.ResolveIdent(ctx, did); err == nil && !id.Handle.IsInvalidHandle() { return id.Handle.String() } return did } // group is one entity's notifications, oldest first func (d *Dispatcher) renderThread(ctx context.Context, recipientHandle string, group []*models.Notification) (mail threadEmail, err error) { latest := group[len(group)-1] repo := "" if latest.RepoDid != "" { repo = latest.RepoOwnerHandle(d.resolver) if latest.RepoName != "" { repo += "/" + latest.RepoName } } title := latest.EntityTitle if title == "" { title = "an issue" if latest.EntityKind == models.EntityKindPull { title = "a pull request" } } events := make([]threadEvent, 0, len(group)) for _, n := range group { actor := d.handle(ctx, n.ActorDid) phrase := notifPhrase(n) events = append(events, threadEvent{ IconURL: d.assetsURL + n.Icon() + ".png", Actor: actor, Phrase: phrase, Header: actor + " " + phrase, }) } data := threadData{ RecipientHandle: recipientHandle, Title: title, Repo: repo, Events: events, URL: d.baseURL + latest.URL(d.resolver), SettingsURL: d.baseURL + "/settings/notifications", } // mail clients thread on matching subjects too, so this must not vary between emails mail.subject = title if repo != "" { mail.subject = "[" + repo + "] " + title } root := threadRoot(latest.EntityAt, d.sender.Domain()) mail.headers = map[string]string{"In-Reply-To": root, "References": root} var textBuf bytes.Buffer if err = d.textTmpl.Execute(&textBuf, data); err != nil { return } mail.text = textBuf.String() var htmlBuf bytes.Buffer if err = d.htmlTmpl.Execute(&htmlBuf, data); err != nil { return } mail.html = emailtemplate.Shell(mail.subject, htmlBuf.String(), d.assetsURL) return }