forked from tangled.org/core
this repo has no description

appview: email: add, delete, make primary

Setup verification emails and future transactional emails using
Resend.

Changed files
+559 -6
appview
+1
appview/config.go
···
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) {
+12 -1
appview/db/db.go
···
-- identifiers
id integer primary key autoincrement,
pull_id integer not null,
-
+
-- at identifiers
repo_at text not null,
owner_did text not null,
···
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
foreign key (repo_at) references repos(at_uri) on delete cascade,
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 (
+203
appview/db/email.go
···
+
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
+
}
+69
appview/email/email.go
···
+
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
+
}
+1
appview/pages/pages.go
···
type SettingsParams struct {
LoggedInUser *auth.User
PubKeys []db.PublicKey
+
Emails []db.Email
}
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
+79 -3
appview/pages/templates/settings.html
···
{{ block "profile" . }} {{ end }}
{{ block "keys" . }} {{ end }}
{{ block "knots" . }} {{ end }}
+
{{ block "emails" . }} {{ end }}
</div>
{{ end }}
···
<h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2>
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
<div id="key-list" class="flex flex-col gap-6 mb-8">
-
{{ range .PubKeys }}
+
{{ range $index, $key := .PubKeys }}
<div class="flex justify-between items-center gap-4">
<div>
<div class="inline-flex items-center gap-4">
···
<p class="font-bold">{{ .Name }}</p>
<p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p>
</div>
-
<code class="block break-all text-sm break-all text-gray-500">{{ .Key }}</code>
+
<code class="block break-all text-sm text-gray-500">{{ .Key }}</code>
</div>
<button
class="btn text-red-500 hover:text-red-700"
···
</button>
</div>
{{ end }}
+
{{ if .PubKeys }}
+
<hr class="mb-4" />
+
{{ end }}
</div>
-
<hr class="mb-4" />
<p class="mb-2">add an ssh key</p>
<form
hx-put="/settings/keys"
···
</form>
</section>
{{ end }}
+
+
{{ define "emails" }}
+
<h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2>
+
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<div id="email-list" class="flex flex-col gap-6 mb-8">
+
{{ range $index, $email := .Emails }}
+
<div class="flex justify-between items-center gap-4">
+
<div>
+
<div class="inline-flex items-center gap-4">
+
<i class="w-3 h-3" data-lucide="mail"></i>
+
<p class="font-bold">{{ .Address }}</p>
+
<p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p>
+
{{ if .Verified }}
+
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span>
+
{{ else }}
+
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span>
+
{{ end }}
+
{{ if .Primary }}
+
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span>
+
{{ end }}
+
</div>
+
</div>
+
<div class="flex gap-2 items-center">
+
{{ if not .Primary }}
+
<a
+
class="text-sm"
+
hx-post="/settings/emails/primary"
+
hx-swap="none"
+
href="#"
+
hx-vals='{"email": "{{ .Address }}"}'>
+
set as primary
+
</a>
+
{{ end }}
+
{{ if not .Primary }}
+
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
+
<input type="hidden" name="email" value="{{ .Address }}">
+
<button
+
class="btn text-red-500 hover:text-red-700"
+
title="Delete email"
+
type="submit">
+
<i class="w-5 h-5" data-lucide="trash-2"></i>
+
</button>
+
</form>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
{{ if .Emails }}
+
<hr class="mb-4" />
+
{{ end }}
+
</div>
+
<p class="mb-2">add an email address</p>
+
<form
+
hx-put="/settings/emails"
+
hx-swap="none"
+
class="max-w-2xl mb-8 space-y-4"
+
>
+
<input
+
type="email"
+
id="email"
+
name="email"
+
placeholder="your@email.com"
+
required
+
class="w-full"/>
+
+
<button class="btn w-full" type="submit">add email</button>
+
+
<div id="settings-emails-error" class="error"></div>
+
<div id="settings-emails-success" class="success"></div>
+
+
</form>
+
</section>
+
{{ end }}
+4
appview/state/router.go
···
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)
+186 -1
appview/state/settings.go
···
package state
import (
+
"database/sql"
+
"errors"
+
"fmt"
"log"
"net/http"
"strings"
···
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: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
+
<p><a href="` + s.verifyUrl(did, emAddr, code) + `">` + s.verifyUrl(did, emAddr, code) + `</a></p>`,
+
})
+
+
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) {
+1
go.mod
···
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
+2
go.sum
···
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=
+1 -1
input.css
···
@apply py-1 text-red-400;
}
.success {
-
@apply py-1 text-green-400;
+
@apply py-1 text-gray-900;
}
}
}