This repository has no description
0

Configure Feed

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

appview/notify/email: add email notification digest dispatcher

author
Anirudh Oppiliappan
committer
Anirudh Oppiliappan
date (Jul 22, 2026, 12:03 PM +0300) commit 91337341 parent 7fa4aec9 change-id oplyqlsr
+382
+1
appview/config/config.go
··· 69 69 ApiKey string `env:"API_KEY"` 70 70 SentFrom string `env:"SENT_FROM, default=noreply@notifs.tangled.sh"` 71 71 NewsletterSegmentId string `env:"NEWSLETTER_SEGMENT_ID"` 72 + AssetsURL string `env:"ASSETS_URL, default=https://assets.tangled.network/email/"` 72 73 } 73 74 74 75 type CamoConfig struct {
+381
appview/notify/email/dispatcher.go
··· 1 + package email 2 + 3 + import ( 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 + 20 + const digestTextTmpl = `Hi {{.RecipientHandle}}, 21 + 22 + You 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 + --- 29 + Manage notifications: {{.SettingsURL}} 30 + ` 31 + 32 + const 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 + 108 + type digestGroup struct { 109 + IconURL string 110 + Header string 111 + HeaderHTML template.HTML 112 + EntityRef string 113 + URL string 114 + } 115 + 116 + func 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 + 156 + func 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 + 166 + func 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 + 192 + type 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. 201 + type 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 + 215 + func 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. 244 + func (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 + 259 + func (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 + 277 + func (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 + 328 + func (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 + }