This repository has no description
1package email
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "html/template"
8 "log/slog"
9 "strings"
10 gotemplate "text/template"
11 "time"
12
13 "tangled.org/core/appview/config"
14 "tangled.org/core/appview/db"
15 "tangled.org/core/appview/email"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/idresolver"
18)
19
20const digestTextTmpl = `Hi {{.RecipientHandle}},
21
22You have {{.Count}} new notification(s) on Tangled:
23
24{{range .Groups}}- {{wrap 70 " " .Header}}{{if .EntityRef}}
25 {{wrap 70 " " .EntityRef}}{{end}}
26 {{.URL}}
27{{end}}{{if .HasMore}}
28View more notifications: {{.NotificationsURL}}
29{{end}}---
30Manage notifications: {{.SettingsURL}}
31`
32
33const digestHTMLTmpl = `<!DOCTYPE html>
34<html dir="ltr" lang="en">
35<head>
36<meta charset="UTF-8">
37<meta name="viewport" content="width=device-width, initial-scale=1.0">
38<title>Tangled notifications</title>
39</head>
40<body style="background-color:#ffffff;padding:0;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
41<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
42 <tbody>
43 <tr>
44 <td style="background-color:#ffffff;padding:16px;color:#111827;font-size:16px;font-weight:400;">
45 <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:600px;width:100%;table-layout:fixed;color:#111827;background-color:#ffffff;margin-left:auto;margin-right:auto;">
46 <tbody>
47 <tr>
48 <td>
49 <div style="padding-bottom:16px;">
50 <img src="{{.AssetsURL}}dolly.png" width="40" height="40" alt="Tangled" style="display:block;border:0;">
51 </div>
52 <p style="font-size:16px;padding:16px 0;">Hi {{.RecipientHandle}},</p>
53 <p style="font-size:16px;padding:0 0 16px 0;margin:0;">
54 You have {{.Count}} new notification{{if gt .Count 1}}s{{end}}:
55 </p>
56 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
57 {{range .Groups}}
58 <tr>
59 <td style="padding:12px 0;border-bottom:1px solid #eaeaea;">
60 <a href="{{.URL}}" style="text-decoration:none;color:inherit;display:block;">
61 <table cellpadding="0" cellspacing="0" width="100%" role="presentation">
62 <tr>
63 <td style="width:20px;vertical-align:top;padding-top:1px;">
64 <img src="{{.IconURL}}" width="16" height="16" alt="" style="display:block;border:0;">
65 </td>
66 <td style="padding-left:8px;">
67 <p style="margin:0 0 3px 0;font-size:14px;color:#374151;">{{.HeaderHTML}}</p>
68 {{if .EntityRef}}<p style="margin:0;font-size:13px;color:#6b7280;">{{.EntityRef}}</p>{{end}}
69 </td>
70 </tr>
71 </table>
72 </a>
73 </td>
74 </tr>
75 {{end}}
76 </table>
77 {{if .HasMore}}
78 <p style="font-size:14px;padding:16px 0 0 0;margin:0;">
79 <a href="{{.NotificationsURL}}" style="color:#111827;text-decoration:underline;font-weight:400;">View more notifications</a>
80 </p>
81 {{end}}
82 <table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
83 <tbody>
84 <tr>
85 <td>
86 <p style="font-size:16px;padding:32px 0 16px 0;text-align:center;">
87 <a href="{{.SettingsURL}}" style="color:#111827;text-decoration:underline;font-weight:400;">Manage notification settings</a>
88 </p>
89 <p style="font-size:16px;text-align:center;color:#6b7280;">Tangled Labs Oy. © 2026 All rights reserved.</p>
90 <p style="font-size:16px;text-align:center;">
91 <a href="https://tangled.org" style="color:#111827;text-decoration:underline;font-weight:400;">tangled.org</a>
92 ·
93 <a href="https://bsky.app/profile/tangled.org" style="color:#111827;text-decoration:underline;font-weight:400;">Bluesky</a>
94 ·
95 <a href="https://x.com/tangled_org" style="color:#111827;text-decoration:underline;font-weight:400;">X</a>
96 ·
97 <a href="https://linkedin.com/in/tangled" style="color:#111827;text-decoration:underline;font-weight:400;">LinkedIn</a>
98 </p>
99 </td>
100 </tr>
101 </tbody>
102 </table>
103 </td>
104 </tr>
105 </tbody>
106 </table>
107 </td>
108 </tr>
109 </tbody>
110</table>
111</body>
112</html>`
113
114type digestGroup struct {
115 IconURL string
116 Header string
117 HeaderHTML template.HTML
118 EntityRef string
119 URL string
120}
121
122func notifHeader(n *models.NotificationWithEntity, actor, repo string) string {
123 switch n.Type {
124 case models.NotificationTypeIssueCreated:
125 return actor + " opened an issue on " + repo
126 case models.NotificationTypeIssueCommented:
127 return actor + " commented on an issue on " + repo
128 case models.NotificationTypeIssueClosed:
129 return actor + " closed an issue on " + repo
130 case models.NotificationTypeIssueReopen:
131 return actor + " reopened an issue on " + repo
132 case models.NotificationTypePullCreated:
133 return actor + " created a PR on " + repo
134 case models.NotificationTypePullCommented:
135 return actor + " commented on a PR on " + repo
136 case models.NotificationTypePullMerged:
137 return actor + " merged a PR on " + repo
138 case models.NotificationTypePullClosed:
139 return actor + " closed a PR on " + repo
140 case models.NotificationTypePullReopen:
141 return actor + " reopened a PR on " + repo
142 case models.NotificationTypeUserMentioned:
143 if n.Issue != nil {
144 return actor + " mentioned you on an issue in " + repo
145 } else if n.Pull != nil {
146 return actor + " mentioned you on a pull request in " + repo
147 }
148 return actor + " mentioned you in " + repo
149 case models.NotificationTypeIssueAssigned:
150 return actor + " assigned you to an issue on " + repo
151 case models.NotificationTypeIssueUnassigned:
152 return actor + " unassigned you from an issue on " + repo
153 case models.NotificationTypePullAssigned:
154 return actor + " assigned you to a PR on " + repo
155 case models.NotificationTypePullUnassigned:
156 return actor + " unassigned you from a PR on " + repo
157 default:
158 return actor + " updated " + repo
159 }
160}
161
162func notifEntityRef(n *models.NotificationWithEntity) string {
163 if n.Issue != nil {
164 return fmt.Sprintf("#%d %s", n.Issue.IssueId, n.Issue.Title)
165 }
166 if n.Pull != nil {
167 return fmt.Sprintf("#%d %s", n.Pull.PullId, n.Pull.Title)
168 }
169 return ""
170}
171
172func wordwrap(width int, indent, text string) string {
173 words := strings.Fields(text)
174 if len(words) == 0 {
175 return text
176 }
177 var b strings.Builder
178 col := 0
179 for i, w := range words {
180 if i == 0 {
181 b.WriteString(w)
182 col = len(w)
183 continue
184 }
185 if col+1+len(w) > width {
186 b.WriteString("\n" + indent)
187 b.WriteString(w)
188 col = len(indent) + len(w)
189 } else {
190 b.WriteByte(' ')
191 b.WriteString(w)
192 col += 1 + len(w)
193 }
194 }
195 return b.String()
196}
197
198// digestMaxGroups caps how many notifications are itemized in a digest email;
199// beyond this a "View more" link points to the notifications page.
200const digestMaxGroups = 10
201
202type digestData struct {
203 RecipientHandle string
204 Count int
205 Groups []digestGroup
206 HasMore bool
207 NotificationsURL string
208 SettingsURL string
209 AssetsURL string
210}
211
212// Dispatcher polls the notifications table and sends digest emails.
213type Dispatcher struct {
214 db *db.DB
215 resend config.ResendConfig
216 baseURL string
217 assetsURL string
218 resolver *idresolver.Resolver
219 logger *slog.Logger
220 batchWait time.Duration
221 interval time.Duration
222
223 textTmpl *gotemplate.Template
224 htmlTmpl *template.Template
225}
226
227func NewDispatcher(
228 database *db.DB,
229 resend config.ResendConfig,
230 baseURL string,
231 resolver *idresolver.Resolver,
232 logger *slog.Logger,
233 dev bool,
234) *Dispatcher {
235 batchWait := 10 * time.Minute
236 interval := 5 * time.Minute
237 if dev {
238 batchWait = 30 * time.Second
239 interval = 15 * time.Second
240 }
241 return &Dispatcher{
242 db: database,
243 resend: resend,
244 baseURL: strings.TrimRight(baseURL, "/"),
245 assetsURL: strings.TrimRight(resend.AssetsURL, "/") + "/",
246 resolver: resolver,
247 logger: logger,
248 batchWait: batchWait,
249 interval: interval,
250 textTmpl: gotemplate.Must(gotemplate.New("digest-text").Funcs(gotemplate.FuncMap{"wrap": wordwrap}).Parse(digestTextTmpl)),
251 htmlTmpl: template.Must(template.New("digest-html").Parse(digestHTMLTmpl)),
252 }
253}
254
255// Start runs the dispatcher ticker loop until ctx is cancelled.
256func (d *Dispatcher) Start(ctx context.Context) {
257 d.logger.Info("email dispatcher started", "interval", d.interval, "batchWait", d.batchWait)
258 ticker := time.NewTicker(d.interval)
259 defer ticker.Stop()
260 for {
261 select {
262 case <-ticker.C:
263 d.dispatch(ctx)
264 case <-ctx.Done():
265 d.logger.Info("email dispatcher stopped")
266 return
267 }
268 }
269}
270
271func (d *Dispatcher) dispatch(ctx context.Context) {
272 cutoff := time.Now().Add(-d.batchWait)
273
274 recipients, err := db.GetPendingEmailDigestRecipients(d.db, cutoff)
275 if err != nil {
276 d.logger.Error("email dispatcher: failed to get recipients", "err", err)
277 return
278 }
279
280 d.logger.Debug("email dispatcher: processing recipients", "count", len(recipients))
281
282 for _, recipientDid := range recipients {
283 if err := d.sendDigest(ctx, recipientDid, cutoff); err != nil {
284 d.logger.Error("email dispatcher: failed to send digest", "did", recipientDid, "err", err)
285 }
286 }
287}
288
289func (d *Dispatcher) sendDigest(ctx context.Context, recipientDid string, cutoff time.Time) error {
290 em, err := db.GetPrimaryEmail(d.db, recipientDid)
291 if err != nil || !em.Verified {
292 return nil
293 }
294
295 notifs, err := db.GetPendingNotificationsForEmailDigest(d.db, recipientDid, cutoff)
296 if err != nil {
297 return fmt.Errorf("get pending notifications: %w", err)
298 }
299 if len(notifs) == 0 {
300 return nil
301 }
302
303 handle := recipientDid
304 if id, err := d.resolver.ResolveIdent(ctx, recipientDid); err == nil && !id.Handle.IsInvalidHandle() {
305 handle = id.Handle.String()
306 }
307
308 subject, text, html, err := d.renderDigest(ctx, handle, notifs)
309 if err != nil {
310 return fmt.Errorf("render digest: %w", err)
311 }
312
313 // collect IDs before sending so new notifications created during sending
314 // aren't accidentally marked as emailed.
315 ids := make([]int64, len(notifs))
316 for i, n := range notifs {
317 ids[i] = n.ID
318 }
319
320 if err := email.SendEmail(email.Email{
321 APIKey: d.resend.ApiKey,
322 From: "Tangled <" + d.resend.SentFrom + ">",
323 To: em.Address,
324 Subject: subject,
325 Text: text,
326 Html: html,
327 }); err != nil {
328 return fmt.Errorf("send email: %w", err)
329 }
330
331 d.logger.Info("email dispatcher: digest sent", "did", recipientDid, "notifications", len(notifs))
332
333 if err := db.MarkNotificationsEmailed(d.db, ids); err != nil {
334 d.logger.Error("email dispatcher: failed to mark notifications emailed", "did", recipientDid, "err", err)
335 }
336
337 return nil
338}
339
340func (d *Dispatcher) renderDigest(ctx context.Context, recipientHandle string, notifs []*models.NotificationWithEntity) (subject, text, html string, err error) {
341 count := len(notifs)
342
343 shown := notifs
344 if len(shown) > digestMaxGroups {
345 shown = shown[:digestMaxGroups]
346 }
347
348 groups := make([]digestGroup, 0, len(shown))
349
350 for _, n := range shown {
351 actorHandle := n.ActorDid
352 if id, err2 := d.resolver.ResolveIdent(ctx, n.ActorDid); err2 == nil && !id.Handle.IsInvalidHandle() {
353 actorHandle = id.Handle.String()
354 }
355
356 repoStr := ""
357 if n.Repo != nil {
358 repoHandle := n.Repo.Did
359 if id, err2 := d.resolver.ResolveIdent(ctx, n.Repo.Did); err2 == nil && !id.Handle.IsInvalidHandle() {
360 repoHandle = id.Handle.String()
361 }
362 repoStr = repoHandle + "/" + n.Repo.Slug()
363 }
364
365 header := notifHeader(n, actorHandle, repoStr)
366 headerHTML := template.HTML(strings.Replace(header, actorHandle, "<strong>"+actorHandle+"</strong>", 1))
367 groups = append(groups, digestGroup{
368 IconURL: d.assetsURL + n.Icon() + ".png",
369 Header: header,
370 HeaderHTML: headerHTML,
371 EntityRef: notifEntityRef(n),
372 URL: d.baseURL + n.URL(d.resolver),
373 })
374 }
375
376 data := digestData{
377 RecipientHandle: recipientHandle,
378 Count: count,
379 Groups: groups,
380 HasMore: count > digestMaxGroups,
381 NotificationsURL: d.baseURL + "/notifications",
382 SettingsURL: d.baseURL + "/settings/notifications",
383 AssetsURL: d.assetsURL,
384 }
385
386 if count > digestMaxGroups {
387 subject = fmt.Sprintf("[%s] %d+ notifications", recipientHandle, digestMaxGroups)
388 } else {
389 subject = fmt.Sprintf("[%s] %d notification(s)", recipientHandle, count)
390 }
391
392 var textBuf bytes.Buffer
393 if err = d.textTmpl.Execute(&textBuf, data); err != nil {
394 return
395 }
396 text = textBuf.String()
397
398 var htmlBuf bytes.Buffer
399 if err = d.htmlTmpl.Execute(&htmlBuf, data); err != nil {
400 return
401 }
402 html = htmlBuf.String()
403
404 return
405}