#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
@@ -6,14 +6,12 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
@@ -47,22 +45,7 @@ func TestAntispamAntivirusEndToEnd(t *testing.T) {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// Seed domain, mailbox, INBOX and Junk folders
|
||||
domainID := uuid.New()
|
||||
|
||||
@@ -2,13 +2,11 @@ package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
@@ -38,21 +36,7 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// Seed domain & admin mailbox
|
||||
domainID := uuid.New()
|
||||
@@ -86,8 +70,11 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
||||
t.Fatalf("SyncDnsRecords execute: %v", err)
|
||||
}
|
||||
|
||||
if out.CreatedCount != 13 {
|
||||
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
||||
// The 13 numbered records of PLAN.md section 7.1 plus the three
|
||||
// client-autoconfiguration records of section 7.2.
|
||||
const expectedRecords = 16
|
||||
if out.CreatedCount != expectedRecords {
|
||||
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecords)
|
||||
}
|
||||
|
||||
// Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@)
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -52,21 +50,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// 2. Seed domain, mailbox, INBOX, Archive
|
||||
password := "correct horse battery staple"
|
||||
@@ -443,7 +427,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
||||
t.Fatalf("SETACTIVE = %q", activeResp)
|
||||
}
|
||||
|
||||
// Verify RFC 5804 §2.7: DELETESCRIPT on active script MUST fail
|
||||
// Verify RFC 5804 section 2.7: DELETESCRIPT on active script MUST fail
|
||||
sendCmdSieve("DELETESCRIPT \"auto_rules\"")
|
||||
if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") {
|
||||
t.Fatalf("expected NO for deleting active script, got %q", delActiveResp)
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,12 +17,14 @@ import (
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
@@ -96,22 +96,7 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
||||
defer pool.Close()
|
||||
|
||||
// Apply Database Migrations
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases
|
||||
@@ -156,8 +141,14 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sync dns records: %v", err)
|
||||
}
|
||||
if dnsOut.CreatedCount != 13 {
|
||||
t.Fatalf("expected 13 created DNS records, got %d", dnsOut.CreatedCount)
|
||||
// The 13 numbered records of PLAN.md section 7.1 plus the three
|
||||
// client-autoconfiguration records of section 7.2.
|
||||
const expectedDnsRecords = 16
|
||||
if dnsOut.CreatedCount != expectedDnsRecords {
|
||||
t.Fatalf("expected %d created DNS records, got %d", expectedDnsRecords, dnsOut.CreatedCount)
|
||||
}
|
||||
if dnsOut.Status != appusecase.SyncStatusVerified {
|
||||
t.Errorf("sync status = %s, want VERIFIED", dnsOut.Status)
|
||||
}
|
||||
|
||||
// Verify system aliases created
|
||||
@@ -174,14 +165,26 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
||||
reportUC := appusecase.NewIngestReportsUseCase(reportRepo)
|
||||
httpRouter := httppresentation.NewRouter(reportUC, func() error { return pool.Ping(ctx) })
|
||||
|
||||
// Test MTA-STS endpoint
|
||||
// Test MTA-STS endpoint. The default is the safe end of the ramp
|
||||
// (PLAN.md section 5): senders honour the policy but do not hard-fail on
|
||||
// it until the operator promotes the domain to enforce.
|
||||
reqSts := httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil)
|
||||
reqSts.Host = "mta-sts.f3e2e.test"
|
||||
recSts := httptest.NewRecorder()
|
||||
httpRouter.ServeHTTP(recSts, reqSts)
|
||||
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: enforce") {
|
||||
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: testing") {
|
||||
t.Errorf("MTA-STS endpoint error: code=%d body=%s", recSts.Code, recSts.Body.String())
|
||||
}
|
||||
if !strings.Contains(recSts.Body.String(), "mx: mail.f3e2e.test") {
|
||||
t.Errorf("MTA-STS policy does not advertise the mail host: %s", recSts.Body.String())
|
||||
}
|
||||
|
||||
httpRouter.SetMtaStsMode(valueobject.MtaStsModeEnforce)
|
||||
recEnforced := httptest.NewRecorder()
|
||||
httpRouter.ServeHTTP(recEnforced, httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil))
|
||||
if !strings.Contains(recEnforced.Body.String(), "mode: enforce") {
|
||||
t.Errorf("promoting the domain to enforce had no effect: %s", recEnforced.Body.String())
|
||||
}
|
||||
|
||||
// Test Autoconfig endpoint
|
||||
reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil)
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -42,22 +40,7 @@ func TestHttpServicesEndToEnd(t *testing.T) {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
reportRepo := postgres.NewReportRepository(pool)
|
||||
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// applyMigrations brings a fresh database up to the current schema by running
|
||||
// every up-migration in order. It reads the directory instead of taking a
|
||||
// hand-written list so that adding a migration cannot leave a test silently
|
||||
// running against a stale schema.
|
||||
func applyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(repoRoot(t), "migrations")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir: %v", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("no up-migrations found in %s", dir)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
sql, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selfSignedTLSCert issues an in-memory certificate for the STARTTLS handshake
|
||||
// in tests. Certificate validation is not what these tests exercise.
|
||||
func selfSignedTLSCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "mail.test"},
|
||||
DNSNames: []string{"mail.test", "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -50,22 +48,7 @@ func TestOutboundQueueEndToEnd(t *testing.T) {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
applyMigrations(t, ctx, pool)
|
||||
|
||||
// Seed domain, sender mailbox, INBOX
|
||||
domainID := uuid.New()
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/alexedwards/argon2id"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
|
||||
@@ -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