Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit3402991

Browse files
committed
feat(cli): add mock SMTP server for testing scaletest notifications
1 parent544f155 commit3402991

File tree

3 files changed

+543
-0
lines changed

3 files changed

+543
-0
lines changed

‎cli/exp_scaletest.go‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
"github.com/coder/coder/v2/scaletest/loadtestutil"
4343
"github.com/coder/coder/v2/scaletest/notifications"
4444
"github.com/coder/coder/v2/scaletest/reconnectingpty"
45+
"github.com/coder/coder/v2/scaletest/smtpmock"
4546
"github.com/coder/coder/v2/scaletest/workspacebuild"
4647
"github.com/coder/coder/v2/scaletest/workspacetraffic"
4748
"github.com/coder/coder/v2/scaletest/workspaceupdates"
@@ -66,6 +67,7 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
6667
r.scaletestWorkspaceTraffic(),
6768
r.scaletestAutostart(),
6869
r.scaletestNotifications(),
70+
r.scaletestSMTP(),
6971
},
7072
}
7173

@@ -2174,6 +2176,106 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
21742176
returncmd
21752177
}
21762178

2179+
func (*RootCmd)scaletestSMTP()*serpent.Command {
2180+
var (
2181+
hoststring
2182+
portint64
2183+
apiPortint64
2184+
purgeAtCountint64
2185+
)
2186+
cmd:=&serpent.Command{
2187+
Use:"smtp",
2188+
Short:"Start a mock SMTP server for testing",
2189+
Long:`Start a mock SMTP server with an HTTP API server that can be used to purge
2190+
messages and get messages by email.
2191+
2192+
Coder deployment values required:
2193+
- CODER_EMAIL_FROM=noreply@coder.com
2194+
- CODER_EMAIL_SMARTHOST=localhost:33199
2195+
- CODER_EMAIL_HELLO=localhost`,
2196+
Handler:func(inv*serpent.Invocation)error {
2197+
ctx:=inv.Context()
2198+
notifyCtx,stop:=signal.NotifyContext(ctx,StopSignals...)
2199+
deferstop()
2200+
ctx=notifyCtx
2201+
2202+
logger:=slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
2203+
srv:=smtpmock.New(smtpmock.Config{
2204+
Host:host,
2205+
SMTPPort:int(port),
2206+
APIPort:int(apiPort),
2207+
Logger:logger,
2208+
})
2209+
2210+
iferr:=srv.Start(ctx);err!=nil {
2211+
returnxerrors.Errorf("start mock SMTP server: %w",err)
2212+
}
2213+
deferfunc() {
2214+
_=srv.Stop()
2215+
}()
2216+
2217+
_,_=fmt.Fprintf(inv.Stdout,"Mock SMTP server started on %s\n",srv.SMTPAddress())
2218+
_,_=fmt.Fprintf(inv.Stdout,"HTTP API server started on %s\n",srv.APIAddress())
2219+
ifpurgeAtCount>0 {
2220+
_,_=fmt.Fprintf(inv.Stdout," Auto-purge when message count reaches %d\n",purgeAtCount)
2221+
}
2222+
2223+
ticker:=time.NewTicker(10*time.Second)
2224+
deferticker.Stop()
2225+
2226+
for {
2227+
select {
2228+
case<-ctx.Done():
2229+
_,_=fmt.Fprintf(inv.Stdout,"\nTotal messages received: %d\n",srv.MessageCount())
2230+
returnnil
2231+
case<-ticker.C:
2232+
count:=srv.MessageCount()
2233+
ifcount>0 {
2234+
_,_=fmt.Fprintf(inv.Stdout,"Messages received: %d\n",count)
2235+
}
2236+
2237+
ifpurgeAtCount>0&&int64(count)>=purgeAtCount {
2238+
_,_=fmt.Fprintf(inv.Stdout,"Message count (%d) reached threshold (%d). Purging...\n",count,purgeAtCount)
2239+
srv.Purge()
2240+
continue
2241+
}
2242+
}
2243+
}
2244+
},
2245+
}
2246+
2247+
cmd.Options= []serpent.Option{
2248+
{
2249+
Flag:"host",
2250+
Env:"CODER_SCALETEST_SMTP_HOST",
2251+
Default:"localhost",
2252+
Description:"Host to bind the mock SMTP and API servers.",
2253+
Value:serpent.StringOf(&host),
2254+
},
2255+
{
2256+
Flag:"port",
2257+
Env:"CODER_SCALETEST_SMTP_PORT",
2258+
Description:"Port for the mock SMTP server. Uses a random port if not specified.",
2259+
Value:serpent.Int64Of(&port),
2260+
},
2261+
{
2262+
Flag:"api-port",
2263+
Env:"CODER_SCALETEST_SMTP_API_PORT",
2264+
Description:"Port for the HTTP API server. Uses a random port if not specified.",
2265+
Value:serpent.Int64Of(&apiPort),
2266+
},
2267+
{
2268+
Flag:"purge-at-count",
2269+
Env:"CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
2270+
Default:"100000",
2271+
Description:"Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
2272+
Value:serpent.Int64Of(&purgeAtCount),
2273+
},
2274+
}
2275+
2276+
returncmd
2277+
}
2278+
21772279
typerunnableTraceWrapperstruct {
21782280
tracer trace.Tracer
21792281
spanNamestring

‎scaletest/smtpmock/server.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp