+1
appview/config.go
+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
+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
+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
+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
+1
appview/pages/pages.go
+79
-3
appview/pages/templates/settings.html
+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
+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
+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
+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
+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=