Monorepo for Tangled tangled.org
2

Configure Feed

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

appview: email: send email notification on mention

Signed-off-by: Seongmin Lee <boltlessengineer@proton.me>

author
Seongmin Lee
committer
Seongmin Lee
date (Jun 17, 2026, 2:39 AM +0900) commit ab720aec parent b9d81230 change-id vkmkzopl
+130
+129
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 + resolvedIdents := n.idResolver.ResolveIdents(ctx, handles) 94 + for _, id := range resolvedIdents { 95 + email, err := db.GetPrimaryEmail(n.db, id.DID.String()) 96 + if err != nil { 97 + log.Println("failed to get primary email:", err) 98 + continue 99 + } 100 + recipients = append(recipients, email.Address) 101 + } 102 + return recipients 103 + } 104 + 105 + func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 106 + email, err := n.buildIssueEmail(ctx, repo, issue, comment) 107 + if err != nil { 108 + log.Println("failed to create issue-email:", err) 109 + return 110 + } 111 + // TODO: get issue-subscribed user DIDs and merge with mentioned users 112 + recipients := n.gatherRecipientEmails(ctx, mentions) 113 + log.Println("sending email to:", recipients) 114 + if err = SendEmail(email, recipients...); err != nil { 115 + log.Println("error sending email:", err) 116 + } 117 + } 118 + 119 + func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 120 + email, err := n.buildPullEmail(ctx, repo, pull, comment) 121 + if err != nil { 122 + log.Println("failed to create pull-email:", err) 123 + } 124 + recipients := n.gatherRecipientEmails(ctx, mentions) 125 + log.Println("sending email to:", recipients) 126 + if err = SendEmail(email); err != nil { 127 + log.Println("error sending email:", err) 128 + } 129 + }
+1
appview/state/state.go
··· 173 173 174 174 // Always add the database notifier 175 175 notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 176 + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) 176 177 177 178 // Add other notifiers in production only 178 179 if !config.Core.Dev {