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

Commit7bbeef4

Browse files
authored
feat(cli): add mock SMTP server for testing scaletest notifications (#20221)
This PR adds a fake SMTP server for scale testing. It collects emails sent during tests, which you can then check using the HTTP API.#### Changes- Added mock SMTP server - Added `coder scaletest smtp` CLI command - Implemented HTTP API endpoints to retrieve messages by email - Added auto-purge to prevent memory issues #### HTTP API Endpoints- `GET /messages?email=<email>` – Get messages sent to an email address - `POST /purge` – Clear all messages from memory The HTTP API parses raw email messages to extract the **date**, **subject**, and **notification ID**.Notification IDs are sent in emails like this:```html<p> <a href="http://127.0.0.1:3000/settings/notifications?disabled=4e19c0ac-94e1-4532-9515-d1801aa283b2" > Stop receiving emails like this </a></p>```#### CLI```bashcoder scaletest smtp --host localhost --port 33199 --api-port 8080 --purge-at-count 1000```**Flags:**- `--host`: Host for the mock SMTP and API server (default: localhost) - `--port`: Port for the mock SMTP server (random if not specified) - `--api-port`: Port for the HTTP API server (random if not specified) - `--purge-at-count`: Max number of messages before auto-purging (default: 100000)
1 parentf64ac8f commit7bbeef4

File tree

4 files changed

+563
-0
lines changed

4 files changed

+563
-0
lines changed

‎cli/exp_scaletest.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
6666
r.scaletestWorkspaceTraffic(),
6767
r.scaletestAutostart(),
6868
r.scaletestNotifications(),
69+
r.scaletestSMTP(),
6970
},
7071
}
7172

‎cli/exp_scaletest_smtp.go‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//go:build !slim
2+
3+
package cli
4+
5+
import (
6+
"fmt"
7+
"os/signal"
8+
"time"
9+
10+
"golang.org/x/xerrors"
11+
12+
"cdr.dev/slog"
13+
"cdr.dev/slog/sloggers/sloghuman"
14+
"github.com/coder/coder/v2/scaletest/smtpmock"
15+
"github.com/coder/serpent"
16+
)
17+
18+
func (*RootCmd)scaletestSMTP()*serpent.Command {
19+
var (
20+
hostAddressstring
21+
smtpPortint64
22+
apiPortint64
23+
purgeAtCountint64
24+
)
25+
cmd:=&serpent.Command{
26+
Use:"smtp",
27+
Short:"Start a mock SMTP server for testing",
28+
Long:`Start a mock SMTP server with an HTTP API server that can be used to purge
29+
messages and get messages by email.`,
30+
Handler:func(inv*serpent.Invocation)error {
31+
ctx:=inv.Context()
32+
notifyCtx,stop:=signal.NotifyContext(ctx,StopSignals...)
33+
deferstop()
34+
ctx=notifyCtx
35+
36+
logger:=slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
37+
config:= smtpmock.Config{
38+
HostAddress:hostAddress,
39+
SMTPPort:int(smtpPort),
40+
APIPort:int(apiPort),
41+
Logger:logger,
42+
}
43+
srv:=new(smtpmock.Server)
44+
45+
iferr:=srv.Start(ctx,config);err!=nil {
46+
returnxerrors.Errorf("start mock SMTP server: %w",err)
47+
}
48+
deferfunc() {
49+
_=srv.Stop()
50+
}()
51+
52+
_,_=fmt.Fprintf(inv.Stdout,"Mock SMTP server started on %s\n",srv.SMTPAddress())
53+
_,_=fmt.Fprintf(inv.Stdout,"HTTP API server started on %s\n",srv.APIAddress())
54+
ifpurgeAtCount>0 {
55+
_,_=fmt.Fprintf(inv.Stdout," Auto-purge when message count reaches %d\n",purgeAtCount)
56+
}
57+
58+
ticker:=time.NewTicker(10*time.Second)
59+
deferticker.Stop()
60+
61+
for {
62+
select {
63+
case<-ctx.Done():
64+
_,_=fmt.Fprintf(inv.Stdout,"\nTotal messages received since last purge: %d\n",srv.MessageCount())
65+
returnnil
66+
case<-ticker.C:
67+
count:=srv.MessageCount()
68+
ifcount>0 {
69+
_,_=fmt.Fprintf(inv.Stdout,"Messages received: %d\n",count)
70+
}
71+
72+
ifpurgeAtCount>0&&int64(count)>=purgeAtCount {
73+
_,_=fmt.Fprintf(inv.Stdout,"Message count (%d) reached threshold (%d). Purging...\n",count,purgeAtCount)
74+
srv.Purge()
75+
continue
76+
}
77+
}
78+
}
79+
},
80+
}
81+
82+
cmd.Options= []serpent.Option{
83+
{
84+
Flag:"host-address",
85+
Env:"CODER_SCALETEST_SMTP_HOST_ADDRESS",
86+
Default:"localhost",
87+
Description:"Host address to bind the mock SMTP and API servers.",
88+
Value:serpent.StringOf(&hostAddress),
89+
},
90+
{
91+
Flag:"smtp-port",
92+
Env:"CODER_SCALETEST_SMTP_PORT",
93+
Description:"Port for the mock SMTP server. Uses a random port if not specified.",
94+
Value:serpent.Int64Of(&smtpPort),
95+
},
96+
{
97+
Flag:"api-port",
98+
Env:"CODER_SCALETEST_SMTP_API_PORT",
99+
Description:"Port for the HTTP API server. Uses a random port if not specified.",
100+
Value:serpent.Int64Of(&apiPort),
101+
},
102+
{
103+
Flag:"purge-at-count",
104+
Env:"CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
105+
Default:"100000",
106+
Description:"Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
107+
Value:serpent.Int64Of(&purgeAtCount),
108+
},
109+
}
110+
111+
returncmd
112+
}

‎scaletest/smtpmock/server.go‎

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

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp