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