Monorepo for Tangled tangled.org

appview: introduce email notifications for @ mentions on issue/pr comments #393

closed opened by boltless.me targeting master from boltless.me/core: feat/mentions

Stacked on top of #392

Yes, I know we have stacked PRs, but I want to explicitly separate two sets of commits and review both on different places

This is MVC implementation of email notification.

Still lot of parts are missing, but this is a PR with most basic features.

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3lvdu42ftpn22
+1004 -68
Diff #6
+7 -1
appview/pages/markup/markdown.go
··· 45 45 Sanitizer Sanitizer 46 46 } 47 47 48 - func (rctx *RenderContext) RenderMarkdown(source string) string { 48 + func NewMarkdown() goldmark.Markdown { 49 49 md := goldmark.New( 50 50 goldmark.WithExtensions( 51 51 extension.GFM, ··· 59 59 extension.NewFootnote( 60 60 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 61 ), 62 + AtExt, 62 63 ), 63 64 goldmark.WithParserOptions( 64 65 parser.WithAutoHeadingID(), 65 66 ), 66 67 goldmark.WithRendererOptions(html.WithUnsafe()), 67 68 ) 69 + return md 70 + } 71 + 72 + func (rctx *RenderContext) RenderMarkdown(source string) string { 73 + md := NewMarkdown() 68 74 69 75 if rctx != nil { 70 76 var transformers []util.PrioritizedValue
+134
appview/pages/markup/markdown_at_extension.go
··· 1 + // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 + 3 + package markup 4 + 5 + import ( 6 + "regexp" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/renderer/html" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // An AtNode struct represents an AtNode 18 + type AtNode struct { 19 + handle string 20 + ast.BaseInline 21 + } 22 + 23 + var _ ast.Node = &AtNode{} 24 + 25 + // Dump implements Node.Dump. 26 + func (n *AtNode) Dump(source []byte, level int) { 27 + ast.DumpHelper(n, source, level, nil, nil) 28 + } 29 + 30 + // KindAt is a NodeKind of the At node. 31 + var KindAt = ast.NewNodeKind("At") 32 + 33 + // Kind implements Node.Kind. 34 + func (n *AtNode) Kind() ast.NodeKind { 35 + return KindAt 36 + } 37 + 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 + 40 + type atParser struct{} 41 + 42 + // NewAtParser return a new InlineParser that parses 43 + // at expressions. 44 + func NewAtParser() parser.InlineParser { 45 + return &atParser{} 46 + } 47 + 48 + func (s *atParser) Trigger() []byte { 49 + return []byte{'@'} 50 + } 51 + 52 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 53 + line, segment := block.PeekLine() 54 + m := atRegexp.FindSubmatchIndex(line) 55 + if m == nil { 56 + return nil 57 + } 58 + block.Advance(m[1]) 59 + node := &AtNode{} 60 + node.AppendChild(node, ast.NewTextSegment(text.NewSegment(segment.Start, segment.Start+m[1]))) 61 + node.handle = string(node.Text(block.Source())[1:]) 62 + return node 63 + } 64 + 65 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 66 + // renders At nodes. 67 + type atHtmlRenderer struct { 68 + html.Config 69 + } 70 + 71 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 72 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 73 + r := &atHtmlRenderer{ 74 + Config: html.NewConfig(), 75 + } 76 + for _, opt := range opts { 77 + opt.SetHTMLOption(&r.Config) 78 + } 79 + return r 80 + } 81 + 82 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 83 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 84 + reg.Register(KindAt, r.renderAt) 85 + } 86 + 87 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 88 + if entering { 89 + w.WriteString(`<a href="/@`) 90 + w.WriteString(n.(*AtNode).handle) 91 + w.WriteString(`" class="text-red-500">`) 92 + } else { 93 + w.WriteString("</a>") 94 + } 95 + return ast.WalkContinue, nil 96 + } 97 + 98 + type atExt struct{} 99 + 100 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 101 + var AtExt = &atExt{} 102 + 103 + func (e *atExt) Extend(m goldmark.Markdown) { 104 + m.Parser().AddOptions(parser.WithInlineParsers( 105 + util.Prioritized(NewAtParser(), 500), 106 + )) 107 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 108 + util.Prioritized(NewAtHTMLRenderer(), 500), 109 + )) 110 + } 111 + 112 + // FindUserMentions returns Set of user handles from given markup soruce. 113 + // It doesn't guarntee unique DIDs 114 + func FindUserMentions(source string) []string { 115 + var ( 116 + mentions []string 117 + mentionsSet = make(map[string]struct{}) 118 + md = NewMarkdown() 119 + sourceBytes = []byte(source) 120 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 121 + ) 122 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 123 + if entering && n.Kind() == KindAt { 124 + handle := n.(*AtNode).handle 125 + mentionsSet[handle] = struct{}{} 126 + return ast.WalkSkipChildren, nil 127 + } 128 + return ast.WalkContinue, nil 129 + }) 130 + for handle := range mentionsSet { 131 + mentions = append(mentions, handle) 132 + } 133 + return mentions 134 + }
+248 -12
appview/db/issues.go
··· 94 94 return nil 95 95 } 96 96 97 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 98 - var issueAt string 99 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 100 - return issueAt, err 101 - } 102 - 103 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 104 - var ownerDid string 105 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 106 - return ownerDid, err 107 - } 108 - 109 97 func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 110 98 var issues []Issue 111 99 openValue := 0 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + var issue Issue 343 + var createdAt string 344 + issue.IssueId = issueId 345 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 346 + if err != nil { 347 + return nil, err
+18 -16
appview/issues/issues.go
··· 260 260 return 261 261 } 262 262 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 265 - 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 263 + comment := &db.Comment{ 267 264 OwnerDid: user.Did, 268 265 RepoAt: f.RepoAt(), 269 266 Issue: issueIdInt, 270 - CommentId: commentId, 267 + CommentId: mathrand.IntN(1000000), 271 268 Body: body, 272 - Rkey: rkey, 273 - }) 269 + Rkey: tid.TID(), 270 + } 271 + 272 + err := db.NewIssueComment(rp.db, comment) 274 273 if err != nil { 275 274 log.Println("failed to create comment", err) 276 275 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 278 277 } 279 278 280 279 createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 - ownerDid := user.Did 283 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 280 + commentIdInt64 := int64(comment.CommentId) 281 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 284 282 if err != nil { 285 - log.Println("failed to get issue at", err) 283 + log.Println("failed to get issue", err) 286 284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 287 285 return 288 286 } 289 287 290 - atUri := f.RepoAt().String() 288 + atUri := comment.RepoAt.String() 291 289 client, err := rp.oauth.AuthorizedClient(r) 292 290 if err != nil { 293 291 log.Println("failed to get authorized client", err) ··· 297 295 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 298 296 Collection: tangled.RepoIssueCommentNSID, 299 297 Repo: user.Did, 300 - Rkey: rkey, 298 + Rkey: comment.Rkey, 301 299 Record: &lexutil.LexiconTypeDecoder{ 302 300 Val: &tangled.RepoIssueComment{ 303 301 Repo: &atUri, 304 - Issue: issueAt, 302 + Issue: issue.AtUri().String(), 305 303 CommentId: &commentIdInt64, 306 - Owner: &ownerDid, 304 + Owner: &comment.OwnerDid, 307 305 Body: body, 308 306 CreatedAt: createdAt, 309 307 }, ··· 315 313 return 316 314 } 317 315 318 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 316 + mentions := markup.FindUserMentions(comment.Body) 317 + 318 + rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions) 319 + 320 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId)) 319 321 return 320 322 } 321 323 }
+25
appview/notify/merged_notifier.go
··· 39 39 } 40 40 } 41 41 42 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 43 + for _, notifier := range m.notifiers { 44 + notifier.NewIssueComment(ctx, repo, issue, comment, mentions) 45 + } 46 + } 47 + 42 48 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 43 49 for _, notifier := range m.notifiers { 44 50 notifier.NewFollow(ctx, follow) 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + notifier.NewPull(ctx, pull) 62 + } 63 + } 64 + func (m *mergedNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 65 + for _, notifier := range m.notifiers { 66 + notifier.NewPullComment(ctx, repo, pull, comment, mentions) 67 + } 68 + } 69 +
+11 -4
appview/notify/notifier.go
··· 13 13 DeleteStar(ctx context.Context, star *db.Star) 14 14 15 15 NewIssue(ctx context.Context, issue *db.Issue) 16 + NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) 16 17 17 18 NewFollow(ctx context.Context, follow *db.Follow) 18 19 DeleteFollow(ctx context.Context, follow *db.Follow) 19 20 21 + NewPull(ctx context.Context, pull *db.Pull) 22 + NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) 20 23 21 - 22 - 23 - 24 - 24 + UpdateProfile(ctx context.Context, profile *db.Profile) 25 + } 25 26 26 27 27 28 ··· 34 35 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 35 36 36 37 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 38 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) {} 37 39 38 40 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 39 41 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 42 + 43 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 44 + func (m *BaseNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) {} 45 + 46 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+29
appview/posthog/notifier.go
··· 70 70 } 71 71 } 72 72 73 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: comment.OwnerDid, 76 + Event: "new_issue", 77 + Properties: posthog.Properties{ 78 + "repo_at": comment.RepoAt.String(), 79 + "issue_id": comment.Issue, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 73 87 func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 88 err := n.client.Enqueue(posthog.Capture{ 75 89 DistinctId: pull.OwnerDid, 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: comment.OwnerDid, 104 + Event: "new_pull_comment",
+3 -8
appview/email/email.go
··· 1 1 package email 2 2 3 3 import ( 4 - "fmt" 5 4 "net" 6 5 "regexp" 7 6 "strings" ··· 11 10 12 11 type Email struct { 13 12 From string 14 - To string 15 13 Subject string 16 14 Text string 17 15 Html string 18 16 APIKey string 19 17 } 20 18 21 - func SendEmail(email Email) error { 19 + func SendEmail(email Email, recipients ...string) error { 22 20 client := resend.NewClient(email.APIKey) 23 21 _, err := client.Emails.Send(&resend.SendEmailRequest{ 24 22 From: email.From, 25 - To: []string{email.To}, 23 + To: recipients, 26 24 Subject: email.Subject, 27 25 Text: email.Text, 28 26 Html: email.Html, 29 27 }) 30 - if err != nil { 31 - return fmt.Errorf("error sending email: %w", err) 32 - } 33 - return nil 28 + return err 34 29 } 35 30 36 31 func IsValidEmail(email string) bool {
+285 -13
appview/settings/settings.go
··· 55 55 56 56 57 57 58 + r.Delete("/", s.keys) 59 + }) 60 + 61 + r.Post("/email/preference", s.emailPreference) 62 + 63 + r.Route("/emails", func(r chi.Router) { 64 + r.Get("/", s.emailsSettings) 65 + r.Put("/", s.emails) 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 101 + user := s.OAuth.GetUser(r) 102 + preference, err := db.GetUserEmailPreference(s.Db, user.Did) 103 + 104 + emails, err := db.GetAllEmails(s.Db, user.Did) 105 + if err != nil { 106 + log.Println(err) 107 + } 108 + 109 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 110 + LoggedInUser: user, 111 + Emails: emails, 112 + NotifPreference: preference, 113 + Tabs: settingsTabs, 114 + Tab: "emails", 115 + }) 116 + } 117 + 118 + 119 + 120 + 121 + 122 + return email.Email{ 123 + APIKey: s.Config.Resend.ApiKey, 124 + From: s.Config.Resend.SentFrom, 125 + Subject: "Verify your Tangled email", 126 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 127 + ` + verifyURL, 128 + 129 + 130 + 131 + 132 + 133 + 134 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 135 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 136 + 137 + err := email.SendEmail(emailToSend, emailAddr) 138 + if err != nil { 139 + log.Printf("failed to send email: %s", err) 140 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 141 + return err 142 + } 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 58 196 59 197 60 198 ··· 114 252 115 253 116 254 117 - return email.Email{ 118 - APIKey: s.Config.Resend.ApiKey, 119 - From: s.Config.Resend.SentFrom, 120 - To: emailAddr, 121 - Subject: "Verify your Tangled email", 122 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 123 - ` + verifyURL, 124 255 125 256 126 257 127 258 128 259 129 260 130 - func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 131 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 132 261 133 - err := email.SendEmail(emailToSend) 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + 378 + 379 + 380 + 381 + s.Pages.HxLocation(w, "/settings/emails") 382 + } 383 + 384 + func (s *Settings) emailPreference(w http.ResponseWriter, r *http.Request) { 385 + did := s.OAuth.GetDid(r) 386 + preferenceValue := r.FormValue("preference") 387 + var preference db.EmailPreference 388 + switch preferenceValue { 389 + case "enable": 390 + preference = db.EmailNotifEnabled 391 + case "mention": 392 + preference = db.EmailNotifMention 393 + case "disable": 394 + preference = db.EmailNotifDisabled 395 + default: 396 + log.Printf("Incorrect email preference value") 397 + return 398 + } 399 + 400 + err := db.UpdateSettingsEmailPreference(s.Db, did, preference) 134 401 if err != nil { 135 - log.Printf("sending email: %s", err) 136 - s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 137 - return err 402 + log.Printf("failed to update email preference setting: %v", err) 403 + s.Pages.Notice(w, "settings-keys", "Failed to update email preference. Try again later.") 404 + return 138 405 } 406 + } 407 + 408 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 409 + switch r.Method { 410 + case http.MethodGet:
+1 -2
appview/signup/signup.go
··· 149 149 em := email.Email{ 150 150 APIKey: s.config.Resend.ApiKey, 151 151 From: s.config.Resend.SentFrom, 152 - To: emailId, 153 152 Subject: "Verify your Tangled account", 154 153 Text: `Copy and paste this code below to verify your account on Tangled. 155 154 ` + code, ··· 157 156 <p><code>` + code + `</code></p>`, 158 157 } 159 158 160 - err = email.SendEmail(em) 159 + err = email.SendEmail(em, emailId) 161 160 if err != nil { 162 161 s.l.Error("failed to send email", "error", err) 163 162 s.pages.Notice(w, noticeId, "Failed to send email.")
+141
appview/email/notifier.go
··· 1 + package email 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + 8 + securejoin "github.com/cyphar/filepath-securejoin" 9 + "tangled.sh/tangled.sh/core/appview/config" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/notify" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + ) 14 + 15 + type EmailNotifier struct { 16 + db *db.DB 17 + idResolver *idresolver.Resolver 18 + Config *config.Config 19 + notify.BaseNotifier 20 + } 21 + 22 + func NewEmailNotifier( 23 + db *db.DB, 24 + idResolver *idresolver.Resolver, 25 + config *config.Config, 26 + ) notify.Notifier { 27 + return &EmailNotifier{ 28 + db, 29 + idResolver, 30 + config, 31 + notify.BaseNotifier{}, 32 + } 33 + } 34 + 35 + var _ notify.Notifier = &EmailNotifier{} 36 + 37 + // TODO: yeah this is just bad design. should be moved under idResolver ore include repoResolver at first place 38 + func (n *EmailNotifier) repoOwnerSlashName(ctx context.Context, repo *db.Repo) (string, error) { 39 + repoOwnerID, err := n.idResolver.ResolveIdent(ctx, repo.Did) 40 + if err != nil || repoOwnerID.Handle.IsInvalidHandle() { 41 + return "", fmt.Errorf("resolve comment owner did: %w", err) 42 + } 43 + repoOwnerHandle := repoOwnerID.Handle 44 + var repoOwnerSlashName string 45 + if repoOwnerHandle != "" && !repoOwnerHandle.IsInvalidHandle() { 46 + repoOwnerSlashName, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", repoOwnerHandle), repo.Name) 47 + } else { 48 + repoOwnerSlashName = repo.DidSlashRepo() 49 + } 50 + return repoOwnerSlashName, nil 51 + } 52 + 53 + func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment) (Email, error) { 54 + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 55 + if err != nil || commentOwner.Handle.IsInvalidHandle() { 56 + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 57 + } 58 + baseUrl := n.Config.Core.AppviewHost 59 + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 60 + if err != nil { 61 + return Email{}, nil 62 + } 63 + url := fmt.Sprintf("%s/%s/issues/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.Issue, comment.CommentId) 64 + return Email{ 65 + APIKey: n.Config.Resend.ApiKey, 66 + From: n.Config.Resend.SentFrom, 67 + Subject: fmt.Sprintf("[%s] %s (issue#%d)", repoOwnerSlashName, issue.Title, issue.IssueId), 68 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 69 + }, nil 70 + } 71 + 72 + func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) { 73 + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 74 + if err != nil || commentOwner.Handle.IsInvalidHandle() { 75 + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 76 + } 77 + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 78 + if err != nil { 79 + return Email{}, nil 80 + } 81 + baseUrl := n.Config.Core.AppviewHost 82 + url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID) 83 + return Email{ 84 + APIKey: n.Config.Resend.ApiKey, 85 + From: n.Config.Resend.SentFrom, 86 + Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId), 87 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 88 + }, nil 89 + } 90 + 91 + func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { 92 + recipients := []string{} 93 + for _, handle := range handles { 94 + id, err := n.idResolver.ResolveIdent(ctx, handle) 95 + if err != nil { 96 + log.Println("failed to resolve handle:", err) 97 + continue 98 + } 99 + emailPreference, err := db.GetUserEmailPreference(n.db, id.DID.String()) 100 + if err != nil { 101 + log.Println("failed to get user email preference:", err) 102 + continue 103 + } 104 + if emailPreference == db.EmailNotifDisabled { 105 + continue 106 + } 107 + email, err := db.GetPrimaryEmail(n.db, id.DID.String()) 108 + if err != nil { 109 + log.Println("failed to get primary email:", err) 110 + continue 111 + } 112 + recipients = append(recipients, email.Address) 113 + } 114 + return recipients 115 + } 116 + 117 + func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 118 + email, err := n.buildIssueEmail(ctx, repo, issue, comment) 119 + if err != nil { 120 + log.Println("failed to create issue-email:", err) 121 + return 122 + } 123 + // TODO: get issue-subscribed user DIDs and merge with mentioned users 124 + recipients := n.gatherRecipientEmails(ctx, mentions) 125 + log.Println("sending email to:", recipients) 126 + if err = SendEmail(email, recipients...); err != nil { 127 + log.Println("error sending email:", err) 128 + } 129 + } 130 + 131 + func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 132 + email, err := n.buildPullEmail(ctx, repo, pull, comment) 133 + if err != nil { 134 + log.Println("failed to create pull-email:", err) 135 + } 136 + recipients := n.gatherRecipientEmails(ctx, mentions) 137 + log.Println("sending email to:", recipients) 138 + if err = SendEmail(email); err != nil { 139 + log.Println("error sending email:", err) 140 + } 141 + }
+2
appview/state/state.go
··· 23 23 "tangled.sh/tangled.sh/core/appview/cache/session" 24 24 "tangled.sh/tangled.sh/core/appview/config" 25 25 "tangled.sh/tangled.sh/core/appview/db" 26 + "tangled.sh/tangled.sh/core/appview/email" 26 27 "tangled.sh/tangled.sh/core/appview/notify" 27 28 "tangled.sh/tangled.sh/core/appview/oauth" 28 29 "tangled.sh/tangled.sh/core/appview/pages" ··· 138 139 spindlestream.Start(ctx) 139 140 140 141 var notifiers []notify.Notifier 142 + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) 141 143 if !config.Core.Dev { 142 144 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 143 145 }
+7
appview/db/db.go
··· 703 703 return err 704 704 }) 705 705 706 + runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error { 707 + _, err := tx.Exec(` 708 + alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable 709 + `) 710 + return err 711 + }) 712 + 706 713 return &DB{db}, nil 707 714 } 708 715
+22
appview/db/email.go
··· 299 299 _, err := e.Exec(query, code, did, email) 300 300 return err 301 301 } 302 + 303 + func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) { 304 + var preference EmailPreference 305 + err := e.QueryRow(` 306 + select email_notif_preference 307 + from profile 308 + where did = ? 309 + `, did).Scan(&preference) 310 + if err != nil { 311 + return preference, err 312 + } 313 + return preference, nil 314 + } 315 + 316 + func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error { 317 + _, err := e.Exec(` 318 + update profile 319 + set email_notif_preference = ? 320 + where did = ? 321 + `, preference, did) 322 + return err 323 + }
+32 -6
appview/db/profile.go
··· 183 183 Links [5]string 184 184 Stats [2]VanityStat 185 185 PinnedRepos [6]syntax.ATURI 186 + 187 + // settings 188 + EmailNotifPreference EmailPreference 189 + } 190 + 191 + type EmailPreference int 192 + 193 + const ( 194 + EmailNotifDisabled EmailPreference = iota 195 + EmailNotifMention 196 + EmailNotifEnabled 197 + ) 198 + 199 + func (p EmailPreference) IsDisabled() bool { 200 + return p == EmailNotifDisabled 201 + } 202 + 203 + func (p EmailPreference) IsMention() bool { 204 + return p == EmailNotifMention 205 + } 206 + 207 + func (p EmailPreference) IsEnabled() bool { 208 + return p == EmailNotifEnabled 186 209 } 187 210 188 211 func (p Profile) IsLinksEmpty() bool { ··· 280 303 did, 281 304 description, 282 305 include_bluesky, 283 - location 306 + location, 307 + email_notif_preference 284 308 ) 285 - values (?, ?, ?, ?)`, 309 + values (?, ?, ?, ?, ?)`, 286 310 profile.Did, 287 311 profile.Description, 288 312 includeBskyValue, 289 313 profile.Location, 314 + profile.EmailNotifPreference, 290 315 ) 291 316 292 317 if err != nil { ··· 367 392 did, 368 393 description, 369 394 include_bluesky, 370 - location 395 + location, 396 + email_notif_preference 371 397 from 372 398 profile 373 399 %s`, ··· 383 409 var profile Profile 384 410 var includeBluesky int 385 411 386 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 412 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 387 413 if err != nil { 388 414 return nil, err 389 415 } ··· 457 483 458 484 includeBluesky := 0 459 485 err := e.QueryRow( 460 - `select description, include_bluesky, location from profile where did = ?`, 486 + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, 461 487 did, 462 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 488 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 463 489 if err == sql.ErrNoRows { 464 490 profile := Profile{} 465 491 profile.Did = did
+5 -4
appview/pages/pages.go
··· 328 328 } 329 329 330 330 type UserEmailsSettingsParams struct { 331 - LoggedInUser *oauth.User 332 - Emails []db.Email 333 - Tabs []map[string]any 334 - Tab string 331 + LoggedInUser *oauth.User 332 + Emails []db.Email 333 + NotifPreference db.EmailPreference 334 + Tabs []map[string]any 335 + Tab string 335 336 } 336 337 337 338 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
+31 -1
appview/pages/templates/user/settings/emails.html
··· 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 13 {{ template "emailSettings" . }} 14 + {{ template "emailList" . }} 14 15 </div> 15 16 </section> 16 17 </div> 17 18 {{ end }} 18 19 19 20 {{ define "emailSettings" }} 21 + <form 22 + hx-post="/settings/email/preference" 23 + hx-swap="none" 24 + hx-indicator="#email-preference-spinner" 25 + class="grid grid-cols-1 md:grid-cols-3 gap-4" 26 + > 27 + <div class="col-span-1 md:col-span-2"> 28 + <h2 class="text-sm pb-2 uppercase font-bold">Email Notifications</h2> 29 + </div> 30 + <select 31 + name="preference" 32 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 33 + > 34 + <option value="enable" {{ if .NotifPreference.IsEnabled }}selected{{ end }}>Enable</option> 35 + <option value="mention" {{ if .NotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option> 36 + <option value="disable" {{ if .NotifPreference.IsDisabled }}selected{{ end }}>Disable</option> 37 + </select> 38 + <div class="md:col-start-2 col-span-2 flex justify-end"> 39 + <button type="submit" class="btn text-base"> 40 + <span>Save Preference</span> 41 + <span id="email-preference-spinner" class="group"> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </span> 44 + </button> 45 + </div> 46 + </form> 47 + {{ end }} 48 + 49 + {{ define "emailList" }} 20 50 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 51 <div class="col-span-1 md:col-span-2"> 22 52 <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> ··· 91 121 <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 122 <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 123 </form> 94 - {{ end }} 124 + {{ end }}
+3 -1
appview/pulls/pulls.go
··· 665 665 return 666 666 } 667 667 668 - s.notifier.NewPullComment(r.Context(), comment) 668 + mentions := markup.FindUserMentions(comment.Body) 669 + 670 + s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions) 669 671 670 672 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 671 673 return

History

7 rounds 4 comments
sign up or login to add to the discussion
7 commits
expand
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: db/issues: set IssueId on GetIssue
appview: email: support multiple recipients on SendEmail
appview: email: send email notification on mention
appview: settings: add email preference setting
appview: email: notify mentioned users on pull-comments
expand 1 comment
closed without merging
7 commits
expand
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: db/issues: set IssueId on GetIssue
appview: email: support multiple recipients on SendEmail
appview: email: send email notification on mention
appview: settings: add email preference setting
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
6 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 1 comment

@oppi.li I addressed your last two points. Thank you again for the detailed review.

for regex part, I'm not sure if we can share part of regex and modify by our needs.

7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: add source to email notification
appview: email: notify mentioned users on pull-comments
expand 2 comments

i'd prefer if this was stacked! it would make it slightly easier to review:

  • in 1b6628ab: can we use the handle regex from the userutil module and customize it a bit for atRegex?
  • 18b248e5 and 9eebc110 seem to be modifying the same file, it would be nice to merge the two changes (running jj absorb when editing the second change should automatically solve it!).
  • similarly, dfa71348 seems to update the logic for issue-comment emails. would be nice to collapse or clean up the diffs.

this was only a first pass of reviews, but ill go through and have a closer look at the logic in the code, and give this a go locally. thanks again for working on this, its looking pretty cool already!

@oppi.li Thank you for the review!

and yeah I agree that I should have made this PR stacked.

Tell me if you think complete resubmission in form of stacked PR will be better.