Monorepo for Tangled tangled.org

appview: email: add, delete, make primary

Setup verification emails and future transactional emails using
Resend.

Changed files
+559 -6
appview
+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 ··· 31 32 <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 32 33 <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 34 <div id="key-list" class="flex flex-col gap-6 mb-8"> 34 - {{ range .PubKeys }} 35 + {{ range $index, $key := .PubKeys }} 35 36 <div class="flex justify-between items-center gap-4"> 36 37 <div> 37 38 <div class="inline-flex items-center gap-4"> ··· 39 40 <p class="font-bold">{{ .Name }}</p> 40 41 <p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p> 41 42 </div> 42 - <code class="block break-all text-sm break-all text-gray-500">{{ .Key }}</code> 43 + <code class="block break-all text-sm text-gray-500">{{ .Key }}</code> 43 44 </div> 44 45 <button 45 46 class="btn text-red-500 hover:text-red-700" ··· 50 51 </button> 51 52 </div> 52 53 {{ end }} 54 + {{ if .PubKeys }} 55 + <hr class="mb-4" /> 56 + {{ end }} 53 57 </div> 54 - <hr class="mb-4" /> 55 58 <p class="mb-2">add an ssh key</p> 56 59 <form 57 60 hx-put="/settings/keys" ··· 79 82 </form> 80 83 </section> 81 84 {{ end }} 85 + 86 + {{ define "emails" }} 87 + <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 88 + <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 89 + <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 + {{ range $index, $email := .Emails }} 91 + <div class="flex justify-between items-center gap-4"> 92 + <div> 93 + <div class="inline-flex items-center gap-4"> 94 + <i class="w-3 h-3" data-lucide="mail"></i> 95 + <p class="font-bold">{{ .Address }}</p> 96 + <p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p> 97 + {{ if .Verified }} 98 + <span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span> 99 + {{ else }} 100 + <span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span> 101 + {{ end }} 102 + {{ if .Primary }} 103 + <span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span> 104 + {{ end }} 105 + </div> 106 + </div> 107 + <div class="flex gap-2 items-center"> 108 + {{ if not .Primary }} 109 + <a 110 + class="text-sm" 111 + hx-post="/settings/emails/primary" 112 + hx-swap="none" 113 + href="#" 114 + hx-vals='{"email": "{{ .Address }}"}'> 115 + set as primary 116 + </a> 117 + {{ end }} 118 + {{ if not .Primary }} 119 + <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 120 + <input type="hidden" name="email" value="{{ .Address }}"> 121 + <button 122 + class="btn text-red-500 hover:text-red-700" 123 + title="Delete email" 124 + type="submit"> 125 + <i class="w-5 h-5" data-lucide="trash-2"></i> 126 + </button> 127 + </form> 128 + {{ end }} 129 + </div> 130 + </div> 131 + {{ end }} 132 + {{ if .Emails }} 133 + <hr class="mb-4" /> 134 + {{ end }} 135 + </div> 136 + <p class="mb-2">add an email address</p> 137 + <form 138 + hx-put="/settings/emails" 139 + hx-swap="none" 140 + class="max-w-2xl mb-8 space-y-4" 141 + > 142 + <input 143 + type="email" 144 + id="email" 145 + name="email" 146 + placeholder="your@email.com" 147 + required 148 + class="w-full"/> 149 + 150 + <button class="btn w-full" type="submit">add email</button> 151 + 152 + <div id="settings-emails-error" class="error"></div> 153 + <div id="settings-emails-success" class="success"></div> 154 + 155 + </form> 156 + </section> 157 + {{ 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" ··· 9 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 10 13 lexutil "github.com/bluesky-social/indigo/lex/util" 11 14 "github.com/gliderlabs/ssh" 15 + "github.com/google/uuid" 12 16 "github.com/sotangled/tangled/api/tangled" 13 17 "github.com/sotangled/tangled/appview/db" 18 + "github.com/sotangled/tangled/appview/email" 14 19 "github.com/sotangled/tangled/appview/pages" 15 20 ) 16 21 17 22 func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 18 - // for now, this is just pubkeys 19 23 user := s.auth.GetUser(r) 20 24 pubKeys, err := db.GetPublicKeys(s.db, user.Did) 21 25 if err != nil { 22 26 log.Println(err) 23 27 } 24 28 29 + emails, err := db.GetAllEmails(s.db, user.Did) 30 + if err != nil { 31 + log.Println(err) 32 + } 33 + 25 34 s.pages.Settings(w, pages.SettingsParams{ 26 35 LoggedInUser: user, 27 36 PubKeys: pubKeys, 37 + Emails: emails, 28 38 }) 39 + } 40 + 41 + func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 42 + switch r.Method { 43 + case http.MethodGet: 44 + s.pages.Notice(w, "settings-emails", "Unimplemented.") 45 + log.Println("unimplemented") 46 + return 47 + case http.MethodPut: 48 + did := s.auth.GetDid(r) 49 + emAddr := r.FormValue("email") 50 + emAddr = strings.TrimSpace(emAddr) 51 + 52 + if !email.IsValidEmail(emAddr) { 53 + s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 54 + return 55 + } 56 + 57 + // check if email already exists in database 58 + existingEmail, err := db.GetEmail(s.db, did, emAddr) 59 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 60 + log.Printf("checking for existing email: %s", err) 61 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 62 + return 63 + } 64 + 65 + if err == nil { 66 + if existingEmail.Verified { 67 + s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 68 + return 69 + } 70 + 71 + s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 72 + return 73 + } 74 + 75 + code := uuid.New().String() 76 + 77 + // Begin transaction 78 + tx, err := s.db.Begin() 79 + if err != nil { 80 + log.Printf("failed to start transaction: %s", err) 81 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 82 + return 83 + } 84 + defer tx.Rollback() 85 + 86 + if err := db.AddEmail(tx, db.Email{ 87 + Did: did, 88 + Address: emAddr, 89 + Verified: false, 90 + VerificationCode: code, 91 + }); err != nil { 92 + log.Printf("adding email: %s", err) 93 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 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 + 115 + // Commit transaction 116 + if err := tx.Commit(); err != nil { 117 + log.Printf("failed to commit transaction: %s", err) 118 + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 119 + return 120 + } 121 + 122 + s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 123 + return 124 + case http.MethodDelete: 125 + did := s.auth.GetDid(r) 126 + emailAddr := r.FormValue("email") 127 + emailAddr = strings.TrimSpace(emailAddr) 128 + 129 + // Begin transaction 130 + tx, err := s.db.Begin() 131 + if err != nil { 132 + log.Printf("failed to start transaction: %s", err) 133 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 134 + return 135 + } 136 + defer tx.Rollback() 137 + 138 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 139 + log.Printf("deleting email: %s", err) 140 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 141 + return 142 + } 143 + 144 + // Commit transaction 145 + if err := tx.Commit(); err != nil { 146 + log.Printf("failed to commit transaction: %s", err) 147 + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 148 + return 149 + } 150 + 151 + s.pages.HxLocation(w, "/settings") 152 + return 153 + } 154 + } 155 + 156 + func (s *State) verifyUrl(did string, email string, code string) string { 157 + var appUrl string 158 + if s.config.Dev { 159 + appUrl = "http://" + s.config.ListenAddr 160 + } else { 161 + appUrl = "https://tangled.sh" 162 + } 163 + 164 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, did, email, code) 165 + } 166 + 167 + func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 168 + q := r.URL.Query() 169 + 170 + // Get the parameters directly from the query 171 + emailAddr := q.Get("email") 172 + did := q.Get("did") 173 + code := q.Get("code") 174 + 175 + valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 176 + if err != nil { 177 + log.Printf("checking email verification: %s", err) 178 + s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 179 + return 180 + } 181 + 182 + if !valid { 183 + s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 184 + return 185 + } 186 + 187 + // Mark email as verified in the database 188 + if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 189 + log.Printf("marking email as verified: %s", err) 190 + s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 191 + return 192 + } 193 + 194 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 195 + } 196 + 197 + func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 198 + did := s.auth.GetDid(r) 199 + emailAddr := r.FormValue("email") 200 + emailAddr = strings.TrimSpace(emailAddr) 201 + 202 + if emailAddr == "" { 203 + s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 204 + return 205 + } 206 + 207 + if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 208 + log.Printf("setting primary email: %s", err) 209 + s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 210 + return 211 + } 212 + 213 + s.pages.HxLocation(w, "/settings") 29 214 } 30 215 31 216 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 }