···202202 email text not null,203203 verified integer not null default 0,204204 verification_code text not null,205205+ last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),205206 is_primary integer not null default 0,206207 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),207208 unique(did, email)
+51-6
appview/db/email.go
···1212 Verified bool1313 Primary bool1414 VerificationCode string1515+ LastSent *time.Time1516 CreatedAt time.Time1617}17181819func GetPrimaryEmail(e Execer, did string) (Email, error) {1920 query := `2020- select id, did, email, verified, is_primary, verification_code, created2121+ select id, did, email, verified, is_primary, verification_code, last_sent, created2122 from emails2223 where did = ? and is_primary = true2324 `2425 var email Email2526 var createdStr string2626- err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr)2727+ var lastSent *string2828+ err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)2729 if err != nil {2830 return Email{}, err2931 }3032 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)3133 if err != nil {3234 return Email{}, err3535+ }3636+ if lastSent != nil {3737+ parsedTime, err := time.Parse(time.RFC3339, *lastSent)3838+ if err != nil {3939+ return Email{}, err4040+ }4141+ email.LastSent = &parsedTime3342 }3443 return email, nil3544}36453746func GetEmail(e Execer, did string, em string) (Email, error) {3847 query := `3939- select id, did, email, verified, is_primary, verification_code, created4848+ select id, did, email, verified, is_primary, verification_code, last_sent, created4049 from emails4150 where did = ? and email = ?4251 `4352 var email Email4453 var createdStr string4545- err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr)5454+ var lastSent *string5555+ err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)4656 if err != nil {4757 return Email{}, err4858 }4959 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)5060 if err != nil {5161 return Email{}, err6262+ }6363+ if lastSent != nil {6464+ parsedTime, err := time.Parse(time.RFC3339, *lastSent)6565+ if err != nil {6666+ return Email{}, err6767+ }6868+ email.LastSent = &parsedTime5269 }5370 return email, nil5471}···237220238221func GetAllEmails(e Execer, did string) ([]Email, error) {239222 query := `240240- select did, email, verified, is_primary, verification_code, created223223+ select did, email, verified, is_primary, verification_code, last_sent, created241224 from emails242225 where did = ?243226 `···251234 for rows.Next() {252235 var email Email253236 var createdStr string254254- err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr)237237+ var lastSent *string238238+ err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)255239 if err != nil {256240 return nil, err257241 }···260242 if err != nil {261243 return nil, err262244 }245245+ if lastSent != nil {246246+ parsedTime, err := time.Parse(time.RFC3339, *lastSent)247247+ if err != nil {248248+ return nil, err249249+ }250250+ email.LastSent = &parsedTime251251+ }263252 emails = append(emails, email)264253 }265254 return emails, nil255255+}256256+257257+func UpdateVerificationCode(e Execer, did string, email string, code string) error {258258+ query := `259259+ update emails260260+ set verification_code = ?261261+ where did = ? and email = ?262262+ `263263+ _, err := e.Exec(query, code, did, email)264264+ return err265265+}266266+267267+func UpdateLastSent(e Execer, did string, email string, lastSent time.Time) error {268268+ query := `269269+ update emails270270+ set last_sent = ?271271+ where did = ? and email = ?272272+ `273273+ _, err := e.Exec(query, lastSent.Format(time.RFC3339), did, email)274274+ return err266275}
+11-1
appview/pages/templates/settings.html
···103103 </div>104104 </div>105105 <div class="flex gap-2 items-center">106106- {{ if not .Primary }}106106+ {{ if not .Verified }}107107+ <a 108108+ class="text-sm"109109+ hx-post="/settings/emails/verify/resend" 110110+ hx-swap="none"111111+ href="#"112112+ hx-vals='{"email": "{{ .Address }}"}'>113113+ resend verification114114+ </a>115115+ {{ end }}116116+ {{ if and (not .Primary) .Verified }}107117 <a 108118 class="text-sm"109119 hx-post="/settings/emails/primary"
···3838 })3939}40404141+// buildVerificationEmail creates an email.Email struct for verification emails4242+func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {4343+ verifyURL := s.verifyUrl(did, emailAddr, code)4444+4545+ return email.Email{4646+ APIKey: s.config.ResendApiKey,4747+ From: "noreply@notifs.tangled.sh",4848+ To: emailAddr,4949+ Subject: "Verify your Tangled email",5050+ Text: `Click the link below (or copy and paste it into your browser) to verify your email address.5151+` + verifyURL,5252+ Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>5353+<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,5454+ }5555+}5656+5757+// sendVerificationEmail handles the common logic for sending verification emails5858+func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {5959+ emailToSend := s.buildVerificationEmail(emailAddr, did, code)6060+6161+ err := email.SendEmail(emailToSend)6262+ if err != nil {6363+ log.Printf("sending email: %s", err)6464+ s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))6565+ return err6666+ }6767+6868+ return nil6969+}7070+4171func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {4272 switch r.Method {4373 case http.MethodGet:···12494 return12595 }12696127127- err = email.SendEmail(email.Email{128128- APIKey: s.config.ResendApiKey,129129-130130- From: "noreply@notifs.tangled.sh",131131- To: emAddr,132132- Subject: "Verify your Tangled email",133133- Text: `Click the link below (or copy and paste it into your browser) to verify your email address.134134-` + s.verifyUrl(did, emAddr, code),135135- Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>136136-<p><a href="` + s.verifyUrl(did, emAddr, code) + `">` + s.verifyUrl(did, emAddr, code) + `</a></p>`,137137- })138138-139139- if err != nil {140140- log.Printf("sending email: %s", err)141141- s.pages.Notice(w, "settings-emails-error", "Unable to send verification email at this moment, try again later.")9797+ if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {14298 return14399 }144100···208192 }209193210194 http.Redirect(w, r, "/settings", http.StatusSeeOther)195195+}196196+197197+func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {198198+ if r.Method != http.MethodPost {199199+ s.pages.Notice(w, "settings-emails-error", "Invalid request method.")200200+ return201201+ }202202+203203+ did := s.auth.GetDid(r)204204+ emAddr := r.FormValue("email")205205+ emAddr = strings.TrimSpace(emAddr)206206+207207+ if !email.IsValidEmail(emAddr) {208208+ s.pages.Notice(w, "settings-emails-error", "Invalid email address.")209209+ return210210+ }211211+212212+ // Check if email exists and is unverified213213+ existingEmail, err := db.GetEmail(s.db, did, emAddr)214214+ if err != nil {215215+ if errors.Is(err, sql.ErrNoRows) {216216+ s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")217217+ } else {218218+ log.Printf("checking for existing email: %s", err)219219+ s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")220220+ }221221+ return222222+ }223223+224224+ if existingEmail.Verified {225225+ s.pages.Notice(w, "settings-emails-error", "This email is already verified.")226226+ return227227+ }228228+229229+ // Check if last verification email was sent less than 10 minutes ago230230+ if existingEmail.LastSent != nil {231231+ timeSinceLastSent := time.Since(*existingEmail.LastSent)232232+ if timeSinceLastSent < 10*time.Minute {233233+ waitTime := 10*time.Minute - timeSinceLastSent234234+ s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))235235+ return236236+ }237237+ }238238+239239+ // Generate new verification code240240+ code := uuid.New().String()241241+242242+ // Begin transaction243243+ tx, err := s.db.Begin()244244+ if err != nil {245245+ log.Printf("failed to start transaction: %s", err)246246+ s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")247247+ return248248+ }249249+ defer tx.Rollback()250250+251251+ // Update the verification code and last sent time252252+ if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {253253+ log.Printf("updating email verification: %s", err)254254+ s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")255255+ return256256+ }257257+258258+ // Send verification email259259+ if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {260260+ return261261+ }262262+263263+ // Commit transaction264264+ if err := tx.Commit(); err != nil {265265+ log.Printf("failed to commit transaction: %s", err)266266+ s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")267267+ return268268+ }269269+270270+ s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")211271}212272213273func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {