Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview: email: add, delete, make primary

Setup verification emails and future transactional emails using
Resend.

+559 -6
+1
appview/config.go
··· 12 12 ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"` 13 13 Dev bool `env:"TANGLED_DEV, default=false"` 14 14 JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 15 + ResendApiKey string `env:"TANGLED_RESEND_API_KEY"` 15 16 } 16 17 17 18 func LoadConfig(ctx context.Context) (*Config, error) {
+12 -1
appview/db/db.go
··· 107 107 -- identifiers 108 108 id integer primary key autoincrement, 109 109 pull_id integer not null, 110 - 110 + 111 111 -- at identifiers 112 112 repo_at text not null, 113 113 owner_did text not null, ··· 194 194 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 195 195 foreign key (repo_at) references repos(at_uri) on delete cascade, 196 196 unique(starred_by_did, repo_at) 197 + ); 198 + 199 + create table if not exists emails ( 200 + id integer primary key autoincrement, 201 + did text not null, 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) 197 208 ); 198 209 199 210 create table if not exists migrations (
+203
appview/db/email.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type Email struct { 6 + ID int64 7 + Did string 8 + Address string 9 + Verified bool 10 + Primary bool 11 + VerificationCode string 12 + CreatedAt time.Time 13 + } 14 + 15 + func GetPrimaryEmail(e Execer, did string) (Email, error) { 16 + query := ` 17 + select id, did, email, verified, is_primary, verification_code, created 18 + from emails 19 + where did = ? and is_primary = true 20 + ` 21 + var email Email 22 + var createdStr string 23 + err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 24 + if err != nil { 25 + return Email{}, err 26 + } 27 + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 28 + if err != nil { 29 + return Email{}, err 30 + } 31 + return email, nil 32 + } 33 + 34 + func GetEmail(e Execer, did string, em string) (Email, error) { 35 + query := ` 36 + select id, did, email, verified, is_primary, verification_code, created 37 + from emails 38 + where did = ? and email = ? 39 + ` 40 + var email Email 41 + var createdStr string 42 + err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 43 + if err != nil { 44 + return Email{}, err 45 + } 46 + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 47 + if err != nil { 48 + return Email{}, err 49 + } 50 + return email, nil 51 + } 52 + 53 + func GetDidForEmail(e Execer, em string) (string, error) { 54 + query := ` 55 + select did 56 + from emails 57 + where email = ? 58 + ` 59 + var did string 60 + err := e.QueryRow(query, em).Scan(&did) 61 + if err != nil { 62 + return "", err 63 + } 64 + return did, nil 65 + } 66 + 67 + func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) { 68 + query := ` 69 + select verification_code 70 + from emails 71 + where did = ? and email = ? 72 + ` 73 + var code string 74 + err := e.QueryRow(query, did, email).Scan(&code) 75 + if err != nil { 76 + return "", err 77 + } 78 + return code, nil 79 + } 80 + 81 + func CheckEmailExists(e Execer, did string, email string) (bool, error) { 82 + query := ` 83 + select count(*) 84 + from emails 85 + where did = ? and email = ? 86 + ` 87 + var count int 88 + err := e.QueryRow(query, did, email).Scan(&count) 89 + if err != nil { 90 + return false, err 91 + } 92 + return count > 0, nil 93 + } 94 + 95 + func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) { 96 + query := ` 97 + select count(*) 98 + from emails 99 + where did = ? and email = ? and verification_code = ? 100 + ` 101 + var count int 102 + err := e.QueryRow(query, did, email, code).Scan(&count) 103 + if err != nil { 104 + return false, err 105 + } 106 + return count > 0, nil 107 + } 108 + 109 + func AddEmail(e Execer, email Email) error { 110 + // Check if this is the first email for this DID 111 + countQuery := ` 112 + select count(*) 113 + from emails 114 + where did = ? 115 + ` 116 + var count int 117 + err := e.QueryRow(countQuery, email.Did).Scan(&count) 118 + if err != nil { 119 + return err 120 + } 121 + 122 + // If this is the first email, mark it as primary 123 + if count == 0 { 124 + email.Primary = true 125 + } 126 + 127 + query := ` 128 + insert into emails (did, email, verified, is_primary, verification_code) 129 + values (?, ?, ?, ?, ?) 130 + ` 131 + _, err = e.Exec(query, email.Did, email.Address, email.Verified, email.Primary, email.VerificationCode) 132 + return err 133 + } 134 + 135 + func DeleteEmail(e Execer, did string, email string) error { 136 + query := ` 137 + delete from emails 138 + where did = ? and email = ? 139 + ` 140 + _, err := e.Exec(query, did, email) 141 + return err 142 + } 143 + 144 + func MarkEmailVerified(e Execer, did string, email string) error { 145 + query := ` 146 + update emails 147 + set verified = true 148 + where did = ? and email = ? 149 + ` 150 + _, err := e.Exec(query, did, email) 151 + return err 152 + } 153 + 154 + func MakeEmailPrimary(e Execer, did string, email string) error { 155 + // First, unset all primary emails for this DID 156 + query1 := ` 157 + update emails 158 + set is_primary = false 159 + where did = ? 160 + ` 161 + _, err := e.Exec(query1, did) 162 + if err != nil { 163 + return err 164 + } 165 + 166 + // Then, set the specified email as primary 167 + query2 := ` 168 + update emails 169 + set is_primary = true 170 + where did = ? and email = ? 171 + ` 172 + _, err = e.Exec(query2, did, email) 173 + return err 174 + } 175 + 176 + func GetAllEmails(e Execer, did string) ([]Email, error) { 177 + query := ` 178 + select did, email, verified, is_primary, verification_code, created 179 + from emails 180 + where did = ? 181 + ` 182 + rows, err := e.Query(query, did) 183 + if err != nil { 184 + return nil, err 185 + } 186 + defer rows.Close() 187 + 188 + var emails []Email 189 + for rows.Next() { 190 + var email Email 191 + var createdStr string 192 + err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) 193 + if err != nil { 194 + return nil, err 195 + } 196 + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 197 + if err != nil { 198 + return nil, err 199 + } 200 + emails = append(emails, email) 201 + } 202 + return emails, nil 203 + }
+69
appview/email/email.go
··· 1 + package email 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "regexp" 7 + "strings" 8 + 9 + "github.com/resend/resend-go/v2" 10 + ) 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 { 37 + // Basic length check 38 + if len(email) < 3 || len(email) > 254 { 39 + return false 40 + } 41 + 42 + // Regular expression for email validation (RFC 5322 compliant) 43 + 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])?)*$` 44 + 45 + // Compile regex 46 + regex := regexp.MustCompile(pattern) 47 + 48 + // Check if email matches regex pattern 49 + if !regex.MatchString(email) { 50 + return false 51 + } 52 + 53 + // Split email into local and domain parts 54 + parts := strings.Split(email, "@") 55 + domain := parts[1] 56 + 57 + mx, err := net.LookupMX(domain) 58 + if err != nil || len(mx) == 0 { 59 + return false 60 + } 61 + 62 + return true 63 + } 64 + 65 + func IsValidEmailSimple(email string) bool { 66 + 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])?)*$` 67 + regex := regexp.MustCompile(pattern) 68 + return regex.MatchString(email) && len(email) <= 254 && len(email) >= 3 69 + }
+1
appview/pages/pages.go
··· 120 120 type SettingsParams struct { 121 121 LoggedInUser *auth.User 122 122 PubKeys []db.PublicKey 123 + Emails []db.Email 123 124 } 124 125 125 126 func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
+79 -3
appview/pages/templates/settings.html
··· 8 8 {{ block "profile" . }} {{ end }} 9 9 {{ block "keys" . }} {{ end }} 10 10 {{ block "knots" . }} {{ end }} 11 + {{ block "emails" . }} {{ end }} 11 12 </div> 12 13 {{ end }} 13 14 ··· 32 31 <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 33 32 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 34 33 <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range .PubKeys }} 34 + {{ range $index, $key := .PubKeys }} 36 35 <div class="flex justify-between items-center gap-4"> 37 36 <div> 38 37 <div class="inline-flex items-center gap-4"> ··· 40 39 <p class="font-bold">{{ .Name }}</p> 41 40 <p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p> 42 41 </div> 43 - <code class="block break-all text-sm break-all text-gray-500">{{ .Key }}</code> 42 + <code class="block break-all text-sm text-gray-500">{{ .Key }}</code> 44 43 </div> 45 44 <button 46 45 class="btn text-red-500 hover:text-red-700" ··· 51 50 </button> 52 51 </div> 53 52 {{ end }} 53 + {{ if .PubKeys }} 54 + <hr class="mb-4" /> 55 + {{ end }} 54 56 </div> 55 - <hr class="mb-4" /> 56 57 <p class="mb-2">add an ssh key</p> 57 58 <form 58 59 hx-put="/settings/keys" ··· 79 76 <button class="btn w-full" type="submit">add key</button> 80 77 81 78 <div id="settings-keys" class="error"></div> 79 + </form> 80 + </section> 81 + {{ end }} 82 + 83 + {{ define "emails" }} 84 + <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 85 + <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 86 + <div id="email-list" class="flex flex-col gap-6 mb-8"> 87 + {{ range $index, $email := .Emails }} 88 + <div class="flex justify-between items-center gap-4"> 89 + <div> 90 + <div class="inline-flex items-center gap-4"> 91 + <i class="w-3 h-3" data-lucide="mail"></i> 92 + <p class="font-bold">{{ .Address }}</p> 93 + <p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p> 94 + {{ if .Verified }} 95 + <span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span> 96 + {{ else }} 97 + <span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span> 98 + {{ end }} 99 + {{ if .Primary }} 100 + <span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span> 101 + {{ end }} 102 + </div> 103 + </div> 104 + <div class="flex gap-2 items-center"> 105 + {{ if not .Primary }} 106 + <a 107 + class="text-sm" 108 + hx-post="/settings/emails/primary" 109 + hx-swap="none" 110 + href="#" 111 + hx-vals='{"email": "{{ .Address }}"}'> 112 + set as primary 113 + </a> 114 + {{ end }} 115 + {{ if not .Primary }} 116 + <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 117 + <input type="hidden" name="email" value="{{ .Address }}"> 118 + <button 119 + class="btn text-red-500 hover:text-red-700" 120 + title="Delete email" 121 + type="submit"> 122 + <i class="w-5 h-5" data-lucide="trash-2"></i> 123 + </button> 124 + </form> 125 + {{ end }} 126 + </div> 127 + </div> 128 + {{ end }} 129 + {{ if .Emails }} 130 + <hr class="mb-4" /> 131 + {{ end }} 132 + </div> 133 + <p class="mb-2">add an email address</p> 134 + <form 135 + hx-put="/settings/emails" 136 + hx-swap="none" 137 + class="max-w-2xl mb-8 space-y-4" 138 + > 139 + <input 140 + type="email" 141 + id="email" 142 + name="email" 143 + placeholder="your@email.com" 144 + required 145 + class="w-full"/> 146 + 147 + <button class="btn w-full" type="submit">add email</button> 148 + 149 + <div id="settings-emails-error" class="error"></div> 150 + <div id="settings-emails-success" class="success"></div> 151 + 82 152 </form> 83 153 </section> 84 154 {{ end }}
+4
appview/state/router.go
··· 167 167 r.Use(AuthMiddleware(s)) 168 168 r.Get("/", s.Settings) 169 169 r.Put("/keys", s.SettingsKeys) 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) 170 174 }) 171 175 172 176 r.Get("/keys/{user}", s.Keys)
+186 -1
appview/state/settings.go
··· 1 1 package state 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 4 7 "log" 5 8 "net/http" 6 9 "strings" ··· 12 9 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 10 lexutil "github.com/bluesky-social/indigo/lex/util" 14 11 "github.com/gliderlabs/ssh" 12 + "github.com/google/uuid" 15 13 "github.com/sotangled/tangled/api/tangled" 16 14 "github.com/sotangled/tangled/appview/db" 15 + "github.com/sotangled/tangled/appview/email" 17 16 "github.com/sotangled/tangled/appview/pages" 18 17 ) 19 18 20 19 func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 21 - // for now, this is just pubkeys 22 20 user := s.auth.GetUser(r) 23 21 pubKeys, err := db.GetPublicKeys(s.db, user.Did) 22 + if err != nil { 23 + log.Println(err) 24 + } 25 + 26 + emails, err := db.GetAllEmails(s.db, user.Did) 24 27 if err != nil { 25 28 log.Println(err) 26 29 } ··· 34 25 s.pages.Settings(w, pages.SettingsParams{ 35 26 LoggedInUser: user, 36 27 PubKeys: pubKeys, 28 + Emails: emails, 37 29 }) 30 + } 31 + 32 + func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 33 + switch r.Method { 34 + case http.MethodGet: 35 + s.pages.Notice(w, "settings-emails", "Unimplemented.") 36 + log.Println("unimplemented") 37 + return 38 + case http.MethodPut: 39 + did := s.auth.GetDid(r) 40 + emAddr := r.FormValue("email") 41 + emAddr = strings.TrimSpace(emAddr) 42 + 43 + if !email.IsValidEmail(emAddr) { 44 + s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 45 + return 46 + } 47 + 48 + // check if email already exists in database 49 + existingEmail, err := db.GetEmail(s.db, did, emAddr) 50 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 51 + log.Printf("checking for existing email: %s", err) 52 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 53 + return 54 + } 55 + 56 + if err == nil { 57 + if existingEmail.Verified { 58 + s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 59 + return 60 + } 61 + 62 + s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 63 + return 64 + } 65 + 66 + code := uuid.New().String() 67 + 68 + // Begin transaction 69 + tx, err := s.db.Begin() 70 + if err != nil { 71 + log.Printf("failed to start transaction: %s", err) 72 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 73 + return 74 + } 75 + defer tx.Rollback() 76 + 77 + if err := db.AddEmail(tx, db.Email{ 78 + Did: did, 79 + Address: emAddr, 80 + Verified: false, 81 + VerificationCode: code, 82 + }); err != nil { 83 + log.Printf("adding email: %s", err) 84 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 85 + return 86 + } 87 + 88 + err = email.SendEmail(email.Email{ 89 + APIKey: s.config.ResendApiKey, 90 + 91 + From: "noreply@notifs.tangled.sh", 92 + To: emAddr, 93 + Subject: "Verify your Tangled email", 94 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 95 + ` + s.verifyUrl(did, emAddr, code), 96 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 97 + <p><a href="` + s.verifyUrl(did, emAddr, code) + `">` + s.verifyUrl(did, emAddr, code) + `</a></p>`, 98 + }) 99 + 100 + if err != nil { 101 + log.Printf("sending email: %s", err) 102 + s.pages.Notice(w, "settings-emails-error", "Unable to send verification email at this moment, try again later.") 103 + return 104 + } 105 + 106 + // Commit transaction 107 + if err := tx.Commit(); err != nil { 108 + log.Printf("failed to commit transaction: %s", err) 109 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 110 + return 111 + } 112 + 113 + s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 114 + return 115 + case http.MethodDelete: 116 + did := s.auth.GetDid(r) 117 + emailAddr := r.FormValue("email") 118 + emailAddr = strings.TrimSpace(emailAddr) 119 + 120 + // Begin transaction 121 + tx, err := s.db.Begin() 122 + if err != nil { 123 + log.Printf("failed to start transaction: %s", err) 124 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 125 + return 126 + } 127 + defer tx.Rollback() 128 + 129 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 130 + log.Printf("deleting email: %s", err) 131 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 132 + return 133 + } 134 + 135 + // Commit transaction 136 + if err := tx.Commit(); err != nil { 137 + log.Printf("failed to commit transaction: %s", err) 138 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 139 + return 140 + } 141 + 142 + s.pages.HxLocation(w, "/settings") 143 + return 144 + } 145 + } 146 + 147 + func (s *State) verifyUrl(did string, email string, code string) string { 148 + var appUrl string 149 + if s.config.Dev { 150 + appUrl = "http://" + s.config.ListenAddr 151 + } else { 152 + appUrl = "https://tangled.sh" 153 + } 154 + 155 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, did, email, code) 156 + } 157 + 158 + func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 159 + q := r.URL.Query() 160 + 161 + // Get the parameters directly from the query 162 + emailAddr := q.Get("email") 163 + did := q.Get("did") 164 + code := q.Get("code") 165 + 166 + valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 167 + if err != nil { 168 + log.Printf("checking email verification: %s", err) 169 + s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 170 + return 171 + } 172 + 173 + if !valid { 174 + s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 175 + return 176 + } 177 + 178 + // Mark email as verified in the database 179 + if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 180 + log.Printf("marking email as verified: %s", err) 181 + s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 182 + return 183 + } 184 + 185 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 186 + } 187 + 188 + func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 189 + did := s.auth.GetDid(r) 190 + emailAddr := r.FormValue("email") 191 + emailAddr = strings.TrimSpace(emailAddr) 192 + 193 + if emailAddr == "" { 194 + s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 195 + return 196 + } 197 + 198 + if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 199 + log.Printf("setting primary email: %s", err) 200 + s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 201 + return 202 + } 203 + 204 + s.pages.HxLocation(w, "/settings") 38 205 } 39 206 40 207 func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
+1
go.mod
··· 92 92 github.com/prometheus/client_model v0.6.1 // indirect 93 93 github.com/prometheus/common v0.54.0 // indirect 94 94 github.com/prometheus/procfs v0.15.1 // indirect 95 + github.com/resend/resend-go/v2 v2.15.0 // indirect 95 96 github.com/sergi/go-diff v1.3.1 // indirect 96 97 github.com/skeema/knownhosts v1.3.1 // indirect 97 98 github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
··· 224 224 github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= 225 225 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 226 226 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 227 + github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 228 + github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 227 229 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 228 230 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 229 231 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+1 -1
input.css
··· 149 149 @apply py-1 text-red-400; 150 150 } 151 151 .success { 152 - @apply py-1 text-green-400; 152 + @apply py-1 text-gray-900; 153 153 } 154 154 } 155 155 }