This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / notify / email / dispatcher.go
12 kB 381 lines
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. &copy; 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 &nbsp;&middot;&nbsp; 87 <a href="https://bsky.app/profile/tangled.org" style="color:#111827;text-decoration:underline;font-weight:400;">Bluesky</a> 88 &nbsp;&middot;&nbsp; 89 <a href="https://x.com/tangled_org" style="color:#111827;text-decoration:underline;font-weight:400;">X</a> 90 &nbsp;&middot;&nbsp; 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}