#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
70 files changed
+5163
-659
No files matched your search
@@ -3,6 +3,7 @@ package httppresentation
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,10 +12,16 @@ import (
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461
|
||||
// recommends a value in the order of weeks so that an attacker cannot strip
|
||||
// the policy by making the endpoint unreachable.
|
||||
const mtaStsMaxAge = 604800 // 7 days
|
||||
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
reportUseCase *appusecase.IngestReportsUseCase
|
||||
pingFunc func() error
|
||||
mtaStsMode valueobject.MtaStsMode
|
||||
}
|
||||
|
||||
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
||||
@@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e
|
||||
mux: http.NewServeMux(),
|
||||
reportUseCase: reportUseCase,
|
||||
pingFunc: pingFunc,
|
||||
// PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at
|
||||
// enforce means any TLS misconfiguration silently blackholes inbound
|
||||
// mail from every sender that honours the policy, with no way to tell
|
||||
// from this side - so the safe mode is the default and the operator
|
||||
// promotes it once TLS-RPT reports come back clean.
|
||||
mtaStsMode: valueobject.MtaStsModeTesting,
|
||||
}
|
||||
r.registerRoutes()
|
||||
return r
|
||||
}
|
||||
|
||||
// SetMtaStsMode selects the mode advertised in the served policy. Callers
|
||||
// should promote to enforce only after TLS-RPT confirms senders can negotiate
|
||||
// TLS against the published MX.
|
||||
func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) {
|
||||
r.mtaStsMode = mode
|
||||
}
|
||||
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.mux.ServeHTTP(w, req)
|
||||
}
|
||||
@@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
|
||||
host := extractHost(req.Host)
|
||||
domainName := strings.TrimPrefix(host, "mta-sts.")
|
||||
|
||||
policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
|
||||
policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
// Kept in step with max_age so an HTTP cache cannot outlive the policy it
|
||||
// is caching.
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(policy.Format()))
|
||||
}
|
||||
|
||||
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
|
||||
host := extractHost(req.Host)
|
||||
// The contact must be a real mailbox on the mail domain, not on whichever
|
||||
// service hostname the request happened to arrive at.
|
||||
domainName := baseDomain(extractHost(req.Host))
|
||||
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
|
||||
|
||||
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
|
||||
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// extractHost strips the optional port from a Host header, handling the
|
||||
// bracketed IPv6 literal form as well.
|
||||
func extractHost(hostHeader string) string {
|
||||
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
|
||||
return h
|
||||
}
|
||||
if hostHeader == "" {
|
||||
return "example.test"
|
||||
}
|
||||
return hostHeader
|
||||
if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" {
|
||||
return host
|
||||
}
|
||||
return strings.Trim(hostHeader, "[]")
|
||||
}
|
||||
|
||||
// serviceHostPrefixes are the per-service names published under a mail domain
|
||||
// (PLAN.md section 7.3); stripping one yields the mail domain itself.
|
||||
var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."}
|
||||
|
||||
func baseDomain(host string) string {
|
||||
for _, prefix := range serviceHostPrefixes {
|
||||
if strings.HasPrefix(host, prefix) {
|
||||
return strings.TrimPrefix(host, prefix)
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -24,4 +24,3 @@ func (s *session) Unsubscribe(_ string) error { return errN
|
||||
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
|
||||
return nil, errNotYetImplemented
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
@@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
|
||||
return &Backend{useCase: uc}
|
||||
}
|
||||
|
||||
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
|
||||
return &session{useCase: b.useCase}, nil
|
||||
func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
|
||||
return &session{useCase: b.useCase, conn: c}, nil
|
||||
}
|
||||
|
||||
// session holds one SMTP transaction's accumulated state (RFC 5321 section
|
||||
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
|
||||
type session struct {
|
||||
useCase *usecase.ProcessInboundEmailUseCase
|
||||
conn *gosmtp.Conn
|
||||
from string
|
||||
recipients []port.MailboxRecord
|
||||
recipientAddresses []string
|
||||
}
|
||||
|
||||
// clientIP is the peer address SPF is evaluated against. It returns nil when
|
||||
// the connection is not available, which downgrades SPF to "none" rather than
|
||||
// producing a wrong verdict.
|
||||
func (s *session) clientIP() net.IP {
|
||||
if s.conn == nil || s.conn.Conn() == nil {
|
||||
return nil
|
||||
}
|
||||
if addr, ok := s.conn.Conn().RemoteAddr().(*net.TCPAddr); ok {
|
||||
return addr.IP
|
||||
}
|
||||
host, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func (s *session) heloDomain() string {
|
||||
if s.conn == nil {
|
||||
return ""
|
||||
}
|
||||
return s.conn.Hostname()
|
||||
}
|
||||
|
||||
func (s *session) Reset() {
|
||||
s.from = ""
|
||||
s.recipients = nil
|
||||
@@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error {
|
||||
Recipients: s.recipients,
|
||||
RecipientAddresses: s.recipientAddresses,
|
||||
Body: r,
|
||||
ClientIP: s.clientIP(),
|
||||
HeloDomain: s.heloDomain(),
|
||||
})
|
||||
if err != nil {
|
||||
var smtpErr *gosmtp.SMTPError
|
||||
@@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error {
|
||||
return smtpErr
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.HasPrefix(errMsg, "550 ") {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 550,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
|
||||
Message: strings.TrimPrefix(errMsg, "550 5.7.1 "),
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(errMsg, "554 ") {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 554,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package smtppresentation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
)
|
||||
|
||||
// loginMechanism is the SASL mechanism name for the legacy LOGIN exchange.
|
||||
// PLAN.md section 4 requires it alongside PLAIN because several desktop and
|
||||
// mobile clients still offer nothing else. Neither go-sasl nor go-smtp ships a
|
||||
// server side for it, so it is implemented here.
|
||||
const loginMechanism = "LOGIN"
|
||||
|
||||
// LOGIN is not specified by an RFC; it is a de facto mechanism where the
|
||||
// server prompts for the username, then for the password, each as a bare
|
||||
// challenge string.
|
||||
var (
|
||||
loginUsernameChallenge = []byte("Username:")
|
||||
loginPasswordChallenge = []byte("Password:")
|
||||
)
|
||||
|
||||
// loginAuthenticator verifies a username and password pair.
|
||||
type loginAuthenticator func(username, password string) error
|
||||
|
||||
// loginServer implements sasl.Server for the LOGIN mechanism.
|
||||
type loginServer struct {
|
||||
authenticate loginAuthenticator
|
||||
username string
|
||||
// state advances from awaiting the username, to awaiting the password,
|
||||
// to finished.
|
||||
state int
|
||||
}
|
||||
|
||||
const (
|
||||
loginStateAwaitingUsername = iota
|
||||
loginStateAwaitingPassword
|
||||
loginStateDone
|
||||
)
|
||||
|
||||
func newLoginServer(authenticate loginAuthenticator) sasl.Server {
|
||||
return &loginServer{authenticate: authenticate}
|
||||
}
|
||||
|
||||
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
switch s.state {
|
||||
case loginStateAwaitingUsername:
|
||||
// A client may send the username as the initial response, in which
|
||||
// case there is nothing to prompt for yet.
|
||||
if response == nil {
|
||||
return loginUsernameChallenge, false, nil
|
||||
}
|
||||
s.username = string(response)
|
||||
s.state = loginStateAwaitingPassword
|
||||
return loginPasswordChallenge, false, nil
|
||||
|
||||
case loginStateAwaitingPassword:
|
||||
s.state = loginStateDone
|
||||
if err := s.authenticate(s.username, string(response)); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
|
||||
default:
|
||||
return nil, false, errors.New("sasl: LOGIN exchange already finished")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package smtppresentation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The LOGIN exchange prompts for the username, then the password, and only
|
||||
// then authenticates.
|
||||
func TestLoginServer_PromptsThenAuthenticates(t *testing.T) {
|
||||
var gotUser, gotPass string
|
||||
server := newLoginServer(func(username, password string) error {
|
||||
gotUser, gotPass = username, password
|
||||
return nil
|
||||
})
|
||||
|
||||
challenge, done, err := server.Next(nil)
|
||||
if err != nil || done || string(challenge) != "Username:" {
|
||||
t.Fatalf("first step: challenge=%q done=%v err=%v", challenge, done, err)
|
||||
}
|
||||
|
||||
challenge, done, err = server.Next([]byte("user@example.test"))
|
||||
if err != nil || done || string(challenge) != "Password:" {
|
||||
t.Fatalf("second step: challenge=%q done=%v err=%v", challenge, done, err)
|
||||
}
|
||||
|
||||
_, done, err = server.Next([]byte("secret"))
|
||||
if err != nil || !done {
|
||||
t.Fatalf("third step: done=%v err=%v", done, err)
|
||||
}
|
||||
if gotUser != "user@example.test" || gotPass != "secret" {
|
||||
t.Errorf("credentials = %q/%q", gotUser, gotPass)
|
||||
}
|
||||
}
|
||||
|
||||
// A client that sends the username as its initial response skips the first
|
||||
// prompt.
|
||||
func TestLoginServer_AcceptsInitialResponse(t *testing.T) {
|
||||
server := newLoginServer(func(string, string) error { return nil })
|
||||
|
||||
challenge, done, err := server.Next([]byte("user@example.test"))
|
||||
if err != nil || done || string(challenge) != "Password:" {
|
||||
t.Fatalf("challenge=%q done=%v err=%v", challenge, done, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginServer_PropagatesAuthFailure(t *testing.T) {
|
||||
authErr := errors.New("bad credentials")
|
||||
server := newLoginServer(func(string, string) error { return authErr })
|
||||
|
||||
server.Next(nil)
|
||||
server.Next([]byte("user@example.test"))
|
||||
if _, done, err := server.Next([]byte("wrong")); err == nil || done {
|
||||
t.Fatalf("expected the failure to surface: done=%v err=%v", done, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginServer_RefusesExtraSteps(t *testing.T) {
|
||||
server := newLoginServer(func(string, string) error { return nil })
|
||||
server.Next(nil)
|
||||
server.Next([]byte("user@example.test"))
|
||||
server.Next([]byte("secret"))
|
||||
|
||||
if _, _, err := server.Next([]byte("extra")); err == nil {
|
||||
t.Fatal("expected an error after the exchange finished")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package smtppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
)
|
||||
|
||||
// SubmissionBackend serves the submission ports (587 and 465). It is a
|
||||
// separate backend from the MX one on purpose: submission always requires
|
||||
// authentication and always relays outward, while port 25 never authenticates
|
||||
// and only accepts mail for local mailboxes (PLAN.md section 4).
|
||||
type SubmissionBackend struct {
|
||||
useCase *usecase.ProcessOutboundEmailUseCase
|
||||
}
|
||||
|
||||
func NewSubmissionBackend(uc *usecase.ProcessOutboundEmailUseCase) *SubmissionBackend {
|
||||
return &SubmissionBackend{useCase: uc}
|
||||
}
|
||||
|
||||
func (b *SubmissionBackend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
|
||||
return &submissionSession{useCase: b.useCase}, nil
|
||||
}
|
||||
|
||||
type submissionSession struct {
|
||||
useCase *usecase.ProcessOutboundEmailUseCase
|
||||
// authenticated is nil until AUTH succeeds; every other command checks it.
|
||||
authenticated *port.MailboxAuth
|
||||
from string
|
||||
recipients []string
|
||||
}
|
||||
|
||||
// AuthMechanisms advertises PLAIN and LOGIN. The server only offers them once
|
||||
// the connection is encrypted - go-smtp withholds AUTH before TLS unless
|
||||
// AllowInsecureAuth is set, which this deployment never sets (PLAN.md
|
||||
// section 4: credentials never travel in the clear).
|
||||
func (s *submissionSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain, loginMechanism}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||
switch mech {
|
||||
case sasl.Plain:
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
return s.authenticate(username, password)
|
||||
}), nil
|
||||
case loginMechanism:
|
||||
return newLoginServer(s.authenticate), nil
|
||||
default:
|
||||
return nil, gosmtp.ErrAuthUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func (s *submissionSession) authenticate(username, password string) error {
|
||||
rec, err := s.useCase.Authenticate(context.Background(), username, password)
|
||||
if err != nil {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 535,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 7, 8},
|
||||
Message: "Authentication credentials invalid",
|
||||
}
|
||||
}
|
||||
s.authenticated = rec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Reset() {
|
||||
s.from = ""
|
||||
s.recipients = nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Logout() error {
|
||||
s.authenticated = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Mail(from string, _ *gosmtp.MailOptions) error {
|
||||
if s.authenticated == nil {
|
||||
return errAuthRequired()
|
||||
}
|
||||
if err := usecase.AuthorizeSender(s.authenticated, from); err != nil {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 550,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
|
||||
Message: "Sender address not owned by the authenticated account",
|
||||
}
|
||||
}
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Rcpt(to string, _ *gosmtp.RcptOptions) error {
|
||||
if s.authenticated == nil {
|
||||
return errAuthRequired()
|
||||
}
|
||||
s.recipients = append(s.recipients, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Data(r io.Reader) error {
|
||||
if s.authenticated == nil {
|
||||
return errAuthRequired()
|
||||
}
|
||||
if len(s.recipients) == 0 {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 503,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 5, 1},
|
||||
Message: "RCPT TO required before DATA",
|
||||
}
|
||||
}
|
||||
|
||||
err := s.useCase.Submit(context.Background(), usecase.ProcessOutboundEmailInput{
|
||||
MailboxID: s.authenticated.ID,
|
||||
SenderAddr: s.from,
|
||||
DomainName: s.authenticated.DomainName,
|
||||
Recipients: s.recipients,
|
||||
Body: r,
|
||||
MaxRecipientsPerHour: s.authenticated.MaxRecipientsPerHour,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A rate-limited sender gets a 4xx: the message is legitimate, the
|
||||
// account has simply run out of budget for this hour and may retry.
|
||||
if errors.Is(err, usecase.ErrSendLimitExceeded) {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 451,
|
||||
EnhancedCode: gosmtp.EnhancedCode{4, 7, 0},
|
||||
Message: "Hourly recipient limit reached, try again later",
|
||||
}
|
||||
}
|
||||
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 451,
|
||||
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
|
||||
Message: "Temporary error accepting message for delivery",
|
||||
}
|
||||
}
|
||||
|
||||
func errAuthRequired() error {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 530,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 7, 0},
|
||||
Message: "Authentication required",
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the session satisfies both the base and the auth add-on interface.
|
||||
var (
|
||||
_ gosmtp.Session = (*submissionSession)(nil)
|
||||
_ gosmtp.AuthSession = (*submissionSession)(nil)
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package smtppresentation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
)
|
||||
|
||||
func smtpCode(t *testing.T, err error) int {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var smtpErr *gosmtp.SMTPError
|
||||
if !ok(err, &smtpErr) {
|
||||
t.Fatalf("error is not an SMTPError: %v", err)
|
||||
}
|
||||
return smtpErr.Code
|
||||
}
|
||||
|
||||
func ok(err error, target **gosmtp.SMTPError) bool {
|
||||
e, is := err.(*gosmtp.SMTPError)
|
||||
if is {
|
||||
*target = e
|
||||
}
|
||||
return is
|
||||
}
|
||||
|
||||
// Every command before AUTH must be refused: submission that accepted an
|
||||
// unauthenticated MAIL FROM would be an open relay.
|
||||
func TestSubmissionSession_RefusesCommandsBeforeAuth(t *testing.T) {
|
||||
s := &submissionSession{}
|
||||
|
||||
if got := smtpCode(t, s.Mail("user@example.test", nil)); got != 530 {
|
||||
t.Errorf("MAIL FROM before AUTH = %d, want 530", got)
|
||||
}
|
||||
if got := smtpCode(t, s.Rcpt("rcpt@remote.test", nil)); got != 530 {
|
||||
t.Errorf("RCPT TO before AUTH = %d, want 530", got)
|
||||
}
|
||||
if got := smtpCode(t, s.Data(strings.NewReader("body"))); got != 530 {
|
||||
t.Errorf("DATA before AUTH = %d, want 530", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An authenticated account must not be able to send as anybody else.
|
||||
func TestSubmissionSession_RefusesForeignSender(t *testing.T) {
|
||||
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
|
||||
|
||||
if err := s.Mail("user@example.test", nil); err != nil {
|
||||
t.Fatalf("sending as itself was refused: %v", err)
|
||||
}
|
||||
if got := smtpCode(t, s.Mail("ceo@example.test", nil)); got != 550 {
|
||||
t.Errorf("MAIL FROM a foreign address = %d, want 550", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionSession_AdvertisesPlainAndLogin(t *testing.T) {
|
||||
mechs := (&submissionSession{}).AuthMechanisms()
|
||||
|
||||
joined := strings.Join(mechs, " ")
|
||||
if !strings.Contains(joined, "PLAIN") || !strings.Contains(joined, "LOGIN") {
|
||||
t.Errorf("AuthMechanisms = %v, want PLAIN and LOGIN", mechs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionSession_RejectsUnsupportedMechanism(t *testing.T) {
|
||||
if _, err := (&submissionSession{}).Auth("SCRAM-SHA-256"); err == nil {
|
||||
t.Fatal("expected an unsupported-mechanism error")
|
||||
}
|
||||
}
|
||||
|
||||
// Reset must clear the envelope but keep the session authenticated: RSET
|
||||
// starts a new transaction, it does not log the user out.
|
||||
func TestSubmissionSession_ResetKeepsAuthentication(t *testing.T) {
|
||||
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
|
||||
s.Mail("user@example.test", nil)
|
||||
s.Rcpt("rcpt@remote.test", nil)
|
||||
|
||||
s.Reset()
|
||||
|
||||
if s.from != "" || len(s.recipients) != 0 {
|
||||
t.Errorf("envelope survived RSET: from=%q recipients=%v", s.from, s.recipients)
|
||||
}
|
||||
if s.authenticated == nil {
|
||||
t.Error("RSET logged the session out")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time proof that the use case wiring matches what the session needs.
|
||||
var _ = usecase.NewProcessOutboundEmailUseCase
|
||||
Reference in new issue
Block a user