back interdiff of round #5 and #4

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.

REVERTED
appview/middleware/middleware.go
··· 9 "slices" 10 "strconv" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" ··· 221 return 222 } 223 224 - ctx := context.WithValue(req.Context(), "repo", repo) 225 next.ServeHTTP(w, req.WithContext(ctx)) 226 }) 227 } ··· 246 return 247 } 248 249 - pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 250 if err != nil { 251 log.Println("failed to get pull and comments", err) 252 return ··· 287 return 288 } 289 290 - fullName := f.OwnerHandle() + "/" + f.Name 291 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 293 if r.URL.Query().Get("go-get") == "1" {
··· 9 "slices" 10 "strconv" 11 "strings" 12 + "time" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/go-chi/chi/v5" ··· 222 return 223 } 224 225 + ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 + ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 + ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 + ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 230 next.ServeHTTP(w, req.WithContext(ctx)) 231 }) 232 } ··· 251 return 252 } 253 254 + pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 255 if err != nil { 256 log.Println("failed to get pull and comments", err) 257 return ··· 292 return 293 } 294 295 + fullName := f.OwnerHandle() + "/" + f.RepoName 296 297 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 if r.URL.Query().Get("go-get") == "1" {
REVERTED
appview/repo/artifact.go
··· 76 Artifact: uploadBlobResp.Blob, 77 CreatedAt: createdAt.Format(time.RFC3339), 78 Name: handler.Filename, 79 - Repo: f.RepoAt().String(), 80 Tag: tag.Tag.Hash[:], 81 }, 82 }, ··· 100 artifact := db.Artifact{ 101 Did: user.Did, 102 Rkey: rkey, 103 - RepoAt: f.RepoAt(), 104 Tag: tag.Tag.Hash, 105 CreatedAt: createdAt, 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt()), 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 db.FilterEq("name", filename), 161 ) ··· 197 198 artifacts, err := db.GetArtifact( 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt()), 201 db.FilterEq("tag", tag[:]), 202 db.FilterEq("name", filename), 203 ) ··· 239 defer tx.Rollback() 240 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt()), 243 db.FilterEq("tag", artifact.Tag[:]), 244 db.FilterEq("name", filename), 245 ) ··· 270 return nil, err 271 } 272 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err
··· 76 Artifact: uploadBlobResp.Blob, 77 CreatedAt: createdAt.Format(time.RFC3339), 78 Name: handler.Filename, 79 + Repo: f.RepoAt.String(), 80 Tag: tag.Tag.Hash[:], 81 }, 82 }, ··· 100 artifact := db.Artifact{ 101 Did: user.Did, 102 Rkey: rkey, 103 + RepoAt: f.RepoAt, 104 Tag: tag.Tag.Hash, 105 CreatedAt: createdAt, 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 + db.FilterEq("repo_at", f.RepoAt), 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 db.FilterEq("name", filename), 161 ) ··· 197 198 artifacts, err := db.GetArtifact( 199 rp.db, 200 + db.FilterEq("repo_at", f.RepoAt), 201 db.FilterEq("tag", tag[:]), 202 db.FilterEq("name", filename), 203 ) ··· 239 defer tx.Rollback() 240 241 err = db.DeleteArtifact(tx, 242 + db.FilterEq("repo_at", f.RepoAt), 243 db.FilterEq("tag", artifact.Tag[:]), 244 db.FilterEq("name", filename), 245 ) ··· 270 return nil, err 271 } 272 273 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err
REVERTED
appview/repo/index.go
··· 37 return 38 } 39 40 - result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 if err != nil { 42 rp.pages.Error503(w) 43 log.Println("failed to reach knotserver", err) ··· 166 // first attempt to fetch from db 167 langs, err := db.GetRepoLanguages( 168 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt()), 170 db.FilterEq("ref", f.Ref), 171 ) 172 173 if err != nil || langs == nil { 174 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref) 176 if err != nil { 177 return nil, err 178 } ··· 182 183 for l, s := range ls.Languages { 184 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt(), 186 Ref: f.Ref, 187 IsDefaultRef: isDefaultRef, 188 Language: l, ··· 279 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 281 var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef) 283 if err != nil { 284 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 return nil, err
··· 37 return 38 } 39 40 + result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 if err != nil { 42 rp.pages.Error503(w) 43 log.Println("failed to reach knotserver", err) ··· 166 // first attempt to fetch from db 167 langs, err := db.GetRepoLanguages( 168 rp.db, 169 + db.FilterEq("repo_at", f.RepoAt), 170 db.FilterEq("ref", f.Ref), 171 ) 172 173 if err != nil || langs == nil { 174 // non-fatal, fetch langs from ks 175 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 176 if err != nil { 177 return nil, err 178 } ··· 182 183 for l, s := range ls.Languages { 184 langs = append(langs, db.RepoLanguage{ 185 + RepoAt: f.RepoAt, 186 Ref: f.Ref, 187 IsDefaultRef: isDefaultRef, 188 Language: l, ··· 279 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 281 var status types.AncestorCheckResponse 282 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 283 if err != nil { 284 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 return nil, err
REVERTED
appview/reporesolver/resolver.go
··· 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 17 "tangled.sh/tangled.sh/core/appview/config" ··· 25 ) 26 27 type ResolvedRepo struct { 28 - db.Repo 29 OwnerId identity.Identity 30 Ref string 31 CurrentDir string 32 ··· 45 } 46 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 48 - repo, ok := r.Context().Value("repo").(*db.Repo) 49 if !ok { 50 - log.Println("malformed middleware: `repo` not exist in context") 51 return nil, fmt.Errorf("malformed middleware") 52 } 53 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 56 return nil, fmt.Errorf("malformed middleware") 57 } 58 59 ref := chi.URLParam(r, "ref") 60 61 if ref == "" { 62 - us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev) 63 if err != nil { 64 return nil, err 65 } 66 67 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name) 68 if err != nil { 69 return nil, err 70 } ··· 74 75 currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 76 77 return &ResolvedRepo{ 78 - Repo: *repo, 79 - OwnerId: id, 80 - Ref: ref, 81 - CurrentDir: currentDir, 82 83 rr: rr, 84 }, nil ··· 97 98 var p string 99 if handle != "" && !handle.IsInvalidHandle() { 100 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 101 } else { 102 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 103 } 104 105 return p 106 } 107 108 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 109 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 110 if err != nil { ··· 153 // this function is a bit weird since it now returns RepoInfo from an entirely different 154 // package. we should refactor this or get rid of RepoInfo entirely. 155 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 156 - repoAt := f.RepoAt() 157 isStarred := false 158 if user != nil { 159 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 160 } 161 162 - starCount, err := db.GetStarCount(f.rr.execer, repoAt) 163 if err != nil { 164 - log.Println("failed to get star count for ", repoAt) 165 } 166 - issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 167 if err != nil { 168 - log.Println("failed to get issue count for ", repoAt) 169 } 170 - pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 171 if err != nil { 172 - log.Println("failed to get issue count for ", repoAt) 173 } 174 - source, err := db.GetRepoSource(f.rr.execer, repoAt) 175 if errors.Is(err, sql.ErrNoRows) { 176 source = "" 177 } else if err != nil { 178 - log.Println("failed to get repo source for ", repoAt, err) 179 } 180 181 var sourceRepo *db.Repo ··· 200 if err != nil { 201 log.Printf("failed to create unsigned client for %s: %v", knot, err) 202 } else { 203 - result, err := us.Branches(f.OwnerDid(), f.Name) 204 if err != nil { 205 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err) 206 } 207 208 if len(result.Branches) == 0 { ··· 213 repoInfo := repoinfo.RepoInfo{ 214 OwnerDid: f.OwnerDid(), 215 OwnerHandle: f.OwnerHandle(), 216 - Name: f.Name, 217 - RepoAt: repoAt, 218 Description: f.Description, 219 Ref: f.Ref, 220 IsStarred: isStarred,
··· 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" ··· 26 ) 27 28 type ResolvedRepo struct { 29 + Knot string 30 OwnerId identity.Identity 31 + RepoName string 32 + RepoAt syntax.ATURI 33 + Description string 34 + Spindle string 35 + CreatedAt string 36 Ref string 37 CurrentDir string 38 ··· 51 } 52 53 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 + repoName := chi.URLParam(r, "repo") 55 + knot, ok := r.Context().Value("knot").(string) 56 if !ok { 57 + log.Println("malformed middleware") 58 return nil, fmt.Errorf("malformed middleware") 59 } 60 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 return nil, fmt.Errorf("malformed middleware") 64 } 65 66 + repoAt, ok := r.Context().Value("repoAt").(string) 67 + if !ok { 68 + log.Println("malformed middleware") 69 + return nil, fmt.Errorf("malformed middleware") 70 + } 71 + 72 + parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 + if err != nil { 74 + log.Println("malformed repo at-uri") 75 + return nil, fmt.Errorf("malformed middleware") 76 + } 77 + 78 ref := chi.URLParam(r, "ref") 79 80 if ref == "" { 81 + us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 if err != nil { 83 return nil, err 84 } 85 86 + defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 if err != nil { 88 return nil, err 89 } ··· 93 94 currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 96 + // pass through values from the middleware 97 + description, ok := r.Context().Value("repoDescription").(string) 98 + addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 + spindle, ok := r.Context().Value("repoSpindle").(string) 100 + 101 return &ResolvedRepo{ 102 + Knot: knot, 103 + OwnerId: id, 104 + RepoName: repoName, 105 + RepoAt: parsedRepoAt, 106 + Description: description, 107 + CreatedAt: addedAt, 108 + Ref: ref, 109 + CurrentDir: currentDir, 110 + Spindle: spindle, 111 112 rr: rr, 113 }, nil ··· 126 127 var p string 128 if handle != "" && !handle.IsInvalidHandle() { 129 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 130 } else { 131 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 132 } 133 134 return p 135 } 136 137 + func (f *ResolvedRepo) DidSlashRepo() string { 138 + p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 + return p 140 + } 141 + 142 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 143 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 144 if err != nil { ··· 187 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 // package. we should refactor this or get rid of RepoInfo entirely. 189 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 190 isStarred := false 191 if user != nil { 192 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 193 } 194 195 + starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 196 if err != nil { 197 + log.Println("failed to get star count for ", f.RepoAt) 198 } 199 + issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 200 if err != nil { 201 + log.Println("failed to get issue count for ", f.RepoAt) 202 } 203 + pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 204 if err != nil { 205 + log.Println("failed to get issue count for ", f.RepoAt) 206 } 207 + source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 208 if errors.Is(err, sql.ErrNoRows) { 209 source = "" 210 } else if err != nil { 211 + log.Println("failed to get repo source for ", f.RepoAt, err) 212 } 213 214 var sourceRepo *db.Repo ··· 233 if err != nil { 234 log.Printf("failed to create unsigned client for %s: %v", knot, err) 235 } else { 236 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 237 if err != nil { 238 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 239 } 240 241 if len(result.Branches) == 0 { ··· 246 repoInfo := repoinfo.RepoInfo{ 247 OwnerDid: f.OwnerDid(), 248 OwnerHandle: f.OwnerHandle(), 249 + Name: f.RepoName, 250 + RepoAt: f.RepoAt, 251 Description: f.Description, 252 Ref: f.Ref, 253 IsStarred: isStarred,
REVERTED
appview/db/star.go
··· 196 r.name, 197 r.knot, 198 r.rkey, 199 - r.created 200 from stars s 201 join repos r on s.repo_at = r.at_uri 202 `) ··· 221 &repo.Knot, 222 &repo.Rkey, 223 &repoCreatedAt, 224 ); err != nil { 225 return nil, err 226 }
··· 196 r.name, 197 r.knot, 198 r.rkey, 199 + r.created, 200 + r.at_uri 201 from stars s 202 join repos r on s.repo_at = r.at_uri 203 `) ··· 222 &repo.Knot, 223 &repo.Rkey, 224 &repoCreatedAt, 225 + &repo.AtUri, 226 ); err != nil { 227 return nil, err 228 }
ERROR
appview/pages/markup/markdown.go

