From a58b1afe5180deabb78b467bc69ba61745ac05bb Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 20 Mar 2025 16:49:57 +0200 Subject: [PATCH] appview: email: add, delete, make primary Setup verification emails and future transactional emails using Resend. --- appview/config.go | 1 + appview/db/db.go | 13 +- appview/db/email.go | 203 ++++++++++++++++++++++++++ appview/email/email.go | 69 +++++++++ appview/pages/pages.go | 1 + appview/pages/templates/settings.html | 82 ++++++++++- appview/state/router.go | 4 + appview/state/settings.go | 187 +++++++++++++++++++++++- go.mod | 1 + go.sum | 2 + input.css | 2 +- 11 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 appview/db/email.go create mode 100644 appview/email/email.go diff --git a/appview/config.go b/appview/config.go index 950b8be8..be28f2ca 100644 --- a/appview/config.go +++ b/appview/config.go @@ -12,6 +12,7 @@ type Config struct { ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"` Dev bool `env:"TANGLED_DEV, default=false"` JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` + ResendApiKey string `env:"TANGLED_RESEND_API_KEY"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/db/db.go b/appview/db/db.go index 01809ee1..b2147576 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -107,7 +107,7 @@ func Make(dbPath string) (*DB, error) { -- identifiers id integer primary key autoincrement, pull_id integer not null, - + -- at identifiers repo_at text not null, owner_did text not null, @@ -196,6 +196,17 @@ func Make(dbPath string) (*DB, error) { unique(starred_by_did, repo_at) ); + create table if not exists emails ( + id integer primary key autoincrement, + did text not null, + email text not null, + verified integer not null default 0, + verification_code text not null, + is_primary integer not null default 0, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + unique(did, email) + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique diff --git a/appview/db/email.go b/appview/db/email.go new file mode 100644 index 00000000..5dbbfe96 --- /dev/null +++ b/appview/db/email.go @@ -0,0 +1,203 @@ +package db + +import "time" + +type Email struct { + ID int64 + Did string + Address string + Verified bool + Primary bool + VerificationCode string + CreatedAt time.Time +} + +func GetPrimaryEmail(e Execer, did string) (Email, error) { + query := ` + select id, did, email, verified, is_primary, verification_code, created + from emails + where did = ? and is_primary = true + ` + var email Email + var createdStr string + err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return Email{}, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return Email{}, err + } + return email, nil +} + +func GetEmail(e Execer, did string, em string) (Email, error) { + query := ` + select id, did, email, verified, is_primary, verification_code, created + from emails + where did = ? and email = ? + ` + var email Email + var createdStr string + err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return Email{}, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return Email{}, err + } + return email, nil +} + +func GetDidForEmail(e Execer, em string) (string, error) { + query := ` + select did + from emails + where email = ? + ` + var did string + err := e.QueryRow(query, em).Scan(&did) + if err != nil { + return "", err + } + return did, nil +} + +func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) { + query := ` + select verification_code + from emails + where did = ? and email = ? + ` + var code string + err := e.QueryRow(query, did, email).Scan(&code) + if err != nil { + return "", err + } + return code, nil +} + +func CheckEmailExists(e Execer, did string, email string) (bool, error) { + query := ` + select count(*) + from emails + where did = ? and email = ? + ` + var count int + err := e.QueryRow(query, did, email).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) { + query := ` + select count(*) + from emails + where did = ? and email = ? and verification_code = ? + ` + var count int + err := e.QueryRow(query, did, email, code).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func AddEmail(e Execer, email Email) error { + // Check if this is the first email for this DID + countQuery := ` + select count(*) + from emails + where did = ? + ` + var count int + err := e.QueryRow(countQuery, email.Did).Scan(&count) + if err != nil { + return err + } + + // If this is the first email, mark it as primary + if count == 0 { + email.Primary = true + } + + query := ` + insert into emails (did, email, verified, is_primary, verification_code) + values (?, ?, ?, ?, ?) + ` + _, err = e.Exec(query, email.Did, email.Address, email.Verified, email.Primary, email.VerificationCode) + return err +} + +func DeleteEmail(e Execer, did string, email string) error { + query := ` + delete from emails + where did = ? and email = ? + ` + _, err := e.Exec(query, did, email) + return err +} + +func MarkEmailVerified(e Execer, did string, email string) error { + query := ` + update emails + set verified = true + where did = ? and email = ? + ` + _, err := e.Exec(query, did, email) + return err +} + +func MakeEmailPrimary(e Execer, did string, email string) error { + // First, unset all primary emails for this DID + query1 := ` + update emails + set is_primary = false + where did = ? + ` + _, err := e.Exec(query1, did) + if err != nil { + return err + } + + // Then, set the specified email as primary + query2 := ` + update emails + set is_primary = true + where did = ? and email = ? + ` + _, err = e.Exec(query2, did, email) + return err +} + +func GetAllEmails(e Execer, did string) ([]Email, error) { + query := ` + select did, email, verified, is_primary, verification_code, created + from emails + where did = ? + ` + rows, err := e.Query(query, did) + if err != nil { + return nil, err + } + defer rows.Close() + + var emails []Email + for rows.Next() { + var email Email + var createdStr string + err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return nil, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return nil, err + } + emails = append(emails, email) + } + return emails, nil +} diff --git a/appview/email/email.go b/appview/email/email.go new file mode 100644 index 00000000..6ad23824 --- /dev/null +++ b/appview/email/email.go @@ -0,0 +1,69 @@ +package email + +import ( + "fmt" + "net" + "regexp" + "strings" + + "github.com/resend/resend-go/v2" +) + +type Email struct { + From string + To string + Subject string + Text string + Html string + APIKey string +} + +func SendEmail(email Email) error { + client := resend.NewClient(email.APIKey) + _, err := client.Emails.Send(&resend.SendEmailRequest{ + From: email.From, + To: []string{email.To}, + Subject: email.Subject, + Text: email.Text, + Html: email.Html, + }) + if err != nil { + return fmt.Errorf("error sending email: %w", err) + } + return nil +} + +func IsValidEmail(email string) bool { + // Basic length check + if len(email) < 3 || len(email) > 254 { + return false + } + + // Regular expression for email validation (RFC 5322 compliant) + pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + + // Compile regex + regex := regexp.MustCompile(pattern) + + // Check if email matches regex pattern + if !regex.MatchString(email) { + return false + } + + // Split email into local and domain parts + parts := strings.Split(email, "@") + domain := parts[1] + + mx, err := net.LookupMX(domain) + if err != nil || len(mx) == 0 { + return false + } + + return true +} + +func IsValidEmailSimple(email string) bool { + pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + regex := regexp.MustCompile(pattern) + return regex.MatchString(email) && len(email) <= 254 && len(email) >= 3 +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index e3e38d7a..d7b9fee8 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -120,6 +120,7 @@ func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { type SettingsParams struct { LoggedInUser *auth.User PubKeys []db.PublicKey + Emails []db.Email } 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 d5e9d63d..750d5255 100644 --- a/appview/pages/templates/settings.html +++ b/appview/pages/templates/settings.html @@ -8,6 +8,7 @@ {{ block "profile" . }} {{ end }} {{ block "keys" . }} {{ end }} {{ block "knots" . }} {{ end }} + {{ block "emails" . }} {{ end }} {{ end }} @@ -31,7 +32,7 @@

ssh keys

- {{ range .PubKeys }} + {{ range $index, $key := .PubKeys }}
@@ -39,7 +40,7 @@

{{ .Name }}

added {{ .Created | timeFmt }}

- {{ .Key }} + {{ .Key }}
{{ end }} + {{ if .PubKeys }} +
+ {{ end }}
-

add an ssh key

{{ end }} + +{{ define "emails" }} +

email addresses

+
+
+ {{ range $index, $email := .Emails }} +
+
+
+ +

{{ .Address }}

+

added {{ .CreatedAt | timeFmt }}

+ {{ if .Verified }} + verified + {{ else }} + unverified + {{ end }} + {{ if .Primary }} + primary + {{ end }} +
+
+
+ {{ if not .Primary }} + + set as primary + + {{ end }} + {{ if not .Primary }} + + + + + {{ end }} +
+
+ {{ end }} + {{ if .Emails }} +
+ {{ end }} +
+

add an email address

+
+ + + + +
+
+ +
+
+{{ end }} diff --git a/appview/state/router.go b/appview/state/router.go index 22f6a0f7..4198ca3f 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -167,6 +167,10 @@ func (s *State) StandardRouter() http.Handler { r.Use(AuthMiddleware(s)) r.Get("/", s.Settings) r.Put("/keys", s.SettingsKeys) + r.Put("/emails", s.SettingsEmails) + r.Delete("/emails", s.SettingsEmails) + r.Get("/emails/verify", s.SettingsEmailsVerify) + r.Post("/emails/primary", s.SettingsEmailsPrimary) }) r.Get("/keys/{user}", s.Keys) diff --git a/appview/state/settings.go b/appview/state/settings.go index 4ed2a019..bdc3d596 100644 --- a/appview/state/settings.go +++ b/appview/state/settings.go @@ -1,6 +1,9 @@ package state import ( + "database/sql" + "errors" + "fmt" "log" "net/http" "strings" @@ -9,25 +12,207 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/gliderlabs/ssh" + "github.com/google/uuid" "github.com/sotangled/tangled/api/tangled" "github.com/sotangled/tangled/appview/db" + "github.com/sotangled/tangled/appview/email" "github.com/sotangled/tangled/appview/pages" ) func (s *State) Settings(w http.ResponseWriter, r *http.Request) { - // for now, this is just pubkeys user := s.auth.GetUser(r) pubKeys, err := db.GetPublicKeys(s.db, user.Did) if err != nil { log.Println(err) } + 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, }) } +func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.pages.Notice(w, "settings-emails", "Unimplemented.") + log.Println("unimplemented") + return + case http.MethodPut: + did := s.auth.GetDid(r) + emAddr := r.FormValue("email") + emAddr = strings.TrimSpace(emAddr) + + if !email.IsValidEmail(emAddr) { + s.pages.Notice(w, "settings-emails-error", "Invalid email address.") + return + } + + // check if email already exists in database + existingEmail, err := db.GetEmail(s.db, did, emAddr) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("checking for existing email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") + return + } + + if err == nil { + if existingEmail.Verified { + s.pages.Notice(w, "settings-emails-error", "This email is already verified.") + return + } + + s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") + return + } + + code := uuid.New().String() + + // Begin transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("failed to start transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") + return + } + defer tx.Rollback() + + if err := db.AddEmail(tx, db.Email{ + Did: did, + Address: emAddr, + Verified: false, + VerificationCode: code, + }); err != nil { + log.Printf("adding email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") + return + } + + err = email.SendEmail(email.Email{ + APIKey: s.config.ResendApiKey, + + From: "noreply@notifs.tangled.sh", + To: emAddr, + Subject: "Verify your Tangled email", + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. +` + s.verifyUrl(did, emAddr, code), + Html: `

Click the link (or copy and paste it into your browser) to verify your email address.

+

` + s.verifyUrl(did, emAddr, code) + `

`, + }) + + if err != nil { + log.Printf("sending email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to send verification email at this moment, try again later.") + return + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("failed to commit transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") + return + } + + s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") + return + case http.MethodDelete: + did := s.auth.GetDid(r) + emailAddr := r.FormValue("email") + emailAddr = strings.TrimSpace(emailAddr) + + // Begin transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("failed to start transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + defer tx.Rollback() + + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { + log.Printf("deleting email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("failed to commit transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + + s.pages.HxLocation(w, "/settings") + return + } +} + +func (s *State) verifyUrl(did string, email string, code string) string { + var appUrl string + if s.config.Dev { + appUrl = "http://" + s.config.ListenAddr + } else { + appUrl = "https://tangled.sh" + } + + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, did, email, code) +} + +func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + // Get the parameters directly from the query + emailAddr := q.Get("email") + did := q.Get("did") + code := q.Get("code") + + valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) + if err != nil { + log.Printf("checking email verification: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") + return + } + + if !valid { + s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") + return + } + + // Mark email as verified in the database + if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { + log.Printf("marking email as verified: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") + return + } + + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} + +func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { + did := s.auth.GetDid(r) + emailAddr := r.FormValue("email") + emailAddr = strings.TrimSpace(emailAddr) + + if emailAddr == "" { + s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") + return + } + + if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { + log.Printf("setting primary email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") + return + } + + s.pages.HxLocation(w, "/settings") +} + func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/go.mod b/go.mod index d37b2827..b79cc791 100644 --- a/go.mod +++ b/go.mod @@ -92,6 +92,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.54.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/resend/resend-go/v2 v2.15.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect diff --git a/go.sum b/go.sum index be8340ec..20e3ac7a 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,8 @@ github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= +github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= diff --git a/input.css b/input.css index bdce9b6e..fa3db545 100644 --- a/input.css +++ b/input.css @@ -149,7 +149,7 @@ @apply py-1 text-red-400; } .success { - @apply py-1 text-green-400; + @apply py-1 text-gray-900; } } } -- 2.54.0