@@ -6,8 +6,11 @@ import (
6
6
"encoding/json"
7
7
"errors"
8
8
"fmt"
9
+ "io"
10
+ "mime/quotedprintable"
9
11
"net"
10
12
"net/http"
13
+ "net/mail"
11
14
"regexp"
12
15
"slices"
13
16
"strings"
@@ -22,45 +25,45 @@ import (
22
25
23
26
// Server wraps the SMTP mock server and provides an HTTP API to retrieve emails.
24
27
type Server struct {
25
- smtpServer * smtpmocklib.Server
26
- httpServer * http.Server
27
- listener net.Listener
28
- logger slog.Logger
29
-
30
- host string
31
- smtpPort int
32
- apiPort int
28
+ smtpServer * smtpmocklib.Server
29
+ httpServer * http.Server
30
+ httpListener net.Listener
31
+ logger slog.Logger
32
+
33
+ hostAddress string
34
+ smtpPort int
35
+ apiPort int
33
36
}
34
37
35
38
type Config struct {
36
- Host string
37
- SMTPPort int
38
- APIPort int
39
- Logger slog.Logger
39
+ HostAddress string
40
+ SMTPPort int
41
+ APIPort int
42
+ Logger slog.Logger
40
43
}
41
44
42
45
type EmailSummary struct {
43
- Subject string `json:"subject"`
44
- Date time.Time `json:"date"`
45
- NotificationID uuid.UUID `json:"notification_id ,omitempty"`
46
+ Subject string `json:"subject"`
47
+ Date time.Time `json:"date"`
48
+ NotificationTemplateID uuid.UUID `json:"notification_template_id ,omitempty"`
46
49
}
47
50
48
- var notificationIDRegex = regexp .MustCompile (`notifications\?disabled=3D ([a-f0-9-]+)` )
51
+ var notificationTemplateIDRegex = regexp .MustCompile (`notifications\?disabled=([a-f0-9-]+)` )
49
52
50
53
func New (cfg Config )* Server {
51
54
return & Server {
52
- host : cfg .Host ,
53
- smtpPort :cfg .SMTPPort ,
54
- apiPort :cfg .APIPort ,
55
- logger :cfg .Logger ,
55
+ hostAddress : cfg .HostAddress ,
56
+ smtpPort :cfg .SMTPPort ,
57
+ apiPort :cfg .APIPort ,
58
+ logger :cfg .Logger ,
56
59
}
57
60
}
58
61
59
62
func (s * Server )Start (ctx context.Context )error {
60
63
s .smtpServer = smtpmocklib .New (smtpmocklib.ConfigurationAttr {
61
64
LogToStdout :false ,
62
65
LogServerActivity :true ,
63
- HostAddress :s .host ,
66
+ HostAddress :s .hostAddress ,
64
67
PortNumber :s .smtpPort ,
65
68
})
66
69
if err := s .smtpServer .Start ();err != nil {
@@ -97,11 +100,11 @@ func (s *Server) Stop() error {
97
100
}
98
101
99
102
func (s * Server )SMTPAddress ()string {
100
- return fmt .Sprintf ("%s:%d" ,s .host ,s .smtpPort )
103
+ return fmt .Sprintf ("%s:%d" ,s .hostAddress ,s .smtpPort )
101
104
}
102
105
103
106
func (s * Server )APIAddress ()string {
104
- return fmt .Sprintf ("http://%s:%d" ,s .host ,s .apiPort )
107
+ return fmt .Sprintf ("http://%s:%d" ,s .hostAddress ,s .apiPort )
105
108
}
106
109
107
110
func (s * Server )MessageCount ()int {
@@ -127,11 +130,11 @@ func (s *Server) startAPIServer(ctx context.Context) error {
127
130
ReadHeaderTimeout :10 * time .Second ,
128
131
}
129
132
130
- listener ,err := net .Listen ("tcp" ,fmt .Sprintf ("%s:%d" ,s .host ,s .apiPort ))
133
+ listener ,err := net .Listen ("tcp" ,fmt .Sprintf ("%s:%d" ,s .hostAddress ,s .apiPort ))
131
134
if err != nil {
132
- return xerrors .Errorf ("listen on %s:%d: %w" ,s .host ,s .apiPort ,err )
135
+ return xerrors .Errorf ("listen on %s:%d: %w" ,s .hostAddress ,s .apiPort ,err )
133
136
}
134
- s .listener = listener
137
+ s .httpListener = listener
135
138
136
139
tcpAddr ,valid := listener .Addr ().(* net.TCPAddr )
137
140
if ! valid {
@@ -187,13 +190,36 @@ func matchesRecipient(recipients [][]string, email string) bool {
187
190
return true
188
191
}
189
192
return slices .ContainsFunc (recipients ,func (rcptPair []string )bool {
190
- return len (rcptPair )> 0 && strings .Contains (rcptPair [0 ],email )
193
+ if len (rcptPair )== 0 {
194
+ return false
195
+ }
196
+
197
+ addrPart ,ok := strings .CutPrefix (rcptPair [0 ],"RCPT TO:" )
198
+ if ! ok {
199
+ return false
200
+ }
201
+
202
+ addr ,err := mail .ParseAddress (addrPart )
203
+ if err != nil {
204
+ return false
205
+ }
206
+
207
+ return strings .EqualFold (addr .Address ,email )
191
208
})
192
209
}
193
210
194
- func parseEmailSummary (content string ) (EmailSummary ,error ) {
211
+ func parseEmailSummary (message string ) (EmailSummary ,error ) {
195
212
var summary EmailSummary
196
- scanner := bufio .NewScanner (strings .NewReader (content ))
213
+
214
+ // Decode quoted-printable message
215
+ reader := quotedprintable .NewReader (strings .NewReader (message ))
216
+ content ,err := io .ReadAll (reader )
217
+ if err != nil {
218
+ return summary ,xerrors .Errorf ("decode email content: %w" ,err )
219
+ }
220
+
221
+ contentStr := string (content )
222
+ scanner := bufio .NewScanner (strings .NewReader (contentStr ))
197
223
198
224
// Extract Subject and Date from headers.
199
225
// Date is used to measure latency.
@@ -211,18 +237,15 @@ func parseEmailSummary(content string) (EmailSummary, error) {
211
237
}
212
238
}
213
239
214
- // Extract notification ID from email content
240
+ // Extract notification ID fromdecoded email content
215
241
// Notification ID is present in the email footer like this
216
242
// <p><a href=3D"http://127.0.0.1:3000/settings/notifications?disabled=3D
217
243
// =3D4e19c0ac-94e1-4532-9515-d1801aa283b2" style=3D"color: #2563eb; text-deco=
218
244
// ration: none;">Stop receiving emails like this</a></p>
219
- replacer := strings .NewReplacer ("=\n " ,"" ,"=\r \n " ,"" )
220
- contentNormalized := replacer .Replace (content )
221
- if matches := notificationIDRegex .FindStringSubmatch (contentNormalized );len (matches )> 1 {
222
- var err error
223
- summary .NotificationID ,err = uuid .Parse (matches [1 ])
245
+ if matches := notificationTemplateIDRegex .FindStringSubmatch (contentStr );len (matches )> 1 {
246
+ summary .NotificationTemplateID ,err = uuid .Parse (matches [1 ])
224
247
if err != nil {
225
- return summary ,xerrors .Errorf ("failed to parse notification ID: %w" ,err )
248
+ return summary ,xerrors .Errorf ("parse notification ID: %w" ,err )
226
249
}
227
250
}
228
251