From 639df66dff3ecf90e273fc026fed63c4eb85ead5 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 25 Jul 2025 00:27:46 +0900 Subject: [PATCH] appview/pages: markup: add `@` user-mention parsing in markdown Change-Id: ulyruprmrqxnopnnuuovqqzuzonqrpyl Signed-off-by: Seongmin Lee --- appview/pages/markup/markdown.go | 8 +- appview/pages/markup/markdown_at_extension.go | 134 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 appview/pages/markup/markdown_at_extension.go diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index 15d02da..25b91e0 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -45,7 +45,7 @@ type RenderContext struct { Sanitizer Sanitizer } -func (rctx *RenderContext) RenderMarkdown(source string) string { +func NewMarkdown() goldmark.Markdown { md := goldmark.New( goldmark.WithExtensions( extension.GFM, @@ -59,12 +59,18 @@ func (rctx *RenderContext) RenderMarkdown(source string) string { extension.NewFootnote( extension.WithFootnoteIDPrefix([]byte("footnote")), ), + AtExt, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions(html.WithUnsafe()), ) + return md +} + +func (rctx *RenderContext) RenderMarkdown(source string) string { + md := NewMarkdown() if rctx != nil { var transformers []util.PrioritizedValue diff --git a/appview/pages/markup/markdown_at_extension.go b/appview/pages/markup/markdown_at_extension.go new file mode 100644 index 0000000..1bd63e8 --- /dev/null +++ b/appview/pages/markup/markdown_at_extension.go @@ -0,0 +1,134 @@ +// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions + +package markup + +import ( + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// An AtNode struct represents an AtNode +type AtNode struct { + handle string + ast.BaseInline +} + +var _ ast.Node = &AtNode{} + +// Dump implements Node.Dump. +func (n *AtNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// KindAt is a NodeKind of the At node. +var KindAt = ast.NewNodeKind("At") + +// Kind implements Node.Kind. +func (n *AtNode) Kind() ast.NodeKind { + return KindAt +} + +var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) + +type atParser struct{} + +// NewAtParser return a new InlineParser that parses +// at expressions. +func NewAtParser() parser.InlineParser { + return &atParser{} +} + +func (s *atParser) Trigger() []byte { + return []byte{'@'} +} + +func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, segment := block.PeekLine() + m := atRegexp.FindSubmatchIndex(line) + if m == nil { + return nil + } + block.Advance(m[1]) + node := &AtNode{} + node.AppendChild(node, ast.NewTextSegment(text.NewSegment(segment.Start, segment.Start+m[1]))) + node.handle = string(node.Text(block.Source())[1:]) + return node +} + +// atHtmlRenderer is a renderer.NodeRenderer implementation that +// renders At nodes. +type atHtmlRenderer struct { + html.Config +} + +// NewAtHTMLRenderer returns a new AtHTMLRenderer. +func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &atHtmlRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindAt, r.renderAt) +} + +func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + w.WriteString(``) + } else { + w.WriteString("") + } + return ast.WalkContinue, nil +} + +type atExt struct{} + +// At is an extension that allow you to use at expression like '@user.bsky.social' . +var AtExt = &atExt{} + +func (e *atExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewAtParser(), 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewAtHTMLRenderer(), 500), + )) +} + +// FindUserMentions returns Set of user handles from given markup soruce. +// It doesn't guarntee unique DIDs +func FindUserMentions(source string) []string { + var ( + mentions []string + mentionsSet = make(map[string]struct{}) + md = NewMarkdown() + sourceBytes = []byte(source) + root = md.Parser().Parse(text.NewReader(sourceBytes)) + ) + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Kind() == KindAt { + handle := n.(*AtNode).handle + mentionsSet[handle] = struct{}{} + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) + for handle := range mentionsSet { + mentions = append(mentions, handle) + } + return mentions +} -- 2.43.0 From 79fbdb0ca77cd1bb973ff9d796f5b5324ea13e55 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sat, 26 Jul 2025 10:59:02 +0900 Subject: [PATCH] appview: add `NewIssueComment` event Change-Id: knkwrvolorspoxoqttxurpplqqxvoyxo Signed-off-by: Seongmin Lee --- appview/db/issues.go | 12 ----------- appview/issues/issues.go | 34 ++++++++++++++++--------------- appview/notify/merged_notifier.go | 6 ++++++ appview/notify/notifier.go | 2 ++ appview/posthog/notifier.go | 14 +++++++++++++ 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/appview/db/issues.go b/appview/db/issues.go index 3525fc4..8a41791 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -94,18 +94,6 @@ func NewIssue(tx *sql.Tx, issue *Issue) error { return nil } -func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { - var issueAt string - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) - return issueAt, err -} - -func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { - var ownerDid string - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) - return ownerDid, err -} - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { var issues []Issue openValue := 0 diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 2b6a6a3..76288da 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -260,17 +260,16 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { return } - commentId := mathrand.IntN(1000000) - rkey := tid.TID() - - err := db.NewIssueComment(rp.db, &db.Comment{ + comment := &db.Comment{ OwnerDid: user.Did, RepoAt: f.RepoAt(), Issue: issueIdInt, - CommentId: commentId, + CommentId: mathrand.IntN(1000000), Body: body, - Rkey: rkey, - }) + Rkey: tid.TID(), + } + + err := db.NewIssueComment(rp.db, comment) if err != nil { log.Println("failed to create comment", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") @@ -278,16 +277,15 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { } createdAt := time.Now().Format(time.RFC3339) - commentIdInt64 := int64(commentId) - ownerDid := user.Did - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) + commentIdInt64 := int64(comment.CommentId) + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) if err != nil { - log.Println("failed to get issue at", err) + log.Println("failed to get issue", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } - atUri := f.RepoAt().String() + atUri := comment.RepoAt.String() client, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) @@ -297,13 +295,13 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, - Rkey: rkey, + Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssueComment{ Repo: &atUri, - Issue: issueAt, + Issue: issue.AtUri().String(), CommentId: &commentIdInt64, - Owner: &ownerDid, + Owner: &comment.OwnerDid, Body: body, CreatedAt: createdAt, }, @@ -315,7 +313,11 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { return } - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) + mentions := markup.FindUserMentions(comment.Body) + + rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions) + + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId)) return } } diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index faf0a11..b08a0e0 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -39,6 +39,12 @@ func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { } } +func (m *mergedNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { + for _, notifier := range m.notifiers { + notifier.NewIssueComment(ctx, repo, issue, comment, mentions) + } +} + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { for _, notifier := range m.notifiers { notifier.NewFollow(ctx, follow) diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 89f8121..01a265c 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -13,6 +13,7 @@ type Notifier interface { DeleteStar(ctx context.Context, star *db.Star) NewIssue(ctx context.Context, issue *db.Issue) + NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) NewFollow(ctx context.Context, follow *db.Follow) DeleteFollow(ctx context.Context, follow *db.Follow) @@ -34,6 +35,7 @@ func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} +func (m *BaseNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) {} func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} diff --git a/appview/posthog/notifier.go b/appview/posthog/notifier.go index 8dbd198..298524f 100644 --- a/appview/posthog/notifier.go +++ b/appview/posthog/notifier.go @@ -70,6 +70,20 @@ func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { } } +func (n *posthogNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { + err := n.client.Enqueue(posthog.Capture{ + DistinctId: comment.OwnerDid, + Event: "new_issue", + Properties: posthog.Properties{ + "repo_at": comment.RepoAt.String(), + "issue_id": comment.Issue, + }, + }) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } +} + func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { err := n.client.Enqueue(posthog.Capture{ DistinctId: pull.OwnerDid, -- 2.43.0 From d82b5d263e33c5584675f875e5513fe6a730ee41 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sat, 23 Aug 2025 19:43:13 +0900 Subject: [PATCH] appview: db/issues: set `IssueId` on `GetIssue` Change-Id: lszvxrwyrpntsynsszqyxwlzptkvnzxz Signed-off-by: Seongmin Lee --- appview/db/issues.go | 1 + 1 file changed, 1 insertion(+) diff --git a/appview/db/issues.go b/appview/db/issues.go index 8a41791..2656240 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -341,6 +341,7 @@ func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { var issue Issue var createdAt string + issue.IssueId = issueId err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) if err != nil { return nil, err -- 2.43.0 From 0f21c1833a5824f1ab41ae8e0df35f27947d26d7 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sat, 23 Aug 2025 20:55:11 +0900 Subject: [PATCH] appview: email: support multiple recipients on `SendEmail` Change-Id: xruymuvtyqtsmpkzwslnovsywnlpsqwv also remove unnecessary error wrap Signed-off-by: Seongmin Lee --- appview/email/email.go | 11 +++-------- appview/settings/settings.go | 5 ++--- appview/signup/signup.go | 3 +-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/appview/email/email.go b/appview/email/email.go index 40f6920..46aa124 100644 --- a/appview/email/email.go +++ b/appview/email/email.go @@ -1,7 +1,6 @@ package email import ( - "fmt" "net" "regexp" "strings" @@ -11,26 +10,22 @@ import ( type Email struct { From string - To string Subject string Text string Html string APIKey string } -func SendEmail(email Email) error { +func SendEmail(email Email, recipients ...string) error { client := resend.NewClient(email.APIKey) _, err := client.Emails.Send(&resend.SendEmailRequest{ From: email.From, - To: []string{email.To}, + To: recipients, Subject: email.Subject, Text: email.Text, Html: email.Html, }) - if err != nil { - return fmt.Errorf("error sending email: %w", err) - } - return nil + return err } func IsValidEmail(email string) bool { diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 23a533e..a25017f 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -82,7 +82,6 @@ func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Ema return email.Email{ APIKey: s.Config.Resend.ApiKey, From: s.Config.Resend.SentFrom, - To: emailAddr, Subject: "Verify your Tangled email", Text: `Click the link below (or copy and paste it into your browser) to verify your email address. ` + verifyURL, @@ -95,9 +94,9 @@ func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Ema func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { emailToSend := s.buildVerificationEmail(emailAddr, did, code) - err := email.SendEmail(emailToSend) + err := email.SendEmail(emailToSend, emailAddr) if err != nil { - log.Printf("sending email: %s", err) + log.Printf("failed to send email: %s", err) s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) return err } diff --git a/appview/signup/signup.go b/appview/signup/signup.go index c720818..1d781aa 100644 --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -149,7 +149,6 @@ func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { em := email.Email{ APIKey: s.config.Resend.ApiKey, From: s.config.Resend.SentFrom, - To: emailId, Subject: "Verify your Tangled account", Text: `Copy and paste this code below to verify your account on Tangled. ` + code, @@ -157,7 +156,7 @@ func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {

` + code + `

`, } - err = email.SendEmail(em) + err = email.SendEmail(em, emailId) if err != nil { s.l.Error("failed to send email", "error", err) s.pages.Notice(w, noticeId, "Failed to send email.") -- 2.43.0 From 92817524150ed030f75bb34b8373b4f3ef774c8c Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 29 Jul 2025 00:27:16 +0900 Subject: [PATCH] appview: email: send email notification on mention Change-Id: vkmkzoplqkxytqyktuqxxnlkyqlxwwnp Signed-off-by: Seongmin Lee --- appview/email/notifier.go | 102 ++++++++++++++++++++++++++++++++++++++ appview/state/state.go | 2 + 2 files changed, 104 insertions(+) create mode 100644 appview/email/notifier.go diff --git a/appview/email/notifier.go b/appview/email/notifier.go new file mode 100644 index 0000000..5d31296 --- /dev/null +++ b/appview/email/notifier.go @@ -0,0 +1,102 @@ +package email + +import ( + "context" + "fmt" + "log" + + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/appview/config" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/notify" + "tangled.sh/tangled.sh/core/idresolver" +) + +type EmailNotifier struct { + db *db.DB + idResolver *idresolver.Resolver + Config *config.Config + notify.BaseNotifier +} + +func NewEmailNotifier( + db *db.DB, + idResolver *idresolver.Resolver, + config *config.Config, +) notify.Notifier { + return &EmailNotifier{ + db, + idResolver, + config, + notify.BaseNotifier{}, + } +} + +var _ notify.Notifier = &EmailNotifier{} + +// TODO: yeah this is just bad design. should be moved under idResolver ore include repoResolver at first place +func (n *EmailNotifier) repoOwnerSlashName(ctx context.Context, repo *db.Repo) (string, error) { + repoOwnerID, err := n.idResolver.ResolveIdent(ctx, repo.Did) + if err != nil || repoOwnerID.Handle.IsInvalidHandle() { + return "", fmt.Errorf("resolve comment owner did: %w", err) + } + repoOwnerHandle := repoOwnerID.Handle + var repoOwnerSlashName string + if repoOwnerHandle != "" && !repoOwnerHandle.IsInvalidHandle() { + repoOwnerSlashName, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", repoOwnerHandle), repo.Name) + } else { + repoOwnerSlashName = repo.DidSlashRepo() + } + return repoOwnerSlashName, nil +} + +func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment) (Email, error) { + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) + if err != nil || commentOwner.Handle.IsInvalidHandle() { + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) + } + baseUrl := n.Config.Core.AppviewHost + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) + if err != nil { + return Email{}, nil + } + url := fmt.Sprintf("%s/%s/issues/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.Issue, comment.CommentId) + return Email{ + APIKey: n.Config.Resend.ApiKey, + From: n.Config.Resend.SentFrom, + Subject: fmt.Sprintf("[%s] %s (issue#%d)", repoOwnerSlashName, issue.Title, issue.IssueId), + Html: fmt.Sprintf(`

@%s mentioned you

View it on tangled.sh.`, commentOwner.Handle.String(), url), + }, nil +} + +func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { + recipients := []string{} + resolvedIdents := n.idResolver.ResolveIdents(ctx, handles) + for _, id := range resolvedIdents { + email, err := db.GetPrimaryEmail(n.db, id.DID.String()) + if err != nil { + log.Println("failed to get primary email:", err) + continue + } + recipients = append(recipients, email.Address) + } + return recipients +} + +func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { + email, err := n.buildIssueEmail(ctx, repo, issue, comment) + if err != nil { + log.Println("failed to create issue-email:", err) + return + } + // TODO: get issue-subscribed user DIDs and merge with mentioned users + recipients := n.gatherRecipientEmails(ctx, mentions) + log.Println("sending email to:", recipients) + if err = SendEmail(email, recipients...); err != nil { + log.Println("error sending email:", err) + } +} + +// func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) { +// n.usersMentioned(ctx, mentions) +// } diff --git a/appview/state/state.go b/appview/state/state.go index 96f4c53..a35ede4 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -20,6 +20,7 @@ import ( "tangled.sh/tangled.sh/core/appview/cache/session" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/email" "tangled.sh/tangled.sh/core/appview/notify" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" @@ -133,6 +134,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { spindlestream.Start(ctx) var notifiers []notify.Notifier + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) if !config.Core.Dev { notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) } -- 2.43.0 From 85b487cade21a521c1115df3e3fe519c71f67f32 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sat, 23 Aug 2025 17:17:57 +0900 Subject: [PATCH] appview: settings: add email preference setting Change-Id: norxwrslpzurqtttypvlstsxtlmsymwv Signed-off-by: Seongmin Lee --- appview/db/db.go | 7 +++++ appview/db/email.go | 22 ++++++++++++++++ appview/db/profile.go | 38 ++++++++++++++++++++++----- appview/email/notifier.go | 16 +++++++++-- appview/pages/pages.go | 7 ++--- appview/pages/templates/settings.html | 20 ++++++++++++++ appview/settings/settings.go | 35 +++++++++++++++++++++--- 7 files changed, 131 insertions(+), 14 deletions(-) diff --git a/appview/db/db.go b/appview/db/db.go index 45cafa3..a3f2fbd 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -678,6 +678,13 @@ func Make(dbPath string) (*DB, error) { return err }) + runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error { + _, err := tx.Exec(` + alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable + `) + return err + }) + return &DB{db}, nil } diff --git a/appview/db/email.go b/appview/db/email.go index 3ce035e..d49ae1f 100644 --- a/appview/db/email.go +++ b/appview/db/email.go @@ -299,3 +299,25 @@ func UpdateVerificationCode(e Execer, did string, email string, code string) err _, err := e.Exec(query, code, did, email) return err } + +func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) { + var preference EmailPreference + err := e.QueryRow(` + select email_notif_preference + from profile + where did = ? + `, did).Scan(&preference) + if err != nil { + return preference, err + } + return preference, nil +} + +func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error { + _, err := e.Exec(` + update profile + set email_notif_preference = ? + where did = ? + `, preference, did) + return err +} diff --git a/appview/db/profile.go b/appview/db/profile.go index 4d3a733..ab7ef89 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -183,6 +183,29 @@ type Profile struct { Links [5]string Stats [2]VanityStat PinnedRepos [6]syntax.ATURI + + // settings + EmailNotifPreference EmailPreference +} + +type EmailPreference int + +const ( + EmailNotifDisabled EmailPreference = iota + EmailNotifMention + EmailNotifEnabled +) + +func (p EmailPreference) IsDisabled() bool { + return p == EmailNotifDisabled +} + +func (p EmailPreference) IsMention() bool { + return p == EmailNotifMention +} + +func (p EmailPreference) IsEnabled() bool { + return p == EmailNotifEnabled } func (p Profile) IsLinksEmpty() bool { @@ -280,13 +303,15 @@ func UpsertProfile(tx *sql.Tx, profile *Profile) error { did, description, include_bluesky, - location + location, + email_notif_preference ) - values (?, ?, ?, ?)`, + values (?, ?, ?, ?, ?)`, profile.Did, profile.Description, includeBskyValue, profile.Location, + profile.EmailNotifPreference, ) if err != nil { @@ -367,7 +392,8 @@ func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { did, description, include_bluesky, - location + location, + email_notif_preference from profile %s`, @@ -383,7 +409,7 @@ func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { var profile Profile var includeBluesky int - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) if err != nil { return nil, err } @@ -462,9 +488,9 @@ func GetProfile(e Execer, did string) (*Profile, error) { includeBluesky := 0 err := e.QueryRow( - `select description, include_bluesky, location from profile where did = ?`, + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, did, - ).Scan(&profile.Description, &includeBluesky, &profile.Location) + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) if err == sql.ErrNoRows { profile := Profile{} profile.Did = did diff --git a/appview/email/notifier.go b/appview/email/notifier.go index 5d31296..53309f8 100644 --- a/appview/email/notifier.go +++ b/appview/email/notifier.go @@ -71,8 +71,20 @@ func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issu func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { recipients := []string{} - resolvedIdents := n.idResolver.ResolveIdents(ctx, handles) - for _, id := range resolvedIdents { + for _, handle := range handles { + id, err := n.idResolver.ResolveIdent(ctx, handle) + if err != nil { + log.Println("failed to resolve handle:", err) + continue + } + emailPreference, err := db.GetUserEmailPreference(n.db, id.DID.String()) + if err != nil { + log.Println("failed to get user email preference:", err) + continue + } + if emailPreference == db.EmailNotifDisabled { + continue + } email, err := db.GetPrimaryEmail(n.db, id.DID.String()) if err != nil { log.Println("failed to get primary email:", err) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index d0c4d50..93fe6da 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -307,9 +307,10 @@ func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { } type SettingsParams struct { - LoggedInUser *oauth.User - PubKeys []db.PublicKey - Emails []db.Email + LoggedInUser *oauth.User + PubKeys []db.PublicKey + Emails []db.Email + EmailNotifPreference db.EmailPreference } func (p *Pages) Settings(w io.Writer, params SettingsParams) error { diff --git a/appview/pages/templates/settings.html b/appview/pages/templates/settings.html index 8a1979b..c2402f4 100644 --- a/appview/pages/templates/settings.html +++ b/appview/pages/templates/settings.html @@ -93,6 +93,26 @@ {{ define "emails" }}

email addresses

+
+ + +

Commits authored using emails listed here will be associated with your Tangled profile.

{{ range $index, $email := .Emails }} diff --git a/appview/settings/settings.go b/appview/settings/settings.go index a25017f..54c2146 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -45,6 +45,8 @@ func (s *Settings) Router() http.Handler { r.Delete("/", s.keys) }) + r.Post("/email/preference", s.emailPreference) + r.Route("/emails", func(r chi.Router) { r.Put("/", s.emails) r.Delete("/", s.emails) @@ -63,15 +65,18 @@ func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { log.Println(err) } + preference, err := db.GetUserEmailPreference(s.Db, user.Did) + emails, err := db.GetAllEmails(s.Db, user.Did) if err != nil { log.Println(err) } s.Pages.Settings(w, pages.SettingsParams{ - LoggedInUser: user, - PubKeys: pubKeys, - Emails: emails, + LoggedInUser: user, + PubKeys: pubKeys, + Emails: emails, + EmailNotifPreference: preference, }) } @@ -341,6 +346,30 @@ func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) { s.Pages.HxLocation(w, "/settings") } +func (s *Settings) emailPreference(w http.ResponseWriter, r *http.Request) { + did := s.OAuth.GetDid(r) + preferenceValue := r.FormValue("preference") + var preference db.EmailPreference + switch preferenceValue { + case "enable": + preference = db.EmailNotifEnabled + case "mention": + preference = db.EmailNotifMention + case "disable": + preference = db.EmailNotifDisabled + default: + log.Printf("Incorrect email preference value") + return + } + + err := db.UpdateSettingsEmailPreference(s.Db, did, preference) + if err != nil { + log.Printf("failed to update email preference setting: %v", err) + s.Pages.Notice(w, "settings-keys", "Failed to update email preference. Try again later.") + return + } +} + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: -- 2.43.0 From e4deb0f4d9db410e36178a898c81bcded6164b6b Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 1 Aug 2025 22:15:52 +0900 Subject: [PATCH] appview: email: notify mentioned users on pull-comments Change-Id: ruullntsvqlyylorxwnnrxxszpkulypz Signed-off-by: Seongmin Lee --- appview/email/notifier.go | 33 ++++++++++++++++++++++++++++--- appview/notify/merged_notifier.go | 4 ++-- appview/notify/notifier.go | 4 ++-- appview/posthog/notifier.go | 2 +- appview/pulls/pulls.go | 4 +++- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/appview/email/notifier.go b/appview/email/notifier.go index 53309f8..7b8946d 100644 --- a/appview/email/notifier.go +++ b/appview/email/notifier.go @@ -69,6 +69,25 @@ func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issu }, nil } +func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) { + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) + if err != nil || commentOwner.Handle.IsInvalidHandle() { + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) + } + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) + if err != nil { + return Email{}, nil + } + baseUrl := n.Config.Core.AppviewHost + url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID) + return Email{ + APIKey: n.Config.Resend.ApiKey, + From: n.Config.Resend.SentFrom, + Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId), + Html: fmt.Sprintf(`

@%s mentioned you

View it on tangled.sh.`, commentOwner.Handle.String(), url), + }, nil +} + func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { recipients := []string{} for _, handle := range handles { @@ -109,6 +128,14 @@ func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issu } } -// func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) { -// n.usersMentioned(ctx, mentions) -// } +func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { + email, err := n.buildPullEmail(ctx, repo, pull, comment) + if err != nil { + log.Println("failed to create pull-email:", err) + } + recipients := n.gatherRecipientEmails(ctx, mentions) + log.Println("sending email to:", recipients) + if err = SendEmail(email); err != nil { + log.Println("error sending email:", err) + } +} diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index b08a0e0..715be13 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -61,9 +61,9 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { notifier.NewPull(ctx, pull) } } -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { +func (m *mergedNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { for _, notifier := range m.notifiers { - notifier.NewPullComment(ctx, comment) + notifier.NewPullComment(ctx, repo, pull, comment, mentions) } } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 01a265c..2725bb4 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -19,7 +19,7 @@ type Notifier interface { DeleteFollow(ctx context.Context, follow *db.Follow) NewPull(ctx context.Context, pull *db.Pull) - NewPullComment(ctx context.Context, comment *db.PullComment) + NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) UpdateProfile(ctx context.Context, profile *db.Profile) } @@ -41,6 +41,6 @@ func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} +func (m *BaseNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) {} func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} diff --git a/appview/posthog/notifier.go b/appview/posthog/notifier.go index 298524f..b3b1b07 100644 --- a/appview/posthog/notifier.go +++ b/appview/posthog/notifier.go @@ -98,7 +98,7 @@ func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { +func (n *posthogNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { err := n.client.Enqueue(posthog.Capture{ DistinctId: comment.OwnerDid, Event: "new_pull_comment", diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 30ed20a..231a1c0 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -668,7 +668,9 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - s.notifier.NewPullComment(r.Context(), comment) + mentions := markup.FindUserMentions(comment.Body) + + s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions) s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) return -- 2.43.0