Skip to content

Commit 0493037

Browse files
committed
implement signing up by sending us an email
this is preferred over putting an email address in the web form: by letting the user send us the first message, our address will be a known correpondent in their email account. that should help their junk filter realize our signup message and later notification messages are legit. it also makes it impossible for users to mistype their own email. we make it easy for users: they just click our link to compose a message and hit send in their email application. we'll now monitor the inbox for messages with "signup" as subject. some metrics related to incoming message handling were changed. messages are only processed if they don't have headers that indicate they are coming from a mailing list or are automated in any way. we also check are either have a dmarc pass, or aligned relaxed spf or aligned relaxed dkim pass, based on the authentication-results header the mail server must add to the message. this adds config options to disable signup through email or website (or both to disable entirely). this changes config option SubjectPrefix. it must now be the full prefix, no ": " is added anymore. this also increases likelyhood of being able to deliver messages to address with non-ascii localparts by setting the smtputf8 & 8bitmime extension when needed. for issue #25
1 parent 2ad1cf3 commit 0493037

20 files changed

+14967
-177
lines changed

api.go

+83-44
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,51 @@ func (API) Signup(ctx context.Context, email string) {
200200
logCheck(err, "closing smtp connectiong")
201201
}()
202202

203-
var user User
204-
var msg []byte
205-
var mailFrom, sendID string
206-
var eightbit, smtputf8 bool
207-
var m Message
203+
user, msg, mailFrom, eightbit, smtputf8, m, err := signup(ctx, email, "", true)
204+
if serr, ok := err.(*sherpa.Error); ok {
205+
panic(serr)
206+
}
207+
xcheckf(err, "adding user to database")
208+
209+
// Send the message.
210+
submitctx, submitcancel := context.WithTimeout(context.Background(), 10*time.Second)
211+
defer submitcancel()
212+
if err := smtpSubmit(submitctx, smtpconn, true, mailFrom, email, msg, eightbit, smtputf8); err != nil {
213+
logErrorx("submission for signup/passwordreset", err, "userid", user.ID)
214+
if err := database.Delete(context.Background(), &m); err != nil {
215+
logErrorx("removing metamessage added before submission error", err)
216+
}
217+
xcheckf(err, "submitting verification/password reset email")
218+
}
219+
slog.Info("submitted signup/passwordreset email", "userid", user.ID)
220+
}
221+
222+
func signup(ctx context.Context, email string, origMessageID string, viaWebsite bool) (user User, msg []byte, mailFrom string, eightbit, smtputf8 bool, m Message, err error) {
223+
var sendID string
224+
225+
// Code below can raise panics with sherpa.Error. Catch them an return as regular error.
226+
defer func() {
227+
x := recover()
228+
if x == nil {
229+
return
230+
}
231+
serr, ok := x.(*sherpa.Error)
232+
if !ok || err != nil {
233+
panic(x)
234+
}
235+
err = serr
236+
}()
237+
208238
err = database.Write(ctx, func(tx *bstore.Tx) error {
209239
user, err = bstore.QueryTx[User](tx).FilterNonzero(User{Email: email}).Get()
210240
if err == bstore.ErrAbsent || err == nil && user.VerifyToken != "" {
241+
if viaWebsite && config.SignupWebsiteDisabled {
242+
return fmt.Errorf("signup via website currently disabled")
243+
}
244+
if !viaWebsite && config.SignupEmailDisabled {
245+
return fmt.Errorf("signup via email currently disabled")
246+
}
247+
211248
metaUnsubToken := user.MetaUnsubscribeToken
212249
if metaUnsubToken == "" {
213250
metaUnsubToken = xrandomID(16)
@@ -239,9 +276,9 @@ func (API) Signup(ctx context.Context, email string) {
239276
return fmt.Errorf("adding user to database: %v", err)
240277
}
241278

242-
subject, text, html, err := composeSignup(user)
279+
subject, text, html, err := composeSignup(user, viaWebsite)
243280
xcheckf(err, "composing signup text")
244-
mailFrom, sendID, msg, eightbit, smtputf8, err = compose(true, user, subject, text, html)
281+
mailFrom, sendID, msg, eightbit, smtputf8, err = compose(true, user, origMessageID, subject, text, html)
245282
xcheckf(err, "composing signup message")
246283

247284
m = Message{
@@ -252,7 +289,11 @@ func (API) Signup(ctx context.Context, email string) {
252289
if err := tx.Insert(&m); err != nil {
253290
return fmt.Errorf("adding outgoing message to database: %v", err)
254291
}
255-
xaddUserLogf(tx, user.ID, "Signup through website")
292+
msg := "Signup through email"
293+
if viaWebsite {
294+
msg = "Signup through website"
295+
}
296+
xaddUserLogf(tx, user.ID, msg)
256297

257298
return nil
258299
}
@@ -266,9 +307,9 @@ func (API) Signup(ctx context.Context, email string) {
266307
return fmt.Errorf("updating user in database: %v", err)
267308
}
268309

269-
subject, text, html, err := composePasswordReset(user)
310+
subject, text, html, err := composePasswordReset(user, viaWebsite)
270311
xcheckf(err, "composing password reset text")
271-
mailFrom, sendID, msg, eightbit, smtputf8, err = compose(true, user, subject, text, html)
312+
mailFrom, sendID, msg, eightbit, smtputf8, err = compose(true, user, origMessageID, subject, text, html)
272313
xcheckf(err, "composing password reset message")
273314

274315
m = Message{
@@ -279,23 +320,15 @@ func (API) Signup(ctx context.Context, email string) {
279320
if err := tx.Insert(&m); err != nil {
280321
return fmt.Errorf("adding outgoing message to database: %v", err)
281322
}
282-
xaddUserLogf(tx, user.ID, "Signup for existing account, sending password reset.")
323+
msg := "Signup through email for existing account, sending password reset."
324+
if viaWebsite {
325+
msg = "Signup through website for existing account, sending password reset."
326+
}
327+
xaddUserLogf(tx, user.ID, msg)
283328

284329
return nil
285330
})
286-
xcheckf(err, "adding user to database")
287-
288-
// Send the message.
289-
submitctx, submitcancel := context.WithTimeout(context.Background(), 10*time.Second)
290-
defer submitcancel()
291-
if err := smtpSubmit(submitctx, smtpconn, true, mailFrom, email, msg, eightbit, smtputf8); err != nil {
292-
logErrorx("submission for signup/passwordreset", err, "userid", user.ID)
293-
if err := database.Delete(context.Background(), &m); err != nil {
294-
logErrorx("removing metamessage added before submission error", err)
295-
}
296-
xcheckf(err, "submitting verification/password reset email")
297-
}
298-
slog.Info("submitted signup/passwordreset email", "userid", user.ID)
331+
return
299332
}
300333

301334
// SignupEmail returns the email address for a verify token. So we can show it, and
@@ -464,10 +497,10 @@ func (API) RequestPasswordReset(ctx context.Context, prepToken, email string) {
464497
}
465498
xcheckf(err, "requesting password reset")
466499

467-
subject, text, html, err := composePasswordReset(user)
500+
subject, text, html, err := composePasswordReset(user, true)
468501
xcheckf(err, "composing password reset text")
469502

470-
mailFrom, sendID, msg, eightbit, smtputf8, err := compose(true, user, subject, text, html)
503+
mailFrom, sendID, msg, eightbit, smtputf8, err := compose(true, user, "", subject, text, html)
471504
xcheckf(err, "composing password reset message")
472505

473506
smtpTake()
@@ -981,17 +1014,20 @@ type Recent struct {
9811014
}
9821015

9831016
type Home struct {
984-
Version string
985-
GoVersion string
986-
GoOS string
987-
GoArch string
988-
ServiceName string
989-
AdminName string
990-
AdminEmail string
991-
Note string
992-
SignupNote string
993-
SkipModulePrefixes []string
994-
Recents []Recent
1017+
Version string
1018+
GoVersion string
1019+
GoOS string
1020+
GoArch string
1021+
ServiceName string
1022+
AdminName string
1023+
AdminEmail string
1024+
Note string
1025+
SignupNote string
1026+
SkipModulePrefixes []string
1027+
SignupEmailDisabled bool
1028+
SignupWebsiteDisabled bool
1029+
SignupAddress string
1030+
Recents []Recent
9951031
}
9961032

9971033
func _recents(ctx context.Context, n int) (recents []Recent) {
@@ -1015,12 +1051,15 @@ func _recents(ctx context.Context, n int) (recents []Recent) {
10151051
// Home returns data for the home page.
10161052
func (API) Home(ctx context.Context) (home Home) {
10171053
home = Home{
1018-
Version: version,
1019-
GoVersion: runtime.Version(),
1020-
GoOS: runtime.GOOS,
1021-
GoArch: runtime.GOARCH,
1022-
ServiceName: config.ServiceName,
1023-
SkipModulePrefixes: config.SkipModulePrefixes,
1054+
Version: version,
1055+
GoVersion: runtime.Version(),
1056+
GoOS: runtime.GOOS,
1057+
GoArch: runtime.GOARCH,
1058+
ServiceName: config.ServiceName,
1059+
SkipModulePrefixes: config.SkipModulePrefixes,
1060+
SignupEmailDisabled: config.SignupEmailDisabled,
1061+
SignupWebsiteDisabled: config.SignupWebsiteDisabled,
1062+
SignupAddress: smtp.Address{Localpart: config.Submission.From.ParsedLocalpartBase, Domain: config.Submission.From.DNSDomain}.String(),
10241063
}
10251064

10261065
home.Recents = _recents(ctx, 15)
@@ -1144,7 +1183,7 @@ func (API) TestSend(ctx context.Context, secret, kind, email string) {
11441183
subject, text, html, err := composeSample(kind, u, loginToken)
11451184
xcheckf(err, "compose text")
11461185

1147-
mailFrom, sendID, msg, eightbit, smtputf8, err := compose(kind != "moduleupdates", u, subject, text, html)
1186+
mailFrom, sendID, msg, eightbit, smtputf8, err := compose(kind != "moduleupdates", u, "", config.SubjectPrefix+subject, text, html)
11481187
xcheckf(err, "compose message")
11491188
slog.Info("composed test message", "sendid", sendID)
11501189

api.json

+21
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,27 @@
773773
"string"
774774
]
775775
},
776+
{
777+
"Name": "SignupEmailDisabled",
778+
"Docs": "",
779+
"Typewords": [
780+
"bool"
781+
]
782+
},
783+
{
784+
"Name": "SignupWebsiteDisabled",
785+
"Docs": "",
786+
"Typewords": [
787+
"bool"
788+
]
789+
},
790+
{
791+
"Name": "SignupAddress",
792+
"Docs": "",
793+
"Typewords": [
794+
"string"
795+
]
796+
},
776797
{
777798
"Name": "Recents",
778799
"Docs": "",

api.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export interface Home {
6969
Note: string
7070
SignupNote: string
7171
SkipModulePrefixes?: string[] | null
72+
SignupEmailDisabled: boolean
73+
SignupWebsiteDisabled: boolean
74+
SignupAddress: string
7275
Recents?: Recent[] | null
7376
}
7477

@@ -98,7 +101,7 @@ export const types: TypenameMap = {
98101
"ModuleUpdateURLs": {"Name":"ModuleUpdateURLs","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"UserID","Docs":"","Typewords":["int64"]},{"Name":"SubscriptionID","Docs":"","Typewords":["int64"]},{"Name":"LogRecordID","Docs":"","Typewords":["int64"]},{"Name":"Discovered","Docs":"","Typewords":["timestamp"]},{"Name":"Module","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"RepoURL","Docs":"","Typewords":["string"]},{"Name":"TagURL","Docs":"","Typewords":["string"]},{"Name":"DocURL","Docs":"","Typewords":["string"]}]},
99102
"UserLog": {"Name":"UserLog","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"UserID","Docs":"","Typewords":["int64"]},{"Name":"Time","Docs":"","Typewords":["timestamp"]},{"Name":"Text","Docs":"","Typewords":["string"]}]},
100103
"SubscriptionImport": {"Name":"SubscriptionImport","Docs":"","Fields":[{"Name":"GoMod","Docs":"","Typewords":["string"]},{"Name":"BelowModule","Docs":"","Typewords":["bool"]},{"Name":"OlderVersions","Docs":"","Typewords":["bool"]},{"Name":"Prerelease","Docs":"","Typewords":["bool"]},{"Name":"Pseudo","Docs":"","Typewords":["bool"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"Indirect","Docs":"","Typewords":["bool"]}]},
101-
"Home": {"Name":"Home","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"GoVersion","Docs":"","Typewords":["string"]},{"Name":"GoOS","Docs":"","Typewords":["string"]},{"Name":"GoArch","Docs":"","Typewords":["string"]},{"Name":"ServiceName","Docs":"","Typewords":["string"]},{"Name":"AdminName","Docs":"","Typewords":["string"]},{"Name":"AdminEmail","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]},{"Name":"SignupNote","Docs":"","Typewords":["string"]},{"Name":"SkipModulePrefixes","Docs":"","Typewords":["[]","string"]},{"Name":"Recents","Docs":"","Typewords":["[]","Recent"]}]},
104+
"Home": {"Name":"Home","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"GoVersion","Docs":"","Typewords":["string"]},{"Name":"GoOS","Docs":"","Typewords":["string"]},{"Name":"GoArch","Docs":"","Typewords":["string"]},{"Name":"ServiceName","Docs":"","Typewords":["string"]},{"Name":"AdminName","Docs":"","Typewords":["string"]},{"Name":"AdminEmail","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]},{"Name":"SignupNote","Docs":"","Typewords":["string"]},{"Name":"SkipModulePrefixes","Docs":"","Typewords":["[]","string"]},{"Name":"SignupEmailDisabled","Docs":"","Typewords":["bool"]},{"Name":"SignupWebsiteDisabled","Docs":"","Typewords":["bool"]},{"Name":"SignupAddress","Docs":"","Typewords":["string"]},{"Name":"Recents","Docs":"","Typewords":["[]","Recent"]}]},
102105
"Recent": {"Name":"Recent","Docs":"","Fields":[{"Name":"Module","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Discovered","Docs":"","Typewords":["timestamp"]},{"Name":"RepoURL","Docs":"","Typewords":["string"]},{"Name":"TagURL","Docs":"","Typewords":["string"]},{"Name":"DocURL","Docs":"","Typewords":["string"]}]},
103106
"Interval": {"Name":"Interval","Docs":"","Values":[{"Name":"IntervalImmediate","Value":"immediate","Docs":""},{"Name":"IntervalHour","Value":"hour","Docs":""},{"Name":"IntervalDay","Value":"day","Docs":""},{"Name":"IntervalWeek","Value":"week","Docs":""}]},
104107
}

compose.go

+33-19
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import (
3232
//
3333
// If configured, the message subject prefix is prepended to the subject before
3434
// adding it to the message.
35-
func compose(meta bool, user User, subject, text, html string) (mailFrom, sendID string, msg []byte, eightbit, smtputf8 bool, rerr error) {
35+
func compose(meta bool, user User, origMessageID, subject, text, html string) (mailFrom, sendID string, msg []byte, eightbit, smtputf8 bool, rerr error) {
3636
// message.Composer uses panic for error handling...
3737
defer func() {
3838
x := recover()
@@ -48,9 +48,22 @@ func compose(meta bool, user User, subject, text, html string) (mailFrom, sendID
4848
var buf bytes.Buffer
4949
c := message.NewComposer(&buf, 0)
5050

51+
rcptToAddr, err := smtp.ParseAddress(user.Email)
52+
if err != nil {
53+
// This shouldn't fail, we've validated the address before.
54+
return "", "", nil, false, false, fmt.Errorf("parsing recipient address: %v", err)
55+
}
56+
for _, ch := range string(rcptToAddr.Localpart + config.Submission.From.ParsedLocalpartBase) {
57+
if ch >= 0x80 {
58+
c.SMTPUTF8 = true
59+
c.Has8bit = true
60+
break
61+
}
62+
}
63+
5164
sendID = xrandomID(16)
5265
mailFromAddr := smtp.Address{
53-
Localpart: smtp.Localpart(config.Submission.From.LocalpartBase),
66+
Localpart: config.Submission.From.ParsedLocalpartBase,
5467
Domain: config.Submission.From.DNSDomain,
5568
}
5669
// We send from our address that uses "+<id>" in the SMTP MAIL FROM address.
@@ -64,19 +77,14 @@ func compose(meta bool, user User, subject, text, html string) (mailFrom, sendID
6477
{Address: config.Admin.AddressParsed},
6578
})
6679

67-
rcptToAddr, err := smtp.ParseAddress(user.Email)
68-
if err != nil {
69-
// This shouldn't fail, we've validated the address before.
70-
return "", "", nil, false, false, fmt.Errorf("parsing recipient address: %v", err)
71-
}
7280
c.HeaderAddrs("To", []message.NameAddress{{Address: rcptToAddr}})
73-
var prefix string
74-
if config.SubjectPrefix != "" {
75-
prefix = config.SubjectPrefix + ": "
76-
}
77-
c.Subject(prefix + subject)
81+
c.Subject(subject)
7882
c.Header("Date", time.Now().Format(RFC5322Z))
7983
c.Header("Message-ID", fmt.Sprintf("<%s@%s>", sendID, hostname))
84+
if origMessageID != "" {
85+
c.Header("In-Reply-To", fmt.Sprintf("<%s>", origMessageID))
86+
c.Header("References", fmt.Sprintf("<%s>", origMessageID))
87+
}
8088
c.Header("User-Agent", "gopherwatch/"+version)
8189

8290
// Try to prevent out-of-office notifications. RFC 3834.
@@ -152,8 +160,11 @@ func composeRender(templText *texttemplate.Template, templHTML *htmltemplate.Tem
152160
return textBuf.String(), pageBuf.String(), nil
153161
}
154162

155-
func composeSignup(u User) (subject, text, html string, err error) {
156-
subject = "Verify new account"
163+
func composeSignup(u User, fromWebsite bool) (subject, text, html string, err error) {
164+
subject = config.SubjectPrefix + "Verify new account"
165+
if !fromWebsite {
166+
subject = "re: signup for " + config.ServiceName
167+
}
157168
args := struct {
158169
BaseURL string
159170
Subject string
@@ -163,8 +174,11 @@ func composeSignup(u User) (subject, text, html string, err error) {
163174
return subject, text, html, err
164175
}
165176

166-
func composePasswordReset(u User) (subject, text, html string, err error) {
167-
subject = "Password reset requested"
177+
func composePasswordReset(u User, fromWebsite bool) (subject, text, html string, err error) {
178+
subject = config.SubjectPrefix + "Password reset requested"
179+
if !fromWebsite {
180+
subject = "re: signup for " + config.ServiceName
181+
}
168182
args := struct {
169183
BaseURL string
170184
Subject string
@@ -244,7 +258,7 @@ func composeModuleUpdates(u User, loginToken string, updates []ModuleUpdate) (su
244258
return l[i].Module < l[j].Module
245259
})
246260

247-
subject = fmt.Sprintf("%d modules with %d new versions", len(l), len(updates))
261+
subject = fmt.Sprintf("%s%d modules with %d new versions", config.SubjectPrefix, len(l), len(updates))
248262

249263
var truncated bool
250264
if len(l) > 1100 {
@@ -267,9 +281,9 @@ func composeModuleUpdates(u User, loginToken string, updates []ModuleUpdate) (su
267281
func composeSample(kind string, user User, loginToken string) (subject, text, html string, err error) {
268282
switch kind {
269283
case "signup":
270-
return composeSignup(user)
284+
return composeSignup(user, true)
271285
case "passwordreset":
272-
return composePasswordReset(user)
286+
return composePasswordReset(user, true)
273287
case "moduleupdates":
274288
updates := []ModuleUpdate{
275289
{

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.22.0
44

55
require (
66
github.com/mjl-/bstore v0.0.4
7-
github.com/mjl-/mox v0.0.10
7+
github.com/mjl-/mox v0.0.11-0.20240313163553-2c9cb5b847a7
88
github.com/mjl-/sconf v0.0.5
99
github.com/mjl-/sherpa v0.6.7
1010
github.com/mjl-/sherpadoc v0.0.12

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ github.com/mjl-/adns v0.0.0-20240309142737-2a1aacf346dc h1:ghTx3KsrO0hSJW0bCFCGw
2828
github.com/mjl-/adns v0.0.0-20240309142737-2a1aacf346dc/go.mod h1:v47qUMJnipnmDTRGaHwpCwzE6oypa5K33mUvBfzZBn8=
2929
github.com/mjl-/bstore v0.0.4 h1:q+R1oAr8+E9yf9q+zxkVjQ18VFqD/E9KmGVoe4FIOBA=
3030
github.com/mjl-/bstore v0.0.4/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0=
31-
github.com/mjl-/mox v0.0.10 h1:n/PamNQyRScHI5rtPBSdD+EeMjkyV36hDLoJZ+NTyD0=
32-
github.com/mjl-/mox v0.0.10/go.mod h1:6YYUzVv+DBNoUmP8OHa3vKKHvQ4N4jwvjjcVVbA1xRc=
31+
github.com/mjl-/mox v0.0.11-0.20240313163553-2c9cb5b847a7 h1:Nbu7NY+XFZVveS7s/jX1cTTmIZuXRiDtu8yHCXaDS4w=
32+
github.com/mjl-/mox v0.0.11-0.20240313163553-2c9cb5b847a7/go.mod h1:6YYUzVv+DBNoUmP8OHa3vKKHvQ4N4jwvjjcVVbA1xRc=
3333
github.com/mjl-/sconf v0.0.5 h1:4CMUTENpSnaeP2g6RKtrs8udTxnJgjX2MCCovxGId6s=
3434
github.com/mjl-/sconf v0.0.5/go.mod h1:uF8OdWtLT8La3i4ln176i1pB0ps9pXGCaABEU55ZkE0=
3535
github.com/mjl-/sherpa v0.6.7 h1:C5F8XQdV5nCuS4fvB+ye/ziUQrajEhOoj/t2w5T14BY=

0 commit comments

Comments
 (0)