mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
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" style="color: #2563eb; text-decoration: none;"> Stop receiving emails like this </a> </p> ``` #### CLI ```bash coder 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)
This commit is contained in:
@@ -66,6 +66,7 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
|||||||
r.scaletestWorkspaceTraffic(),
|
r.scaletestWorkspaceTraffic(),
|
||||||
r.scaletestAutostart(),
|
r.scaletestAutostart(),
|
||||||
r.scaletestNotifications(),
|
r.scaletestNotifications(),
|
||||||
|
r.scaletestSMTP(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build !slim
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
"cdr.dev/slog/sloggers/sloghuman"
|
||||||
|
"github.com/coder/coder/v2/scaletest/smtpmock"
|
||||||
|
"github.com/coder/serpent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (*RootCmd) scaletestSMTP() *serpent.Command {
|
||||||
|
var (
|
||||||
|
hostAddress string
|
||||||
|
smtpPort int64
|
||||||
|
apiPort int64
|
||||||
|
purgeAtCount int64
|
||||||
|
)
|
||||||
|
cmd := &serpent.Command{
|
||||||
|
Use: "smtp",
|
||||||
|
Short: "Start a mock SMTP server for testing",
|
||||||
|
Long: `Start a mock SMTP server with an HTTP API server that can be used to purge
|
||||||
|
messages and get messages by email.`,
|
||||||
|
Handler: func(inv *serpent.Invocation) error {
|
||||||
|
ctx := inv.Context()
|
||||||
|
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||||
|
defer stop()
|
||||||
|
ctx = notifyCtx
|
||||||
|
|
||||||
|
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||||
|
config := smtpmock.Config{
|
||||||
|
HostAddress: hostAddress,
|
||||||
|
SMTPPort: int(smtpPort),
|
||||||
|
APIPort: int(apiPort),
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
|
||||||
|
if err := srv.Start(ctx, config); err != nil {
|
||||||
|
return xerrors.Errorf("start mock SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = srv.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, "Mock SMTP server started on %s\n", srv.SMTPAddress())
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, "HTTP API server started on %s\n", srv.APIAddress())
|
||||||
|
if purgeAtCount > 0 {
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, " Auto-purge when message count reaches %d\n", purgeAtCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, "\nTotal messages received since last purge: %d\n", srv.MessageCount())
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
count := srv.MessageCount()
|
||||||
|
if count > 0 {
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, "Messages received: %d\n", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if purgeAtCount > 0 && int64(count) >= purgeAtCount {
|
||||||
|
_, _ = fmt.Fprintf(inv.Stdout, "Message count (%d) reached threshold (%d). Purging...\n", count, purgeAtCount)
|
||||||
|
srv.Purge()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Options = []serpent.Option{
|
||||||
|
{
|
||||||
|
Flag: "host-address",
|
||||||
|
Env: "CODER_SCALETEST_SMTP_HOST_ADDRESS",
|
||||||
|
Default: "localhost",
|
||||||
|
Description: "Host address to bind the mock SMTP and API servers.",
|
||||||
|
Value: serpent.StringOf(&hostAddress),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Flag: "smtp-port",
|
||||||
|
Env: "CODER_SCALETEST_SMTP_PORT",
|
||||||
|
Description: "Port for the mock SMTP server. Uses a random port if not specified.",
|
||||||
|
Value: serpent.Int64Of(&smtpPort),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Flag: "api-port",
|
||||||
|
Env: "CODER_SCALETEST_SMTP_API_PORT",
|
||||||
|
Description: "Port for the HTTP API server. Uses a random port if not specified.",
|
||||||
|
Value: serpent.Int64Of(&apiPort),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Flag: "purge-at-count",
|
||||||
|
Env: "CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
|
||||||
|
Default: "100000",
|
||||||
|
Description: "Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
|
||||||
|
Value: serpent.Int64Of(&purgeAtCount),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package smtpmock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
smtpmocklib "github.com/mocktools/go-smtp-mock/v2"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server wraps the SMTP mock server and provides an HTTP API to retrieve emails.
|
||||||
|
type Server struct {
|
||||||
|
smtpServer *smtpmocklib.Server
|
||||||
|
httpServer *http.Server
|
||||||
|
httpListener net.Listener
|
||||||
|
logger slog.Logger
|
||||||
|
|
||||||
|
hostAddress string
|
||||||
|
smtpPort int
|
||||||
|
apiPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HostAddress string
|
||||||
|
SMTPPort int
|
||||||
|
APIPort int
|
||||||
|
Logger slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSummary struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
NotificationTemplateID uuid.UUID `json:"notification_template_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var notificationTemplateIDRegex = regexp.MustCompile(`notifications\?disabled=([a-f0-9-]+)`)
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context, cfg Config) error {
|
||||||
|
s.hostAddress = cfg.HostAddress
|
||||||
|
s.smtpPort = cfg.SMTPPort
|
||||||
|
s.apiPort = cfg.APIPort
|
||||||
|
s.logger = cfg.Logger
|
||||||
|
|
||||||
|
s.smtpServer = smtpmocklib.New(smtpmocklib.ConfigurationAttr{
|
||||||
|
LogToStdout: false,
|
||||||
|
LogServerActivity: true,
|
||||||
|
HostAddress: s.hostAddress,
|
||||||
|
PortNumber: s.smtpPort,
|
||||||
|
})
|
||||||
|
if err := s.smtpServer.Start(); err != nil {
|
||||||
|
return xerrors.Errorf("start SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
s.smtpPort = s.smtpServer.PortNumber()
|
||||||
|
|
||||||
|
if err := s.startAPIServer(ctx); err != nil {
|
||||||
|
_ = s.smtpServer.Stop()
|
||||||
|
return xerrors.Errorf("start API server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stop() error {
|
||||||
|
var httpErr, smtpErr error
|
||||||
|
|
||||||
|
if s.httpServer != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
httpErr = xerrors.Errorf("shutdown HTTP server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.smtpServer != nil {
|
||||||
|
if err := s.smtpServer.Stop(); err != nil {
|
||||||
|
smtpErr = xerrors.Errorf("stop SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(httpErr, smtpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SMTPAddress() string {
|
||||||
|
return fmt.Sprintf("%s:%d", s.hostAddress, s.smtpPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) APIAddress() string {
|
||||||
|
return fmt.Sprintf("http://%s:%d", s.hostAddress, s.apiPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) MessageCount() int {
|
||||||
|
if s.smtpServer == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(s.smtpServer.Messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Purge() {
|
||||||
|
if s.smtpServer != nil {
|
||||||
|
s.smtpServer.MessagesAndPurge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startAPIServer(ctx context.Context) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /purge", s.handlePurge)
|
||||||
|
mux.HandleFunc("GET /messages", s.handleMessages)
|
||||||
|
|
||||||
|
s.httpServer = &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.hostAddress, s.apiPort))
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("listen on %s:%d: %w", s.hostAddress, s.apiPort, err)
|
||||||
|
}
|
||||||
|
s.httpListener = listener
|
||||||
|
|
||||||
|
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !valid {
|
||||||
|
err := listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error(ctx, "failed to close listener", slog.Error(err))
|
||||||
|
}
|
||||||
|
return xerrors.Errorf("listener returned invalid address: %T", listener.Addr())
|
||||||
|
}
|
||||||
|
s.apiPort = tcpAddr.Port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.logger.Error(ctx, "http API server error", slog.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePurge(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.smtpServer.MessagesAndPurge()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := r.URL.Query().Get("email")
|
||||||
|
msgs := s.smtpServer.Messages()
|
||||||
|
|
||||||
|
var summaries []EmailSummary
|
||||||
|
for _, msg := range msgs {
|
||||||
|
recipients := msg.RcpttoRequestResponse()
|
||||||
|
if !matchesRecipient(recipients, email) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := parseEmailSummary(msg.MsgRequest())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(r.Context(), "failed to parse email summary", slog.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summaries = append(summaries, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(summaries); err != nil {
|
||||||
|
s.logger.Warn(r.Context(), "failed to encode JSON response", slog.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesRecipient(recipients [][]string, email string) bool {
|
||||||
|
if email == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(recipients, func(rcptPair []string) bool {
|
||||||
|
if len(rcptPair) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
addrPart, ok := strings.CutPrefix(rcptPair[0], "RCPT TO:")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := mail.ParseAddress(addrPart)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.EqualFold(addr.Address, email)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmailSummary(message string) (EmailSummary, error) {
|
||||||
|
var summary EmailSummary
|
||||||
|
|
||||||
|
// Decode quoted-printable message
|
||||||
|
reader := quotedprintable.NewReader(strings.NewReader(message))
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return summary, xerrors.Errorf("decode email content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(contentStr))
|
||||||
|
|
||||||
|
// Extract Subject and Date from headers.
|
||||||
|
// Date is used to measure latency.
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if prefix, found := strings.CutPrefix(line, "Subject: "); found {
|
||||||
|
summary.Subject = prefix
|
||||||
|
} else if prefix, found := strings.CutPrefix(line, "Date: "); found {
|
||||||
|
if parsedDate, err := time.Parse(time.RFC1123Z, prefix); err == nil {
|
||||||
|
summary.Date = parsedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract notification ID from decoded email content
|
||||||
|
// Notification ID is present in the email footer like this
|
||||||
|
// <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>
|
||||||
|
if matches := notificationTemplateIDRegex.FindStringSubmatch(contentStr); len(matches) > 1 {
|
||||||
|
summary.NotificationTemplateID, err = uuid.Parse(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return summary, xerrors.Errorf("parse notification ID: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package smtpmock_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"cdr.dev/slog/sloggers/slogtest"
|
||||||
|
"github.com/coder/coder/v2/scaletest/smtpmock"
|
||||||
|
"github.com/coder/coder/v2/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer_StartStop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
err := srv.Start(ctx, smtpmock.Config{
|
||||||
|
HostAddress: "127.0.0.1",
|
||||||
|
SMTPPort: 0,
|
||||||
|
APIPort: 0,
|
||||||
|
Logger: slogtest.Make(t, nil),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, srv.SMTPAddress())
|
||||||
|
require.NotEmpty(t, srv.APIAddress())
|
||||||
|
|
||||||
|
err = srv.Stop()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_SendAndReceiveEmail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
err := srv.Start(ctx, smtpmock.Config{
|
||||||
|
HostAddress: "127.0.0.1",
|
||||||
|
SMTPPort: 0,
|
||||||
|
APIPort: 0,
|
||||||
|
Logger: slogtest.Make(t, nil),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer srv.Stop()
|
||||||
|
|
||||||
|
err = sendTestEmail(srv.SMTPAddress(), "test@example.com", "Test Subject", "Test Body")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return srv.MessageCount() == 1
|
||||||
|
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/messages", srv.APIAddress())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var summaries []smtpmock.EmailSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&summaries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, summaries, 1)
|
||||||
|
require.Equal(t, "Test Subject", summaries[0].Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_FilterByEmail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
err := srv.Start(ctx, smtpmock.Config{
|
||||||
|
HostAddress: "127.0.0.1",
|
||||||
|
SMTPPort: 0,
|
||||||
|
APIPort: 0,
|
||||||
|
Logger: slogtest.Make(t, nil),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer srv.Stop()
|
||||||
|
|
||||||
|
err = sendTestEmail(srv.SMTPAddress(), "admin@coder.com", "Email for admin", "Body 1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Email for test-user", "Body 2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return srv.MessageCount() == 2
|
||||||
|
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/messages?email=admin@coder.com", srv.APIAddress())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var summaries []smtpmock.EmailSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&summaries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, summaries, 1)
|
||||||
|
require.Equal(t, "Email for admin", summaries[0].Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_NotificationTemplateID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
err := srv.Start(ctx, smtpmock.Config{
|
||||||
|
HostAddress: "127.0.0.1",
|
||||||
|
SMTPPort: 0,
|
||||||
|
APIPort: 0,
|
||||||
|
Logger: slogtest.Make(t, nil),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer srv.Stop()
|
||||||
|
|
||||||
|
notificationID := uuid.New()
|
||||||
|
body := fmt.Sprintf(`<p><a href=3D"http://127.0.0.1:3000/settings/notifications?disabled=3D%s">Unsubscribe</a></p>`, notificationID.String())
|
||||||
|
|
||||||
|
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Notification", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return srv.MessageCount() == 1
|
||||||
|
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/messages", srv.APIAddress())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var summaries []smtpmock.EmailSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&summaries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, summaries, 1)
|
||||||
|
require.Equal(t, notificationID, summaries[0].NotificationTemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Purge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
srv := new(smtpmock.Server)
|
||||||
|
err := srv.Start(ctx, smtpmock.Config{
|
||||||
|
HostAddress: "127.0.0.1",
|
||||||
|
SMTPPort: 0,
|
||||||
|
APIPort: 0,
|
||||||
|
Logger: slogtest.Make(t, nil),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer srv.Stop()
|
||||||
|
|
||||||
|
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Test", "Body")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return srv.MessageCount() == 1
|
||||||
|
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/purge", srv.APIAddress())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
require.Equal(t, 0, srv.MessageCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendTestEmail(smtpAddr, to, subject, body string) error {
|
||||||
|
from := "noreply@coder.com"
|
||||||
|
now := time.Now().Format(time.RFC1123Z)
|
||||||
|
|
||||||
|
msg := strings.Builder{}
|
||||||
|
_, _ = msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
|
||||||
|
_, _ = msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||||
|
_, _ = msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||||
|
_, _ = msg.WriteString(fmt.Sprintf("Date: %s\r\n", now))
|
||||||
|
_, _ = msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
|
_, _ = msg.WriteString("\r\n")
|
||||||
|
_, _ = msg.WriteString(body)
|
||||||
|
|
||||||
|
return smtp.SendMail(smtpAddr, nil, from, []string{to}, []byte(msg.String()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user