#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
@@ -0,0 +1,360 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/vault"
|
||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// TestSubmissionEndToEnd covers the outbound half of PLAN.md section 6.2:
|
||||
// an authenticated client submits on the submission port, the message is
|
||||
// signed with both DKIM keys, queued, and then delivered by the worker to a
|
||||
// remote MX.
|
||||
func TestSubmissionEndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e test skipped in -short mode")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
runtimeDir := t.TempDir()
|
||||
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||
Port(54344).
|
||||
RuntimePath(runtimeDir).
|
||||
StartTimeout(120 * time.Second))
|
||||
if err := pg.Start(); err != nil {
|
||||
t.Fatalf("start embedded postgres: %v", err)
|
||||
}
|
||||
defer pg.Stop()
|
||||
|
||||
dbURL := "postgres://postgres:postgres@localhost:54344/postgres?sslmode=disable"
|
||||
pool, err := postgres.NewPool(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// ---------------------------------------------------------------- seed
|
||||
const (
|
||||
senderAddress = "sender@submission.test"
|
||||
password = "correct horse battery staple"
|
||||
)
|
||||
|
||||
domainID, mailboxID, inboxID := uuid.New(), uuid.New(), uuid.New()
|
||||
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
|
||||
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'submission.test', 'submission.test')`, domainID)
|
||||
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, max_recipients_per_hour)
|
||||
VALUES ($1, $2, 'sender', $3, $4, 5)`, mailboxID, domainID, senderAddress, hash)
|
||||
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID)
|
||||
|
||||
// ----------------------------------------------------------- provision
|
||||
secretVault, err := vault.New("e2e-master-key")
|
||||
if err != nil {
|
||||
t.Fatalf("vault: %v", err)
|
||||
}
|
||||
dkimRepo := postgres.NewDkimRepository(pool, secretVault)
|
||||
|
||||
provisioner := appusecase.NewProvisionDkimKeysUseCase(dkimRepo, func(algorithm string) ([]byte, string, error) {
|
||||
generated, err := dkim.Generate(algorithm)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
|
||||
})
|
||||
|
||||
keys, err := provisioner.Execute(ctx, "submission.test")
|
||||
if err != nil {
|
||||
t.Fatalf("provision DKIM keys: %v", err)
|
||||
}
|
||||
if keys.RsaPublicKey == "" || keys.Ed25519PublicKey == "" {
|
||||
t.Fatal("provisioning produced no public keys for the DNS records")
|
||||
}
|
||||
|
||||
// The private halves must not be readable from the table without the
|
||||
// master key (PLAN.md section 9).
|
||||
var storedCiphertext []byte
|
||||
pool.QueryRow(ctx, `SELECT private_key_enc FROM dkim_keys WHERE algorithm = 'rsa2048'`).Scan(&storedCiphertext)
|
||||
if strings.Contains(string(storedCiphertext), "PRIVATE KEY") {
|
||||
t.Error("the DKIM private key was stored in the clear")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- wiring
|
||||
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
|
||||
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||
authRepo := postgres.NewAuthRepository(pool)
|
||||
outboundRepo := postgres.NewOutboundRepository(pool)
|
||||
|
||||
submissionUC := appusecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, dkim.NewSigner(dkimRepo))
|
||||
|
||||
certificate := selfSignedTLSCert(t)
|
||||
server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
|
||||
server.Domain = "mail.submission.test"
|
||||
server.MaxMessageBytes = 10 << 20
|
||||
server.MaxRecipients = 10
|
||||
server.AllowInsecureAuth = false
|
||||
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{certificate}}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go server.Serve(ln)
|
||||
|
||||
// -------------------------------------------------- submit over STARTTLS
|
||||
submitMessage(t, ln.Addr().String(), senderAddress, password, "rcpt@remote.test")
|
||||
|
||||
// The job must be queued against the sending mailbox.
|
||||
var queued int
|
||||
var destinationDomain string
|
||||
pool.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(destination_domain), '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
|
||||
Scan(&queued, &destinationDomain)
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued %d jobs, want 1", queued)
|
||||
}
|
||||
if destinationDomain != "remote.test" {
|
||||
t.Errorf("destination domain = %q, want remote.test", destinationDomain)
|
||||
}
|
||||
|
||||
// The stored message must carry both signatures.
|
||||
var blobID uuid.UUID
|
||||
pool.QueryRow(ctx, `SELECT blob_id FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&blobID)
|
||||
stored, err := blobReader.ReadByID(ctx, blobID)
|
||||
if err != nil {
|
||||
t.Fatalf("read stored message: %v", err)
|
||||
}
|
||||
if count := strings.Count(string(stored), "DKIM-Signature:"); count != 2 {
|
||||
t.Errorf("stored message carries %d DKIM signatures, want 2 (RSA and Ed25519)", count)
|
||||
}
|
||||
if !strings.Contains(string(stored), "d=submission.test") {
|
||||
t.Errorf("signatures do not claim the sending domain:\n%s", stored)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- delivery
|
||||
received := make(chan string, 1)
|
||||
mxAddr := startCapturingMX(t, received)
|
||||
|
||||
worker := appusecase.NewOutboundWorkerUseCase(
|
||||
outboundRepo, &staticMXResolver{addr: mxAddr}, blobReader, nil, nil, "mail.submission.test",
|
||||
)
|
||||
if _, err := worker.ProcessBatch(ctx, "worker1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
var status, tlsPolicy string
|
||||
pool.QueryRow(ctx, `SELECT status, COALESCE(tls_policy_used, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
|
||||
Scan(&status, &tlsPolicy)
|
||||
if status != "DELIVERED" {
|
||||
var lastError string
|
||||
pool.QueryRow(ctx, `SELECT COALESCE(last_error, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&lastError)
|
||||
t.Fatalf("status = %s, want DELIVERED (last error: %s)", status, lastError)
|
||||
}
|
||||
if tlsPolicy != "opportunistic" {
|
||||
t.Errorf("tls_policy_used = %q, want opportunistic for a destination with no published policy", tlsPolicy)
|
||||
}
|
||||
|
||||
select {
|
||||
case delivered := <-received:
|
||||
if !strings.Contains(delivered, "DKIM-Signature:") {
|
||||
t.Error("the delivered message lost its signatures")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the remote MX never received the message")
|
||||
}
|
||||
|
||||
// ------------------------------------------------ per-mailbox send limit
|
||||
// The mailbox allows five recipients an hour and has used one.
|
||||
assertSubmissionRefused(t, ln.Addr().String(), senderAddress, password,
|
||||
[]string{"a@remote.test", "b@remote.test", "c@remote.test", "d@remote.test", "e@remote.test"})
|
||||
|
||||
// An account may not send as somebody else.
|
||||
assertSenderRefused(t, ln.Addr().String(), senderAddress, password, "ceo@submission.test")
|
||||
}
|
||||
|
||||
// submitMessage performs a full EHLO/STARTTLS/AUTH/MAIL/RCPT/DATA exchange.
|
||||
func submitMessage(t *testing.T, addr, username, password, recipient string) {
|
||||
t.Helper()
|
||||
|
||||
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dial submission over STARTTLS: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Auth(plainAuth(username, password)); err != nil {
|
||||
t.Fatalf("AUTH: %v", err)
|
||||
}
|
||||
if err := client.Mail(username, nil); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := client.Rcpt(recipient, nil); err != nil {
|
||||
t.Fatalf("RCPT TO: %v", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
t.Fatalf("DATA: %v", err)
|
||||
}
|
||||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: e2e\r\nDate: Mon, 20 Jul 2026 10:00:00 +0000\r\nMessage-ID: <e2e@submission.test>\r\n\r\nhello\r\n", username, recipient)
|
||||
if _, err := w.Write([]byte(message)); err != nil {
|
||||
t.Fatalf("write DATA: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close DATA: %v", err)
|
||||
}
|
||||
client.Quit()
|
||||
}
|
||||
|
||||
// assertSubmissionRefused checks that going past the hourly recipient budget
|
||||
// is refused with a temporary error rather than silently accepted.
|
||||
func assertSubmissionRefused(t *testing.T, addr, username, password string, recipients []string) {
|
||||
t.Helper()
|
||||
|
||||
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
client.Auth(plainAuth(username, password))
|
||||
client.Mail(username, nil)
|
||||
for _, r := range recipients {
|
||||
client.Rcpt(r, nil)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
// Some servers refuse at DATA issue time; either point is acceptable.
|
||||
return
|
||||
}
|
||||
w.Write([]byte("From: x\r\n\r\nbody\r\n"))
|
||||
if err := w.Close(); err == nil {
|
||||
t.Error("submission past the hourly recipient limit was accepted")
|
||||
} else if !strings.Contains(err.Error(), "451") {
|
||||
t.Errorf("error = %v, want a 451 temporary refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSenderRefused(t *testing.T, addr, username, password, forgedSender string) {
|
||||
t.Helper()
|
||||
|
||||
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
client.Auth(plainAuth(username, password))
|
||||
|
||||
if err := client.Mail(forgedSender, nil); err == nil {
|
||||
t.Errorf("MAIL FROM %s was accepted for an account that does not own it", forgedSender)
|
||||
}
|
||||
}
|
||||
|
||||
// startCapturingMX accepts one message and hands its body back on a channel.
|
||||
func startCapturingMX(t *testing.T, received chan<- string) string {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
r := bufio.NewReader(c)
|
||||
c.Write([]byte("220 capture.mx ESMTP\r\n"))
|
||||
|
||||
var body strings.Builder
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
|
||||
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
||||
c.Write([]byte("250 capture.mx\r\n"))
|
||||
case cmd == "DATA":
|
||||
c.Write([]byte("354 Send it\r\n"))
|
||||
for {
|
||||
dataLine, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(dataLine) == "." {
|
||||
break
|
||||
}
|
||||
body.WriteString(dataLine)
|
||||
}
|
||||
c.Write([]byte("250 2.0.0 Queued\r\n"))
|
||||
select {
|
||||
case received <- body.String():
|
||||
default:
|
||||
}
|
||||
case cmd == "QUIT":
|
||||
c.Write([]byte("221 Bye\r\n"))
|
||||
return
|
||||
default:
|
||||
c.Write([]byte("250 Ok\r\n"))
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// plainAuth builds a SASL PLAIN client without pulling in another dependency.
|
||||
type plainAuthClient struct {
|
||||
username, password string
|
||||
done bool
|
||||
}
|
||||
|
||||
func plainAuth(username, password string) *plainAuthClient {
|
||||
return &plainAuthClient{username: username, password: password}
|
||||
}
|
||||
|
||||
func (a *plainAuthClient) Start() (mech string, ir []byte, err error) {
|
||||
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
|
||||
}
|
||||
|
||||
func (a *plainAuthClient) Next(challenge []byte) ([]byte, error) {
|
||||
if a.done {
|
||||
return nil, fmt.Errorf("unexpected challenge %q", base64.StdEncoding.EncodeToString(challenge))
|
||||
}
|
||||
a.done = true
|
||||
return nil, nil
|
||||
}
|
||||
Reference in new issue
Block a user