|
| 1 | +package smtpmock |
| 2 | + |
| 3 | +import ( |
| 4 | +"bufio" |
| 5 | +"context" |
| 6 | +"encoding/json" |
| 7 | +"errors" |
| 8 | +"fmt" |
| 9 | +"net" |
| 10 | +"net/http" |
| 11 | +"regexp" |
| 12 | +"slices" |
| 13 | +"strings" |
| 14 | +"time" |
| 15 | + |
| 16 | +"github.com/google/uuid" |
| 17 | +smtpmocklib"github.com/mocktools/go-smtp-mock/v2" |
| 18 | +"golang.org/x/xerrors" |
| 19 | + |
| 20 | +"cdr.dev/slog" |
| 21 | +) |
| 22 | + |
| 23 | +// Server wraps the SMTP mock server and provides an HTTP API to retrieve emails. |
| 24 | +typeServerstruct { |
| 25 | +smtpServer*smtpmocklib.Server |
| 26 | +httpServer*http.Server |
| 27 | +listener net.Listener |
| 28 | +logger slog.Logger |
| 29 | + |
| 30 | +hoststring |
| 31 | +smtpPortint |
| 32 | +apiPortint |
| 33 | +} |
| 34 | + |
| 35 | +typeConfigstruct { |
| 36 | +Hoststring |
| 37 | +SMTPPortint |
| 38 | +APIPortint |
| 39 | +Logger slog.Logger |
| 40 | +} |
| 41 | + |
| 42 | +typeEmailSummarystruct { |
| 43 | +Subjectstring`json:"subject"` |
| 44 | +Date time.Time`json:"date"` |
| 45 | +NotificationID uuid.UUID`json:"notification_id,omitempty"` |
| 46 | +} |
| 47 | + |
| 48 | +varnotificationIDRegex=regexp.MustCompile(`notifications\?disabled=3D([a-f0-9-]+)`) |
| 49 | + |
| 50 | +funcNew(cfgConfig)*Server { |
| 51 | +return&Server{ |
| 52 | +host:cfg.Host, |
| 53 | +smtpPort:cfg.SMTPPort, |
| 54 | +apiPort:cfg.APIPort, |
| 55 | +logger:cfg.Logger, |
| 56 | +} |
| 57 | +} |
| 58 | + |
| 59 | +func (s*Server)Start(ctx context.Context)error { |
| 60 | +s.smtpServer=smtpmocklib.New(smtpmocklib.ConfigurationAttr{ |
| 61 | +LogToStdout:false, |
| 62 | +LogServerActivity:true, |
| 63 | +HostAddress:s.host, |
| 64 | +PortNumber:s.smtpPort, |
| 65 | +}) |
| 66 | +iferr:=s.smtpServer.Start();err!=nil { |
| 67 | +returnxerrors.Errorf("start SMTP server: %w",err) |
| 68 | +} |
| 69 | +s.smtpPort=s.smtpServer.PortNumber() |
| 70 | + |
| 71 | +iferr:=s.startAPIServer(ctx);err!=nil { |
| 72 | +_=s.smtpServer.Stop() |
| 73 | +returnxerrors.Errorf("start API server: %w",err) |
| 74 | +} |
| 75 | + |
| 76 | +returnnil |
| 77 | +} |
| 78 | + |
| 79 | +func (s*Server)Stop()error { |
| 80 | +varhttpErr,listenerErr,smtpErrerror |
| 81 | + |
| 82 | +ifs.httpServer!=nil { |
| 83 | +shutdownCtx,cancel:=context.WithTimeout(context.Background(),5*time.Second) |
| 84 | +defercancel() |
| 85 | +iferr:=s.httpServer.Shutdown(shutdownCtx);err!=nil { |
| 86 | +httpErr=xerrors.Errorf("shutdown HTTP server: %w",err) |
| 87 | +} |
| 88 | +} |
| 89 | + |
| 90 | +ifs.listener!=nil { |
| 91 | +iferr:=s.listener.Close();err!=nil { |
| 92 | +listenerErr=xerrors.Errorf("close listener: %w",err) |
| 93 | +} |
| 94 | +} |
| 95 | + |
| 96 | +ifs.smtpServer!=nil { |
| 97 | +iferr:=s.smtpServer.Stop();err!=nil { |
| 98 | +smtpErr=xerrors.Errorf("stop SMTP server: %w",err) |
| 99 | +} |
| 100 | +} |
| 101 | + |
| 102 | +returnerrors.Join(httpErr,listenerErr,smtpErr) |
| 103 | +} |
| 104 | + |
| 105 | +func (s*Server)SMTPAddress()string { |
| 106 | +returnfmt.Sprintf("%s:%d",s.host,s.smtpPort) |
| 107 | +} |
| 108 | + |
| 109 | +func (s*Server)APIAddress()string { |
| 110 | +returnfmt.Sprintf("%s:%d",s.host,s.apiPort) |
| 111 | +} |
| 112 | + |
| 113 | +func (s*Server)MessageCount()int { |
| 114 | +ifs.smtpServer==nil { |
| 115 | +return0 |
| 116 | +} |
| 117 | +returnlen(s.smtpServer.Messages()) |
| 118 | +} |
| 119 | + |
| 120 | +func (s*Server)Purge() { |
| 121 | +ifs.smtpServer!=nil { |
| 122 | +s.smtpServer.MessagesAndPurge() |
| 123 | +} |
| 124 | +} |
| 125 | + |
| 126 | +func (s*Server)startAPIServer(ctx context.Context)error { |
| 127 | +mux:=http.NewServeMux() |
| 128 | +mux.HandleFunc("POST /purge",s.handlePurge) |
| 129 | +mux.HandleFunc("GET /messages",s.handleMessages) |
| 130 | + |
| 131 | +s.httpServer=&http.Server{ |
| 132 | +Handler:mux, |
| 133 | +ReadHeaderTimeout:10*time.Second, |
| 134 | +} |
| 135 | + |
| 136 | +listener,err:=net.Listen("tcp",fmt.Sprintf("%s:%d",s.host,s.apiPort)) |
| 137 | +iferr!=nil { |
| 138 | +returnxerrors.Errorf("listen on %s:%d: %w",s.host,s.apiPort,err) |
| 139 | +} |
| 140 | +s.listener=listener |
| 141 | + |
| 142 | +tcpAddr,valid:=listener.Addr().(*net.TCPAddr) |
| 143 | +if!valid { |
| 144 | +listener.Close() |
| 145 | +returnxerrors.Errorf("listener returned invalid address: %T",listener.Addr()) |
| 146 | +} |
| 147 | +s.apiPort=tcpAddr.Port |
| 148 | + |
| 149 | +gofunc() { |
| 150 | +iferr:=s.httpServer.Serve(listener);err!=nil&&!errors.Is(err,http.ErrServerClosed) { |
| 151 | +s.logger.Error(ctx,"http API server error",slog.Error(err)) |
| 152 | +} |
| 153 | +}() |
| 154 | + |
| 155 | +returnnil |
| 156 | +} |
| 157 | + |
| 158 | +func (s*Server)handlePurge(w http.ResponseWriter,_*http.Request) { |
| 159 | +s.smtpServer.MessagesAndPurge() |
| 160 | +w.WriteHeader(http.StatusOK) |
| 161 | +} |
| 162 | + |
| 163 | +func (s*Server)handleMessages(w http.ResponseWriter,r*http.Request) { |
| 164 | +email:=r.URL.Query().Get("email") |
| 165 | +msgs:=s.smtpServer.Messages() |
| 166 | + |
| 167 | +varsummaries []EmailSummary |
| 168 | +for_,msg:=rangemsgs { |
| 169 | +recipients:=msg.RcpttoRequestResponse() |
| 170 | +if!matchesRecipient(recipients,email) { |
| 171 | +continue |
| 172 | +} |
| 173 | + |
| 174 | +summary,err:=parseEmailSummary(msg.MsgRequest()) |
| 175 | +iferr!=nil { |
| 176 | +s.logger.Warn(r.Context(),"failed to parse email summary",slog.Error(err)) |
| 177 | +continue |
| 178 | +} |
| 179 | +summaries=append(summaries,summary) |
| 180 | +} |
| 181 | + |
| 182 | +w.Header().Set("Content-Type","application/json") |
| 183 | +iferr:=json.NewEncoder(w).Encode(summaries);err!=nil { |
| 184 | +s.logger.Warn(r.Context(),"failed to encode JSON response",slog.Error(err)) |
| 185 | +} |
| 186 | +} |
| 187 | + |
| 188 | +funcmatchesRecipient(recipients [][]string,emailstring)bool { |
| 189 | +ifemail=="" { |
| 190 | +returntrue |
| 191 | +} |
| 192 | +returnslices.ContainsFunc(recipients,func(rcptPair []string)bool { |
| 193 | +returnlen(rcptPair)>0&&strings.Contains(rcptPair[0],email) |
| 194 | +}) |
| 195 | +} |
| 196 | + |
| 197 | +funcparseEmailSummary(contentstring) (EmailSummary,error) { |
| 198 | +varsummaryEmailSummary |
| 199 | +scanner:=bufio.NewScanner(strings.NewReader(content)) |
| 200 | + |
| 201 | +// Extract Subject and Date from headers. |
| 202 | +// Date is used to measure latency. |
| 203 | +forscanner.Scan() { |
| 204 | +line:=scanner.Text() |
| 205 | +ifline=="" { |
| 206 | +break |
| 207 | +} |
| 208 | +ifprefix,found:=strings.CutPrefix(line,"Subject: ");found { |
| 209 | +summary.Subject=prefix |
| 210 | +}elseifprefix,found:=strings.CutPrefix(line,"Date: ");found { |
| 211 | +ifparsedDate,err:=time.Parse(time.RFC1123Z,prefix);err==nil { |
| 212 | +summary.Date=parsedDate |
| 213 | +} |
| 214 | +} |
| 215 | +} |
| 216 | + |
| 217 | +// Extract notification ID from email content |
| 218 | +// Notification ID is present in the email footer like this |
| 219 | +// <p><a href=3D"http://127.0.0.1:3000/settings/notifications?disabled=3D |
| 220 | +// =3D4e19c0ac-94e1-4532-9515-d1801aa283b2" style=3D"color: #2563eb; text-deco= |
| 221 | +// ration: none;">Stop receiving emails like this</a></p> |
| 222 | +replacer:=strings.NewReplacer("=\n","","=\r\n","") |
| 223 | +contentNormalized:=replacer.Replace(content) |
| 224 | +ifmatches:=notificationIDRegex.FindStringSubmatch(contentNormalized);len(matches)>1 { |
| 225 | +varerrerror |
| 226 | +summary.NotificationID,err=uuid.Parse(matches[1]) |
| 227 | +iferr!=nil { |
| 228 | +returnsummary,xerrors.Errorf("failed to parse notification ID: %w",err) |
| 229 | +} |
| 230 | +} |
| 231 | + |
| 232 | +returnsummary,nil |
| 233 | +} |