mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
9e8075db0f
First of two PRs. Notification title and body templates are Markdown authored by Coder, but the label values interpolated into them are user-controlled and were substituted through `text/template`, which does no escaping. Those values arrive from user profile fields and from OIDC/GitHub name claims. This PR: - Neutralizes Markdown structure in label, data, and `UserName` values before they reach the template. Applied in `notifier.prepare`, so already-queued messages are covered and the stored payload keeps its original values for webhook consumers. Nested `.Data` map keys are escaped too: one shipped template prints a key, and those keys are Terraform resource addresses. - Narrows the notification Markdown grammar to what templates actually use. `CommonExtensions` enabled Tables, DefinitionLists and MathJax, each openable from a value and used by no template. Autolink stays off so a URL in a value cannot become an anchor. - Enables `html.Safelink`, restricting generated hrefs to safe schemes, and guards the panic it exposes: `parser.IsSafeURL` slices a destination before bounds-checking it, so `[docs]()` crashed both renderers. - Folds line breaks out of the `Subject:` header and encodes it, fixing a pre-existing RFC 2047 violation for non-ASCII subjects, a forged encoded-word that let a value choose the displayed subject, and headers running past RFC 5322's 998-octet line limit. Escaping is narrow on purpose, split by where each character carries meaning: - `` \[]()!<` `` everywhere. Backtick is in this group because a fenced block's info string is an HTML sink: gomarkdown writes it into `class="language-..."` unescaped, and `SkipHTML` does not apply to a `CodeBlock` node, so a value that closes the attribute and the tag injects live markup. - `#-+.>|` only in leading position, so values like `bobby-workspace` and `1.5` are untouched. - `=`, `~` and `:` are not escapable by both renderers, so the preceding line break is folded instead. `:` opens a definition list and a GFM table delimiter row that escaping `|` cannot reach. A value's *first* line has no preceding break to fold, so a real tilde fence or `===` underline is escaped there instead, accepting a visible backslash: an unterminated `~~~` at the start of a title otherwise renders the Subject, `<title>` and heading empty. - Leading indentation is truncated to three spaces. Four open an indented code block and a space has no escape. - Emphasis characters are left alone. Escaping `_` corrupts label values such as `user_override` that body templates compare with `eq`, which silently drops content from the rendered email. One golden file changes: the resource replacements `body_markdown` now reads `docker_container\[0\]`, from the map-key escaping above. Every other golden is byte-identical. **One known residual**, pinned by a test that fails if it closes: CommonMark does not process escapes inside a code span, so where a template wraps a value in one, as the workspace out-of-disk body does, the escaper's own backslashes reach the reader. That depends on where the value lands rather than what it contains, which a pre-render escaper cannot see. This narrows the class rather than closing it. **#28397 completes the fix and is stacked on this branch. This PR should not merge without it.** Escaping here cannot reach the SMTP HTML template's sinks, by design rather than by oversight: the subject is produced by `PlaintextFromMarkdown`, which strips exactly the backslashes added here, and `html.gotmpl` then interpolated the result through `text/template`. On this branch alone, a label value still reaches the Subject, `<title>` and `<h1>` as live markup. #28397 escapes at those sinks with `| html`, which is the only place the information needed to escape correctly exists.
763 lines
20 KiB
Go
763 lines
20 KiB
Go
package dispatch_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/emersion/go-sasl"
|
|
"github.com/emersion/go-smtp"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/notifications/dispatch"
|
|
"github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest"
|
|
"github.com/coder/coder/v2/coderd/notifications/types"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
|
}
|
|
|
|
func TestSMTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "bob"
|
|
password = "🤫"
|
|
|
|
hello = "localhost"
|
|
|
|
identity = "robert"
|
|
from = "system@coder.com"
|
|
to = "bob@bob.com"
|
|
|
|
subject = "This is the subject"
|
|
body = "This is the body"
|
|
|
|
caFile = "smtptest/fixtures/ca.crt"
|
|
certFile = "smtptest/fixtures/server.crt"
|
|
keyFile = "smtptest/fixtures/server.key"
|
|
)
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug)
|
|
tests := []struct {
|
|
name string
|
|
cfg codersdk.NotificationsEmailConfig
|
|
toAddrs []string
|
|
authMechs []string
|
|
expectedAuthMeth string
|
|
expectedErr string
|
|
retryable bool
|
|
useTLS bool
|
|
failOnDataFn func() error
|
|
}{
|
|
/**
|
|
* LOGIN auth mechanism
|
|
*/
|
|
{
|
|
name: "LOGIN auth",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
},
|
|
{
|
|
name: "invalid LOGIN auth user",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username + "-wrong",
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
expectedErr: "unknown user",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "invalid LOGIN auth credentials",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password + "-wrong",
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
expectedErr: "incorrect password",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "password from file",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
PasswordFile: "smtptest/fixtures/password.txt",
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
},
|
|
/**
|
|
* PLAIN auth mechanism
|
|
*/
|
|
{
|
|
name: "PLAIN auth",
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
{
|
|
name: "PLAIN auth without identity",
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: "",
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
{
|
|
name: "PLAIN+LOGIN, choose PLAIN",
|
|
authMechs: []string{sasl.Login, sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
/**
|
|
* No auth mechanism
|
|
*/
|
|
{
|
|
name: "No auth mechanisms supported",
|
|
authMechs: []string{},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
expectedErr: "no authentication mechanisms supported by server",
|
|
retryable: false,
|
|
},
|
|
{
|
|
name: "No auth mechanisms supported, none configured",
|
|
authMechs: []string{},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
},
|
|
{
|
|
name: "Auth mechanisms supported optionally, none configured",
|
|
authMechs: []string{sasl.Login, sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
},
|
|
/**
|
|
* TLS connections
|
|
*/
|
|
{
|
|
// TLS is forced but certificate used by mock server is untrusted.
|
|
name: "TLS: x509 untrusted",
|
|
useTLS: true,
|
|
expectedErr: "tls: failed to verify certificate",
|
|
retryable: true,
|
|
},
|
|
{
|
|
// TLS is forced and self-signed certificate used by mock server is not verified.
|
|
name: "TLS: x509 untrusted ignored",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
ForceTLS: true,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS is forced and STARTTLS is configured, but STARTTLS cannot be used by TLS connections.
|
|
// STARTTLS should be disabled and connection should succeed.
|
|
name: "TLS: STARTTLS is ignored",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
StartTLS: true,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// Plain connection is established and upgraded via STARTTLS, but certificate is untrusted.
|
|
name: "TLS: STARTTLS untrusted",
|
|
useTLS: false,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: false,
|
|
StartTLS: true,
|
|
},
|
|
ForceTLS: false,
|
|
},
|
|
expectedErr: "tls: failed to verify certificate",
|
|
retryable: true,
|
|
},
|
|
{
|
|
// Plain connection is established and upgraded via STARTTLS, certificate is not verified.
|
|
name: "TLS: STARTTLS",
|
|
useTLS: false,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
StartTLS: true,
|
|
},
|
|
ForceTLS: false,
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS connection using self-signed certificate.
|
|
name: "TLS: self-signed",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS connection using self-signed certificate & specifying the DNS name configured in the certificate.
|
|
name: "TLS: self-signed + SNI",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
ServerName: "myserver.local",
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
name: "TLS: load CA",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: "nope.crt",
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open nope.crt:",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "TLS: load cert",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: "smtptest/fixtures/nope.cert",
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open smtptest/fixtures/nope.cert:",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "TLS: load cert key",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: "smtptest/fixtures/nope.key",
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open smtptest/fixtures/nope.key:",
|
|
retryable: true,
|
|
},
|
|
/**
|
|
* Kitchen sink
|
|
*/
|
|
{
|
|
name: "PLAIN auth and TLS",
|
|
useTLS: true,
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
/**
|
|
* Other errors
|
|
*/
|
|
{
|
|
name: "Rejected on DATA",
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
failOnDataFn: func() error {
|
|
return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"}
|
|
},
|
|
expectedErr: "SMTP error 501: Rejected!",
|
|
retryable: true,
|
|
},
|
|
}
|
|
|
|
// nolint:paralleltest // Reinitialization is not required as of Go v1.22.
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
tc.cfg.ForceTLS = serpent.Bool(tc.useTLS)
|
|
|
|
backend := smtptest.NewBackend(smtptest.Config{
|
|
AuthMechanisms: tc.authMechs,
|
|
|
|
AcceptedIdentity: tc.cfg.Auth.Identity.String(),
|
|
AcceptedUsername: username,
|
|
AcceptedPassword: password,
|
|
|
|
FailOnDataFn: tc.failOnDataFn,
|
|
})
|
|
|
|
// Create a mock SMTP server which conditionally listens for plain or TLS connections.
|
|
srv, listen, err := smtptest.CreateMockSMTPServer(backend, tc.useTLS)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
// We expect that the server has already been closed in the test
|
|
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
|
|
})
|
|
|
|
errs := bytes.NewBuffer(nil)
|
|
srv.ErrorLog = log.New(errs, "oops", 0)
|
|
// Enable this to debug mock SMTP server.
|
|
// srv.Debug = os.Stderr
|
|
|
|
var hp serpent.HostPort
|
|
require.NoError(t, hp.Set(listen.Addr().String()))
|
|
tc.cfg.Smarthost = serpent.String(hp.String())
|
|
|
|
handler := dispatch.NewSMTPHandler(tc.cfg, logger.Named("smtp"))
|
|
|
|
// Start mock SMTP server in the background.
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
assert.NoError(t, srv.Serve(listen))
|
|
})
|
|
|
|
// Wait for the server to become pingable.
|
|
require.Eventually(t, func() bool {
|
|
cl, err := smtptest.PingClient(listen, tc.useTLS, tc.cfg.TLS.StartTLS.Value())
|
|
if err != nil {
|
|
t.Logf("smtp not yet dialable: %s", err)
|
|
return false
|
|
}
|
|
|
|
if err = cl.Noop(); err != nil {
|
|
t.Logf("smtp not yet noopable: %s", err)
|
|
return false
|
|
}
|
|
|
|
if err = cl.Close(); err != nil {
|
|
t.Logf("smtp didn't close properly: %s", err)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
// Build a fake payload.
|
|
payload := types.MessagePayload{
|
|
Version: "1.0",
|
|
UserEmail: to,
|
|
Labels: make(map[string]string),
|
|
}
|
|
|
|
dispatchFn, err := handler.Dispatcher(payload, subject, body, helpers())
|
|
require.NoError(t, err)
|
|
|
|
msgID := uuid.New()
|
|
retryable, err := dispatchFn(ctx, msgID)
|
|
|
|
if tc.expectedErr == "" {
|
|
require.Nil(t, err)
|
|
require.Empty(t, errs.Bytes())
|
|
|
|
msg := backend.LastMessage()
|
|
require.NotNil(t, msg)
|
|
backend.Reset()
|
|
|
|
require.Equal(t, tc.expectedAuthMeth, msg.AuthMech)
|
|
require.Equal(t, from, msg.From)
|
|
require.Equal(t, tc.toAddrs, msg.To)
|
|
if !tc.cfg.Auth.Empty() {
|
|
require.Equal(t, tc.cfg.Auth.Identity.String(), msg.Identity)
|
|
require.Equal(t, username, msg.Username)
|
|
require.Equal(t, password, msg.Password)
|
|
}
|
|
require.Contains(t, msg.Contents, subject)
|
|
require.Contains(t, msg.Contents, body)
|
|
require.Contains(t, msg.Contents, fmt.Sprintf("Message-Id: %s", msgID))
|
|
} else {
|
|
require.ErrorContains(t, err, tc.expectedErr)
|
|
}
|
|
|
|
require.Equal(t, tc.retryable, retryable)
|
|
|
|
require.NoError(t, srv.Shutdown(ctx))
|
|
wg.Wait()
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSMTPEnvelopeAndHeaders verifies that SMTP envelope addresses (used in
|
|
// MAIL FROM and RCPT TO commands) contain only bare email addresses, while
|
|
// email headers preserve the full address including display names.
|
|
//
|
|
// This is important because RFC 5321 requires envelope addresses to be bare
|
|
// emails, while RFC 5322 allows headers to include display names.
|
|
//
|
|
// See: https://github.com/coder/coder/issues/20727
|
|
func TestSMTPEnvelopeAndHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
hello = "localhost"
|
|
to = "bob@bob.com"
|
|
|
|
subject = "This is the subject"
|
|
body = "This is the body"
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
fromConfig string // The configured From address (may include display name)
|
|
expectedEnvFrom string // Expected envelope MAIL FROM (bare email)
|
|
expectedHeaderFrom string // Expected From header (preserves display name)
|
|
}{
|
|
{
|
|
name: "bare email address",
|
|
fromConfig: "system@coder.com",
|
|
expectedEnvFrom: "system@coder.com",
|
|
expectedHeaderFrom: "system@coder.com",
|
|
},
|
|
{
|
|
name: "email with display name",
|
|
fromConfig: "Coder System <system@coder.com>",
|
|
expectedEnvFrom: "system@coder.com",
|
|
expectedHeaderFrom: "Coder System <system@coder.com>",
|
|
},
|
|
{
|
|
name: "email with quoted display name",
|
|
fromConfig: `"Coder Notifications" <notifications@coder.com>`,
|
|
expectedEnvFrom: "notifications@coder.com",
|
|
expectedHeaderFrom: `"Coder Notifications" <notifications@coder.com>`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
cfg := codersdk.NotificationsEmailConfig{
|
|
Hello: serpent.String(hello),
|
|
From: serpent.String(tc.fromConfig),
|
|
}
|
|
|
|
backend := smtptest.NewBackend(smtptest.Config{
|
|
AuthMechanisms: []string{},
|
|
})
|
|
|
|
srv, listen, err := smtptest.CreateMockSMTPServer(backend, false)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
|
|
})
|
|
|
|
var hp serpent.HostPort
|
|
require.NoError(t, hp.Set(listen.Addr().String()))
|
|
cfg.Smarthost = serpent.String(hp.String())
|
|
|
|
handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp"))
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
assert.NoError(t, srv.Serve(listen))
|
|
})
|
|
|
|
require.Eventually(t, func() bool {
|
|
cl, err := smtptest.PingClient(listen, false, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = cl.Close()
|
|
return true
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
payload := types.MessagePayload{
|
|
Version: "1.0",
|
|
UserEmail: to,
|
|
Labels: make(map[string]string),
|
|
}
|
|
|
|
dispatchFn, err := handler.Dispatcher(payload, subject, body, helpers())
|
|
require.NoError(t, err)
|
|
|
|
msgID := uuid.New()
|
|
retryable, err := dispatchFn(ctx, msgID)
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, retryable)
|
|
|
|
msg := backend.LastMessage()
|
|
require.NotNil(t, msg)
|
|
|
|
// Verify envelope address (MAIL FROM) contains only the bare email.
|
|
require.Equal(t, tc.expectedEnvFrom, msg.From,
|
|
"SMTP envelope MAIL FROM should contain only the bare email address")
|
|
|
|
// Verify header From preserves the display name.
|
|
require.Contains(t, msg.Contents, fmt.Sprintf("From: %s\r\n", tc.expectedHeaderFrom),
|
|
"Email From header should preserve the display name if present")
|
|
|
|
require.NoError(t, srv.Shutdown(ctx))
|
|
wg.Wait()
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject
|
|
// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit.
|
|
func TestSMTPSubjectHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
hello = "localhost"
|
|
to = "bob@bob.com"
|
|
body = "This is the body"
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
// title is the rendered title template handed to the dispatcher.
|
|
title string
|
|
// wantSubject, when set, is the exact Subject header value.
|
|
wantSubject string
|
|
// wantSubjectContains are substrings the single Subject line must hold,
|
|
// used where pinning exact output would test glamour, not the header.
|
|
wantSubjectContains []string
|
|
// wantAbsent must not appear anywhere in the transmitted message.
|
|
wantAbsent string
|
|
}{
|
|
{
|
|
name: "plain subject",
|
|
title: "This is the subject",
|
|
wantSubject: "This is the subject",
|
|
},
|
|
{
|
|
name: "newline cannot inject a header",
|
|
// PlaintextFromMarkdown keeps the paragraph break, so this reaches
|
|
// the header writer with newlines in it.
|
|
title: "Innocent subject\n\nBcc: attacker@example.com",
|
|
wantSubjectContains: []string{"Innocent subject", "Bcc: attacker@example.com"},
|
|
wantAbsent: "\r\nBcc:",
|
|
},
|
|
{
|
|
name: "non-ascii subject is encoded",
|
|
title: "Konto gelöscht",
|
|
wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
cfg := codersdk.NotificationsEmailConfig{
|
|
Hello: serpent.String(hello),
|
|
From: serpent.String("system@coder.com"),
|
|
}
|
|
|
|
backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}})
|
|
srv, listen, err := smtptest.CreateMockSMTPServer(backend, false)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
|
|
})
|
|
|
|
var hp serpent.HostPort
|
|
require.NoError(t, hp.Set(listen.Addr().String()))
|
|
cfg.Smarthost = serpent.String(hp.String())
|
|
|
|
handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp"))
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
assert.NoError(t, srv.Serve(listen))
|
|
})
|
|
|
|
require.Eventually(t, func() bool {
|
|
cl, err := smtptest.PingClient(listen, false, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = cl.Close()
|
|
return true
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
payload := types.MessagePayload{
|
|
Version: "1.0",
|
|
UserEmail: to,
|
|
Labels: make(map[string]string),
|
|
}
|
|
|
|
dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers())
|
|
require.NoError(t, err)
|
|
|
|
retryable, err := dispatchFn(ctx, uuid.New())
|
|
require.NoError(t, err)
|
|
require.False(t, retryable)
|
|
|
|
msg := backend.LastMessage()
|
|
require.NotNil(t, msg)
|
|
|
|
// Assertions are scoped to the header block, which a blank line ends.
|
|
headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n")
|
|
require.True(t, found, "message has no header/body separator")
|
|
|
|
// The header must occupy exactly one line, whatever the value held.
|
|
require.Equal(t, 1, strings.Count(headers, "Subject: "),
|
|
"exactly one Subject header must be present")
|
|
_, after, found := strings.Cut(headers, "Subject: ")
|
|
require.True(t, found, "no Subject header in %q", headers)
|
|
subject, _, found := strings.Cut(after, "\r\n")
|
|
require.True(t, found, "Subject header is not CRLF terminated")
|
|
|
|
if tc.wantSubject != "" {
|
|
require.Equal(t, tc.wantSubject, subject)
|
|
}
|
|
for _, want := range tc.wantSubjectContains {
|
|
require.Contains(t, subject, want)
|
|
}
|
|
if tc.wantAbsent != "" {
|
|
require.NotContains(t, headers, tc.wantAbsent,
|
|
"a value must not be able to inject an additional header")
|
|
}
|
|
|
|
require.NoError(t, srv.Shutdown(ctx))
|
|
wg.Wait()
|
|
})
|
|
}
|
|
}
|