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 = `<p style="font-size:15px;line-height:24px;margin:0 0 8px 0;">Hi {{.RecipientHandle}},</p>
<p style="font-size:15px;line-height:24px;margin:0 0 16px 0;">New activity on <span style="font-weight:500;">{{.Title}}</span>{{if .Repo}} in {{.Repo}}{{end}}:</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-top:1px solid #e5e5e5;">
{{range .Events}}
<tr>
<td style="padding:12px 0;border-bottom:1px solid #e5e5e5;">
<table cellpadding="0" cellspacing="0" width="100%" role="presentation">
<tr>
<td style="width:16px;vertical-align:top;padding-top:2px;">
<img src="{{.IconURL}}" width="16" height="16" alt="" style="display:block;border:0;">
</td>
<td style="padding-left:8px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#0a0a0a;"><span style="font-weight:500;">{{.Actor}}</span> {{.Phrase}}</p>
</td>
</tr>
</table>
</td>
</tr>
{{end}}
</table>
<p style="font-size:13px;line-height:20px;margin:16px 0 0 0;">
<a href="{{.URL}}" style="color:#0a0a0a;text-decoration:underline;">View it on Tangled</a>
</p>
<p style="font-size:13px;line-height:20px;margin:24px 0 0 0;">
<a href="{{.SettingsURL}}" style="color:#525252;text-decoration:underline;">Manage notification settings</a>
</p>`
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 "<thread." + hex.EncodeToString(sum[:16]) + "@" + domain + ">"
}
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
}