Failed to calculate interdiff for this file.

ERROR
appview/pages/markup/markdown_at_extension.go

Failed to calculate interdiff for this file.

REBASED
appview/pulls/pulls.go

This patch was likely rebased, as context lines do not match.

NEW
appview/issues/issues.go
··· 260 return 261 } 262 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 265 - 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 OwnerDid: user.Did, 268 RepoAt: f.RepoAt(), 269 Issue: issueIdInt, 270 - CommentId: commentId, 271 Body: body, 272 - Rkey: rkey, 273 - }) 274 if err != nil { 275 log.Println("failed to create comment", err) 276 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 278 } 279 280 createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 - ownerDid := user.Did 283 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 if err != nil { 285 - log.Println("failed to get issue at", err) 286 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 287 return 288 } 289 290 - atUri := f.RepoAt().String() 291 client, err := rp.oauth.AuthorizedClient(r) 292 if err != nil { 293 log.Println("failed to get authorized client", err) ··· 297 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 298 Collection: tangled.RepoIssueCommentNSID, 299 Repo: user.Did, 300 - Rkey: rkey, 301 Record: &lexutil.LexiconTypeDecoder{ 302 Val: &tangled.RepoIssueComment{ 303 Repo: &atUri, 304 - Issue: issueAt, 305 CommentId: &commentIdInt64, 306 - Owner: &ownerDid, 307 Body: body, 308 CreatedAt: createdAt, 309 }, ··· 315 return 316 } 317 318 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 319 return 320 } 321 }
··· 260 return 261 } 262 263 + comment := &db.Comment{ 264 OwnerDid: user.Did, 265 RepoAt: f.RepoAt(), 266 Issue: issueIdInt, 267 + CommentId: mathrand.IntN(1000000), 268 Body: body, 269 + Rkey: tid.TID(), 270 + } 271 + 272 + err := db.NewIssueComment(rp.db, comment) 273 if err != nil { 274 log.Println("failed to create comment", err) 275 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 277 } 278 279 createdAt := time.Now().Format(time.RFC3339) 280 + commentIdInt64 := int64(comment.CommentId) 281 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 282 if err != nil { 283 + log.Println("failed to get issue", err) 284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 return 286 } 287 288 + atUri := comment.RepoAt.String() 289 client, err := rp.oauth.AuthorizedClient(r) 290 if err != nil { 291 log.Println("failed to get authorized client", err) ··· 295 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 296 Collection: tangled.RepoIssueCommentNSID, 297 Repo: user.Did, 298 + Rkey: comment.Rkey, 299 Record: &lexutil.LexiconTypeDecoder{ 300 Val: &tangled.RepoIssueComment{ 301 Repo: &atUri, 302 + Issue: issue.AtUri().String(), 303 CommentId: &commentIdInt64, 304 + Owner: &comment.OwnerDid, 305 Body: body, 306 CreatedAt: createdAt, 307 }, ··· 313 return 314 } 315 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)) 321 return 322 } 323 }
NEW
appview/email/email.go
··· 1 package email 2 3 import ( 4 - "fmt" 5 "net" 6 "regexp" 7 "strings" ··· 11 12 type Email struct { 13 From string 14 - To string 15 Subject string 16 Text string 17 Html string 18 APIKey string 19 } 20 21 - func SendEmail(email Email) error { 22 client := resend.NewClient(email.APIKey) 23 _, err := client.Emails.Send(&resend.SendEmailRequest{ 24 From: email.From, 25 - To: []string{email.To}, 26 Subject: email.Subject, 27 Text: email.Text, 28 Html: email.Html, 29 }) 30 - if err != nil { 31 - return fmt.Errorf("error sending email: %w", err) 32 - } 33 - return nil 34 } 35 36 func IsValidEmail(email string) bool {
··· 1 package email 2 3 import ( 4 "net" 5 "regexp" 6 "strings" ··· 10 11 type Email struct { 12 From string 13 Subject string 14 Text string 15 Html string 16 APIKey string 17 } 18 19 + func SendEmail(email Email, recipients ...string) error { 20 client := resend.NewClient(email.APIKey) 21 _, err := client.Emails.Send(&resend.SendEmailRequest{ 22 From: email.From, 23 + To: recipients, 24 Subject: email.Subject, 25 Text: email.Text, 26 Html: email.Html, 27 }) 28 + return err 29 } 30 31 func IsValidEmail(email string) bool {
NEW
appview/signup/signup.go
··· 149 em := email.Email{ 150 APIKey: s.config.Resend.ApiKey, 151 From: s.config.Resend.SentFrom, 152 - To: emailId, 153 Subject: "Verify your Tangled account", 154 Text: `Copy and paste this code below to verify your account on Tangled. 155 ` + code, ··· 157 <p><code>` + code + `</code></p>`, 158 } 159 160 - err = email.SendEmail(em) 161 if err != nil { 162 s.l.Error("failed to send email", "error", err) 163 s.pages.Notice(w, noticeId, "Failed to send email.")
··· 149 em := email.Email{ 150 APIKey: s.config.Resend.ApiKey, 151 From: s.config.Resend.SentFrom, 152 Subject: "Verify your Tangled account", 153 Text: `Copy and paste this code below to verify your account on Tangled. 154 ` + code, ··· 156 <p><code>` + code + `</code></p>`, 157 } 158 159 + err = email.SendEmail(em, emailId) 160 if err != nil { 161 s.l.Error("failed to send email", "error", err) 162 s.pages.Notice(w, noticeId, "Failed to send email.")
NEW
appview/state/state.go
··· 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" ··· 133 spindlestream.Start(ctx) 134 135 var notifiers []notify.Notifier 136 if !config.Core.Dev { 137 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 138 }
··· 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/email" 24 "tangled.sh/tangled.sh/core/appview/notify" 25 "tangled.sh/tangled.sh/core/appview/oauth" 26 "tangled.sh/tangled.sh/core/appview/pages" ··· 134 spindlestream.Start(ctx) 135 136 var notifiers []notify.Notifier 137 + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) 138 if !config.Core.Dev { 139 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 140 }
NEW
appview/db/db.go
··· 678 return err 679 }) 680 681 return &DB{db}, nil 682 } 683
··· 678 return err 679 }) 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 + 688 return &DB{db}, nil 689 } 690
NEW
appview/db/email.go
··· 299 _, err := e.Exec(query, code, did, email) 300 return err 301 }
··· 299 _, err := e.Exec(query, code, did, email) 300 return err 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 + }
NEW
appview/db/profile.go
··· 183 Links [5]string 184 Stats [2]VanityStat 185 PinnedRepos [6]syntax.ATURI 186 } 187 188 func (p Profile) IsLinksEmpty() bool { ··· 280 did, 281 description, 282 include_bluesky, 283 - location 284 ) 285 - values (?, ?, ?, ?)`, 286 profile.Did, 287 profile.Description, 288 includeBskyValue, 289 profile.Location, 290 ) 291 292 if err != nil { ··· 367 did, 368 description, 369 include_bluesky, 370 - location 371 from 372 profile 373 %s`, ··· 383 var profile Profile 384 var includeBluesky int 385 386 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 387 if err != nil { 388 return nil, err 389 } ··· 462 463 includeBluesky := 0 464 err := e.QueryRow( 465 - `select description, include_bluesky, location from profile where did = ?`, 466 did, 467 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 468 if err == sql.ErrNoRows { 469 profile := Profile{} 470 profile.Did = did
··· 183 Links [5]string 184 Stats [2]VanityStat 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 209 } 210 211 func (p Profile) IsLinksEmpty() bool { ··· 303 did, 304 description, 305 include_bluesky, 306 + location, 307 + email_notif_preference 308 ) 309 + values (?, ?, ?, ?, ?)`, 310 profile.Did, 311 profile.Description, 312 includeBskyValue, 313 profile.Location, 314 + profile.EmailNotifPreference, 315 ) 316 317 if err != nil { ··· 392 did, 393 description, 394 include_bluesky, 395 + location, 396 + email_notif_preference 397 from 398 profile 399 %s`, ··· 409 var profile Profile 410 var includeBluesky int 411 412 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 413 if err != nil { 414 return nil, err 415 } ··· 488 489 includeBluesky := 0 490 err := e.QueryRow( 491 + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, 492 did, 493 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 494 if err == sql.ErrNoRows { 495 profile := Profile{} 496 profile.Did = did
NEW
appview/pages/pages.go
··· 307 } 308 309 type SettingsParams struct { 310 - LoggedInUser *oauth.User 311 - PubKeys []db.PublicKey 312 - Emails []db.Email 313 } 314 315 func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
··· 307 } 308 309 type SettingsParams struct { 310 + LoggedInUser *oauth.User 311 + PubKeys []db.PublicKey 312 + Emails []db.Email 313 + EmailNotifPreference db.EmailPreference 314 } 315 316 func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
NEW
appview/pages/templates/settings.html
··· 93 {{ define "emails" }} 94 <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 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 <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 {{ range $index, $email := .Emails }}
··· 93 {{ define "emails" }} 94 <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 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> 116 <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 117 <div id="email-list" class="flex flex-col gap-6 mb-8"> 118 {{ range $index, $email := .Emails }}
NEW
appview/email/notifier.go
··· 69 }, nil 70 } 71 72 func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { 73 recipients := []string{} 74 for _, handle := range handles { ··· 109 } 110 } 111 112 - // func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) { 113 - // n.usersMentioned(ctx, mentions) 114 - // }
··· 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 { ··· 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 + }