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
+992 -66
Diff #5
+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
··· 42 42 43 43 44 44 45 + r.Delete("/", s.keys) 46 + }) 47 + 48 + r.Post("/email/preference", s.emailPreference) 49 + 50 + r.Route("/emails", func(r chi.Router) { 51 + r.Put("/", s.emails) 52 + r.Delete("/", s.emails) 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + log.Println(err) 66 + } 67 + 68 + preference, err := db.GetUserEmailPreference(s.Db, user.Did) 69 + 70 + emails, err := db.GetAllEmails(s.Db, user.Did) 71 + if err != nil { 72 + log.Println(err) 73 + } 74 + 75 + s.Pages.Settings(w, pages.SettingsParams{ 76 + LoggedInUser: user, 77 + PubKeys: pubKeys, 78 + Emails: emails, 79 + EmailNotifPreference: preference, 80 + }) 81 + } 82 + 83 + 84 + 85 + 86 + 87 + return email.Email{ 88 + APIKey: s.Config.Resend.ApiKey, 89 + From: s.Config.Resend.SentFrom, 90 + Subject: "Verify your Tangled email", 91 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 92 + ` + verifyURL, 93 + 94 + 95 + 96 + 97 + 98 + 99 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 100 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 101 + 102 + err := email.SendEmail(emailToSend, emailAddr) 103 + if err != nil { 104 + log.Printf("failed to send email: %s", err) 105 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 106 + return err 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 + 45 182 46 183 47 184 ··· 79 216 80 217 81 218 82 - return email.Email{ 83 - APIKey: s.Config.Resend.ApiKey, 84 - From: s.Config.Resend.SentFrom, 85 - To: emailAddr, 86 - Subject: "Verify your Tangled email", 87 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 88 - ` + verifyURL, 89 219 90 220 91 221 92 222 93 223 94 224 95 - func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 96 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 97 225 98 - err := email.SendEmail(emailToSend) 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 + 343 + 344 + 345 + 346 + s.Pages.HxLocation(w, "/settings") 347 + } 348 + 349 + func (s *Settings) emailPreference(w http.ResponseWriter, r *http.Request) { 350 + did := s.OAuth.GetDid(r) 351 + preferenceValue := r.FormValue("preference") 352 + var preference db.EmailPreference 353 + switch preferenceValue { 354 + case "enable": 355 + preference = db.EmailNotifEnabled 356 + case "mention": 357 + preference = db.EmailNotifMention 358 + case "disable": 359 + preference = db.EmailNotifDisabled 360 + default: 361 + log.Printf("Incorrect email preference value") 362 + return 363 + } 364 + 365 + err := db.UpdateSettingsEmailPreference(s.Db, did, preference) 99 366 if err != nil { 100 - log.Printf("sending email: %s", err) 101 - s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 102 - return err 367 + log.Printf("failed to update email preference setting: %v", err) 368 + s.Pages.Notice(w, "settings-keys", "Failed to update email preference. Try again later.") 369 + return 103 370 } 371 + } 372 + 373 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 374 + switch r.Method { 375 + 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
··· 20 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 21 "tangled.sh/tangled.sh/core/appview/config" 22 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/email" 23 24 "tangled.sh/tangled.sh/core/appview/notify" 24 25 "tangled.sh/tangled.sh/core/appview/oauth" 25 26 "tangled.sh/tangled.sh/core/appview/pages" ··· 133 134 spindlestream.Start(ctx) 134 135 135 136 var notifiers []notify.Notifier 137 + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) 136 138 if !config.Core.Dev { 137 139 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 138 140 }
+7
appview/db/db.go
··· 678 678 return err 679 679 }) 680 680 681 + runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error { 682 + _, err := tx.Exec(` 683 + alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable 684 + `) 685 + return err 686 + }) 687 + 681 688 return &DB{db}, nil 682 689 } 683 690
+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 } ··· 462 488 463 489 includeBluesky := 0 464 490 err := e.QueryRow( 465 - `select description, include_bluesky, location from profile where did = ?`, 491 + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, 466 492 did, 467 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 493 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 468 494 if err == sql.ErrNoRows { 469 495 profile := Profile{} 470 496 profile.Did = did
+4 -3
appview/pages/pages.go
··· 307 307 } 308 308 309 309 type SettingsParams struct { 310 - LoggedInUser *oauth.User 311 - PubKeys []db.PublicKey 312 - Emails []db.Email 310 + LoggedInUser *oauth.User 311 + PubKeys []db.PublicKey 312 + Emails []db.Email 313 + EmailNotifPreference db.EmailPreference 313 314 } 314 315 315 316 func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
+20
appview/pages/templates/settings.html
··· 93 93 {{ define "emails" }} 94 94 <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 95 <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 + <form 97 + hx-post="/settings/email/preference" 98 + hx-swap="none" 99 + hx-indicator="#email-preference-spinner" 100 + > 101 + <select 102 + name="preference" 103 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 104 + > 105 + <option value="enable" {{ if .EmailNotifPreference.IsEnabled }}selected{{ end }}>Enable Email Notifications</option> 106 + <option value="mention" {{ if .EmailNotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option> 107 + <option value="disable" {{ if .EmailNotifPreference.IsDisabled }}selected{{ end }}>Disable Email Notifications</option> 108 + </select> 109 + <button type="submit" class="btn text-base"> 110 + <span>Save Preference</span> 111 + <span id="email-preference-spinner" class="group"> 112 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 + </span> 114 + </button> 115 + </form> 96 116 <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 117 <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 118 {{ range $index, $email := .Emails }}
+3 -1
appview/pulls/pulls.go
··· 668 668 return 669 669 } 670 670 671 - s.notifier.NewPullComment(r.Context(), comment) 671 + mentions := markup.FindUserMentions(comment.Body) 672 + 673 + s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions) 672 674 673 675 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 674 676 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.