Monorepo for Tangled tangled.org

appview: email: resend verification

anirudh.fi 8f5bcee3 b120a6e4

verified
Changed files
+171 -22
appview
db
pages
templates
state
+1
appview/db/db.go
··· 202 email text not null, 203 verified integer not null default 0, 204 verification_code text not null, 205 is_primary integer not null default 0, 206 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 207 unique(did, email)
··· 202 email text not null, 203 verified integer not null default 0, 204 verification_code text not null, 205 + last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 206 is_primary integer not null default 0, 207 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 208 unique(did, email)
+51 -6
appview/db/email.go
··· 12 Verified bool 13 Primary bool 14 VerificationCode string 15 CreatedAt time.Time 16 } 17 18 func GetPrimaryEmail(e Execer, did string) (Email, error) { 19 query := ` 20 - select id, did, email, verified, is_primary, verification_code, created 21 from emails 22 where did = ? and is_primary = true 23 ` 24 var email Email 25 var createdStr string 26 - err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 27 if err != nil { 28 return Email{}, err 29 } 30 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 31 if err != nil { 32 return Email{}, err 33 } 34 return email, nil 35 } 36 37 func GetEmail(e Execer, did string, em string) (Email, error) { 38 query := ` 39 - select id, did, email, verified, is_primary, verification_code, created 40 from emails 41 where did = ? and email = ? 42 ` 43 var email Email 44 var createdStr string 45 - err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 46 if err != nil { 47 return Email{}, err 48 } 49 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 50 if err != nil { 51 return Email{}, err 52 } 53 return email, nil 54 } ··· 220 221 func GetAllEmails(e Execer, did string) ([]Email, error) { 222 query := ` 223 - select did, email, verified, is_primary, verification_code, created 224 from emails 225 where did = ? 226 ` ··· 234 for rows.Next() { 235 var email Email 236 var createdStr string 237 - err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 238 if err != nil { 239 return nil, err 240 } ··· 242 if err != nil { 243 return nil, err 244 } 245 emails = append(emails, email) 246 } 247 return emails, nil 248 }
··· 12 Verified bool 13 Primary bool 14 VerificationCode string 15 + LastSent *time.Time 16 CreatedAt time.Time 17 } 18 19 func GetPrimaryEmail(e Execer, did string) (Email, error) { 20 query := ` 21 + select id, did, email, verified, is_primary, verification_code, last_sent, created 22 from emails 23 where did = ? and is_primary = true 24 ` 25 var email Email 26 var createdStr string 27 + var lastSent *string 28 + err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 29 if err != nil { 30 return Email{}, err 31 } 32 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 33 if err != nil { 34 return Email{}, err 35 + } 36 + if lastSent != nil { 37 + parsedTime, err := time.Parse(time.RFC3339, *lastSent) 38 + if err != nil { 39 + return Email{}, err 40 + } 41 + email.LastSent = &parsedTime 42 } 43 return email, nil 44 } 45 46 func GetEmail(e Execer, did string, em string) (Email, error) { 47 query := ` 48 + select id, did, email, verified, is_primary, verification_code, last_sent, created 49 from emails 50 where did = ? and email = ? 51 ` 52 var email Email 53 var createdStr string 54 + var lastSent *string 55 + err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 56 if err != nil { 57 return Email{}, err 58 } 59 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 60 if err != nil { 61 return Email{}, err 62 + } 63 + if lastSent != nil { 64 + parsedTime, err := time.Parse(time.RFC3339, *lastSent) 65 + if err != nil { 66 + return Email{}, err 67 + } 68 + email.LastSent = &parsedTime 69 } 70 return email, nil 71 } ··· 237 238 func GetAllEmails(e Execer, did string) ([]Email, error) { 239 query := ` 240 + select did, email, verified, is_primary, verification_code, last_sent, created 241 from emails 242 where did = ? 243 ` ··· 251 for rows.Next() { 252 var email Email 253 var createdStr string 254 + var lastSent *string 255 + err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 256 if err != nil { 257 return nil, err 258 } ··· 260 if err != nil { 261 return nil, err 262 } 263 + if lastSent != nil { 264 + parsedTime, err := time.Parse(time.RFC3339, *lastSent) 265 + if err != nil { 266 + return nil, err 267 + } 268 + email.LastSent = &parsedTime 269 + } 270 emails = append(emails, email) 271 } 272 return emails, nil 273 } 274 + 275 + func UpdateVerificationCode(e Execer, did string, email string, code string) error { 276 + query := ` 277 + update emails 278 + set verification_code = ? 279 + where did = ? and email = ? 280 + ` 281 + _, err := e.Exec(query, code, did, email) 282 + return err 283 + } 284 + 285 + func UpdateLastSent(e Execer, did string, email string, lastSent time.Time) error { 286 + query := ` 287 + update emails 288 + set last_sent = ? 289 + where did = ? and email = ? 290 + ` 291 + _, err := e.Exec(query, lastSent.Format(time.RFC3339), did, email) 292 + return err 293 + }
+11 -1
appview/pages/templates/settings.html
··· 103 </div> 104 </div> 105 <div class="flex gap-2 items-center"> 106 - {{ if not .Primary }} 107 <a 108 class="text-sm" 109 hx-post="/settings/emails/primary"
··· 103 </div> 104 </div> 105 <div class="flex gap-2 items-center"> 106 + {{ if not .Verified }} 107 + <a 108 + class="text-sm" 109 + hx-post="/settings/emails/verify/resend" 110 + hx-swap="none" 111 + href="#" 112 + hx-vals='{"email": "{{ .Address }}"}'> 113 + resend verification 114 + </a> 115 + {{ end }} 116 + {{ if and (not .Primary) .Verified }} 117 <a 118 class="text-sm" 119 hx-post="/settings/emails/primary"
+1
appview/state/router.go
··· 170 r.Put("/emails", s.SettingsEmails) 171 r.Delete("/emails", s.SettingsEmails) 172 r.Get("/emails/verify", s.SettingsEmailsVerify) 173 r.Post("/emails/primary", s.SettingsEmailsPrimary) 174 }) 175
··· 170 r.Put("/emails", s.SettingsEmails) 171 r.Delete("/emails", s.SettingsEmails) 172 r.Get("/emails/verify", s.SettingsEmailsVerify) 173 + r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend) 174 r.Post("/emails/primary", s.SettingsEmailsPrimary) 175 }) 176
+107 -15
appview/state/settings.go
··· 38 }) 39 } 40 41 func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 42 switch r.Method { 43 case http.MethodGet: ··· 94 return 95 } 96 97 - err = email.SendEmail(email.Email{ 98 - APIKey: s.config.ResendApiKey, 99 - 100 - From: "noreply@notifs.tangled.sh", 101 - To: emAddr, 102 - Subject: "Verify your Tangled email", 103 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 104 - ` + s.verifyUrl(did, emAddr, code), 105 - Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 106 - <p><a href="` + s.verifyUrl(did, emAddr, code) + `">` + s.verifyUrl(did, emAddr, code) + `</a></p>`, 107 - }) 108 - 109 - if err != nil { 110 - log.Printf("sending email: %s", err) 111 - s.pages.Notice(w, "settings-emails-error", "Unable to send verification email at this moment, try again later.") 112 return 113 } 114 ··· 192 } 193 194 http.Redirect(w, r, "/settings", http.StatusSeeOther) 195 } 196 197 func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
··· 38 }) 39 } 40 41 + // buildVerificationEmail creates an email.Email struct for verification emails 42 + func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email { 43 + verifyURL := s.verifyUrl(did, emailAddr, code) 44 + 45 + return email.Email{ 46 + APIKey: s.config.ResendApiKey, 47 + From: "noreply@notifs.tangled.sh", 48 + To: emailAddr, 49 + Subject: "Verify your Tangled email", 50 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 51 + ` + verifyURL, 52 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 53 + <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 54 + } 55 + } 56 + 57 + // sendVerificationEmail handles the common logic for sending verification emails 58 + func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 59 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 60 + 61 + err := email.SendEmail(emailToSend) 62 + if err != nil { 63 + log.Printf("sending email: %s", err) 64 + s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 65 + return err 66 + } 67 + 68 + return nil 69 + } 70 + 71 func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 72 switch r.Method { 73 case http.MethodGet: ··· 124 return 125 } 126 127 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 128 return 129 } 130 ··· 208 } 209 210 http.Redirect(w, r, "/settings", http.StatusSeeOther) 211 + } 212 + 213 + func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) { 214 + if r.Method != http.MethodPost { 215 + s.pages.Notice(w, "settings-emails-error", "Invalid request method.") 216 + return 217 + } 218 + 219 + did := s.auth.GetDid(r) 220 + emAddr := r.FormValue("email") 221 + emAddr = strings.TrimSpace(emAddr) 222 + 223 + if !email.IsValidEmail(emAddr) { 224 + s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 225 + return 226 + } 227 + 228 + // Check if email exists and is unverified 229 + existingEmail, err := db.GetEmail(s.db, did, emAddr) 230 + if err != nil { 231 + if errors.Is(err, sql.ErrNoRows) { 232 + s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 233 + } else { 234 + log.Printf("checking for existing email: %s", err) 235 + s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 236 + } 237 + return 238 + } 239 + 240 + if existingEmail.Verified { 241 + s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 242 + return 243 + } 244 + 245 + // Check if last verification email was sent less than 10 minutes ago 246 + if existingEmail.LastSent != nil { 247 + timeSinceLastSent := time.Since(*existingEmail.LastSent) 248 + if timeSinceLastSent < 10*time.Minute { 249 + waitTime := 10*time.Minute - timeSinceLastSent 250 + s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 251 + return 252 + } 253 + } 254 + 255 + // Generate new verification code 256 + code := uuid.New().String() 257 + 258 + // Begin transaction 259 + tx, err := s.db.Begin() 260 + if err != nil { 261 + log.Printf("failed to start transaction: %s", err) 262 + s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 263 + return 264 + } 265 + defer tx.Rollback() 266 + 267 + // Update the verification code and last sent time 268 + if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 269 + log.Printf("updating email verification: %s", err) 270 + s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 271 + return 272 + } 273 + 274 + // Send verification email 275 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 276 + return 277 + } 278 + 279 + // Commit transaction 280 + if err := tx.Commit(); err != nil { 281 + log.Printf("failed to commit transaction: %s", err) 282 + s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 283 + return 284 + } 285 + 286 + s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 287 } 288 289 func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {