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
13 kB 405 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}}{{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. &copy; 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 &nbsp;&middot;&nbsp; 93 <a href="https://bsky.app/profile/tangled.org" style="color:#111827;text-decoration:underline;font-weight:400;">Bluesky</a> 94 &nbsp;&middot;&nbsp; 95 <a href="https://x.com/tangled_org" style="color:#111827;text-decoration:underline;font-weight:400;">X</a> 96 &nbsp;&middot;&nbsp; 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}