#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:49:02 -03:00
1 parent 7249419efc
commit d56f29d084
70 files changed
+5163 -659

No files matched your search

@@ -0,0 +1,135 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/infrastructure/acme"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/vault"
)
// startAcmeManager brings up mode B of PLAN.md section 8.3.
func startAcmeManager(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault) (port.CertProvider, error) {
directoryURL := cfg.AcmeDirectoryURL
if directoryURL == "" {
directoryURL = acme.LetsEncryptProduction
}
store := postgres.NewAcmeRepository(pool, secretVault, directoryURL)
issuer := acme.NewIssuer(store, cfg.AcmeEmail, directoryURL, cfg.CloudflareToken)
publisher := &tlsaPublisher{
adapter: cloudflare.NewCloudflareAdapter(cfg.CloudflareToken),
domain: cfg.domain(),
}
// The hosts that need a certificate are the mail host itself plus the two
// service names of PLAN.md section 7.3, which have to present a valid
// certificate for MTA-STS and autoconfig to work at all.
extraHosts := []string{
"mta-sts." + cfg.domain(),
"autoconfig." + cfg.domain(),
}
manager := acme.NewManager(issuer, store, store, publisher, cfg.PrimaryMailHost, extraHosts, cfg.OutboundDane)
// Reconciling every six hours is frequent enough for a 90 day certificate
// and for the propagation waits the rollover schedules.
if err := manager.Start(ctx, 6*time.Hour); err != nil {
return nil, err
}
log.Printf("lambdamail-protocols managing its own certificate for %s (DANE: %v)", cfg.PrimaryMailHost, cfg.OutboundDane)
return manager, nil
}
// tlsaPublisher writes the DANE associations the rollover decides on.
//
// It replaces the full set every time rather than computing a delta: the
// rollover always hands over the complete desired state, and a delta would
// risk removing an association that is still needed.
type tlsaPublisher struct {
adapter *cloudflare.CloudflareAdapter
domain string
}
func (p *tlsaPublisher) PublishTlsa(ctx context.Context, mailHost string, digests []string) error {
if len(digests) == 0 {
return nil
}
zoneID, err := p.adapter.GetZoneID(ctx, p.domain)
if err != nil {
return fmt.Errorf("resolve zone for TLSA publication: %w", err)
}
existing, err := p.adapter.ListRecords(ctx, zoneID)
if err != nil {
return fmt.Errorf("list records for TLSA publication: %w", err)
}
name := fmt.Sprintf("_25._tcp.%s", mailHost)
desired := map[string]bool{}
for _, digest := range digests {
desired[fmt.Sprintf("3 1 1 %s", digest)] = true
}
// Create the ones that are missing before removing anything, so there is
// never a moment with fewer associations published than required.
published := map[string]string{}
for _, record := range existing {
if record.Type == "TLSA" && equalFoldTrim(record.Name, name) {
published[record.Value] = record.ID
}
}
for value := range desired {
if _, alreadyThere := published[value]; alreadyThere {
continue
}
record := entityTlsaRecord(name, value)
if err := p.adapter.CreateRecord(ctx, zoneID, record); err != nil {
return fmt.Errorf("publish TLSA %s: %w", value, err)
}
log.Printf("acme: published TLSA association %s", value)
}
for value, id := range published {
if desired[value] {
continue
}
if err := p.adapter.DeleteRecord(ctx, zoneID, id); err != nil {
// A stale association is harmless to delivery - it only means one
// extra candidate a validator may match - so this is logged
// rather than treated as a failure.
log.Printf("acme: could not remove the retired TLSA association %s: %v", value, err)
continue
}
log.Printf("acme: retired TLSA association %s", value)
}
return nil
}
func entityTlsaRecord(name, value string) entity.DnsRecord {
return entity.DnsRecord{
Type: "TLSA",
Name: name,
Value: value,
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
}
}
func equalFoldTrim(a, b string) bool {
return strings.EqualFold(strings.TrimSuffix(a, "."), strings.TrimSuffix(b, "."))
}
@@ -0,0 +1,137 @@
package main
import (
"os"
"strconv"
"strings"
"time"
)
// config is the resolved environment of PLAN.md section 13.
type config struct {
DatabaseURL string
PrimaryMailHost string
MailDomain string
SpoolDir string
MasterKey string
TLSMode string
TraefikAcmeDir string
TraefikAcmeFile string
CertPollInterval time.Duration
StaticCertPath string
StaticCertKeyPath string
RspamdURL string
ClamavAddr string
MtaStsMode string
OutboundDane bool
OutboundMtaSts bool
DnssecResolver string
MaxMessageBytes int64
RelayHost string
RelayPort int
RelayUser string
RelayPass string
// ArcSealEnabled adds an ARC set to accepted messages. It is off by
// default because it only earns its keep on a host that forwards mail
// onward; sealing mail we are the final destination for changes the
// stored message for no benefit (PLAN.md section 5).
ArcSealEnabled bool
// AcmeEmail and AcmeDirectoryURL configure mode B (PLAN.md section 8.3).
AcmeEmail string
AcmeDirectoryURL string
CloudflareToken string
PublicIPv4 string
PublicIPv6 string
DnsSyncInterval time.Duration
}
func loadConfig() config {
return config{
DatabaseURL: os.Getenv("DATABASE_URL"),
PrimaryMailHost: envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost"),
MailDomain: os.Getenv("MAIL_DOMAIN"),
SpoolDir: envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool"),
MasterKey: os.Getenv("LAMBDAMAIL_MASTER_KEY"),
TLSMode: strings.ToLower(envOrDefault("TLS_MODE", "traefik")),
TraefikAcmeDir: os.Getenv("TRAEFIK_ACME_DIR"),
TraefikAcmeFile: envOrDefault("TRAEFIK_ACME_FILENAME", "acme.json"),
CertPollInterval: time.Duration(envInt("CERT_POLL_INTERVAL_SECONDS", 60)) * time.Second,
StaticCertPath: os.Getenv("PROTOCOLS_CERT_PATH"),
StaticCertKeyPath: os.Getenv("PROTOCOLS_KEY_PATH"),
RspamdURL: os.Getenv("RSPAMD_URL"),
ClamavAddr: os.Getenv("CLAMAV_ADDR"),
MtaStsMode: envOrDefault("MTA_STS_MODE", "testing"),
// DANE stays off unless explicitly enabled: PLAN.md section 5.1
// explains why publishing TLSA under Traefik-managed certificates
// breaks mail sixty days later.
OutboundDane: envBool("OUTBOUND_DANE_ENABLED", false),
OutboundMtaSts: envBool("OUTBOUND_MTA_STS_ENABLED", true),
DnssecResolver: envOrDefault("DNSSEC_RESOLVER", "1.1.1.1:53"),
MaxMessageBytes: int64(envInt("MAX_MESSAGE_BYTES", 52428800)),
RelayHost: os.Getenv("RELAY_HOST"),
RelayPort: envInt("RELAY_PORT", 587),
RelayUser: os.Getenv("RELAY_USER"),
RelayPass: os.Getenv("RELAY_PASS"),
ArcSealEnabled: envBool("ARC_SEAL_ENABLED", false),
AcmeEmail: os.Getenv("ACME_EMAIL"),
AcmeDirectoryURL: os.Getenv("ACME_DIRECTORY_URL"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
PublicIPv4: os.Getenv("PUBLIC_IPV4"),
PublicIPv6: os.Getenv("PUBLIC_IPV6"),
// PLAN.md section 7.5 reconciles every six hours to detect drift.
DnsSyncInterval: time.Duration(envInt("DNS_SYNC_INTERVAL_SECONDS", 6*60*60)) * time.Second,
}
}
// domainOfMailHost derives the mail domain from the mail host when it was not
// configured explicitly: "mail.example.com" yields "example.com".
func (c config) domain() string {
if c.MailDomain != "" {
return c.MailDomain
}
if _, rest, found := strings.Cut(c.PrimaryMailHost, "."); found {
return rest
}
return c.PrimaryMailHost
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
return parsed
}
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if parsed, err := strconv.ParseBool(v); err == nil {
return parsed
}
}
return fallback
}
@@ -0,0 +1,12 @@
//go:build !unix
package main
import "errors"
// freeDiskPercent has no portable implementation outside unix. Returning an
// error makes the preflight report the check as skipped rather than inventing
// a number.
func freeDiskPercent(string) (float64, error) {
return 0, errors.New("free disk space check is only implemented on unix")
}
@@ -0,0 +1,22 @@
//go:build unix
package main
import "syscall"
// freeDiskPercent reports the free space on the filesystem holding path.
// PLAN.md section 15 treats a nearly full spool as a medium-severity finding:
// once it fills, accepted mail cannot be written and has to be refused.
func freeDiskPercent(path string) (float64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, err
}
total := stat.Blocks
if total == 0 {
return 0, nil
}
// Bavail rather than Bfree: the blocks reserved for root are not usable
// by this process.
return float64(stat.Bavail) / float64(total) * 100, nil
}
@@ -1,86 +1,430 @@
// Command lambdamail-protocols is the entry point for the protocols service.
//
// Subcommands:
//
// (none) run every protocol listener
// preflight run the environment checks of PLAN.md section 15 and exit
// healthcheck probe the local HTTP health endpoint and exit
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/valueobject"
"lambdamail/protocols/internal/infrastructure/arc"
"lambdamail/protocols/internal/infrastructure/clamav"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/dkim"
"lambdamail/protocols/internal/infrastructure/mailauth"
"lambdamail/protocols/internal/infrastructure/netdns"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/rspamd"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
imappresentation "lambdamail/protocols/internal/presentation/imap"
"lambdamail/protocols/internal/infrastructure/tlspolicy"
"lambdamail/protocols/internal/infrastructure/vault"
httppresentation "lambdamail/protocols/internal/presentation/http"
imappresentation "lambdamail/protocols/internal/presentation/imap"
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
)
func main() {
ctx := context.Background()
cfg := loadConfig()
pool, err := postgres.NewPool(ctx, os.Getenv("DATABASE_URL"))
if len(os.Args) > 1 {
switch os.Args[1] {
case "preflight":
runPreflight(cfg)
return
case "healthcheck":
runHealthcheck()
return
default:
log.Fatalf("unknown subcommand %q (expected preflight or healthcheck)", os.Args[1])
}
}
run(cfg)
}
func run(cfg config) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("open database pool: %v", err)
}
defer pool.Close()
spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool")
if err := os.MkdirAll(spoolDir, 0o755); err != nil {
log.Fatalf("create spool dir %s: %v", spoolDir, err)
if err := os.MkdirAll(cfg.SpoolDir, 0o755); err != nil {
log.Fatalf("create spool dir %s: %v", cfg.SpoolDir, err)
}
secretVault, vaultErr := vault.New(cfg.MasterKey)
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
// ---------------------------------------------------------------- repos
mailboxes := postgres.NewMailboxRepository(pool)
blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir)
blobs := diskstorage.NewLocalDiskBlobStorage(pool, cfg.SpoolDir)
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
messages := postgres.NewInboundMessageRepository(pool)
useCase := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
authRepo := postgres.NewAuthRepository(pool)
imapFolders := postgres.NewImapFolderRepository(pool)
messageQuery := postgres.NewMessageQueryRepository(pool)
flagRepo := postgres.NewFlagRepository(pool)
expungeRepo := postgres.NewExpungeRepository(pool)
copyRepo := postgres.NewCopyRepository(pool)
sieveRepo := postgres.NewSieveRepository(pool)
reportRepo := postgres.NewReportRepository(pool)
outboundRepo := postgres.NewOutboundRepository(pool)
aliasRepo := postgres.NewAliasRepository(pool)
clamavAddr := os.Getenv("CLAMAV_ADDR")
rspamdURL := os.Getenv("RSPAMD_URL")
if clamavAddr != "" || rspamdURL != "" {
var scanners []port.ContentScanner
if clamavAddr != "" {
scanners = append(scanners, clamav.NewClamAVAdapter(clamavAddr))
}
if rspamdURL != "" {
scanners = append(scanners, rspamd.NewRspamdAdapter(rspamdURL))
}
pipeline := usecase.NewScanningPipeline(scanners...)
useCase.SetScanner(pipeline)
log.Printf("lambdamail-protocols antispam/antivirus scanner enabled (clamav: %s, rspamd: %s)", clamavAddr, rspamdURL)
// The vault is required for DKIM: without it the private keys could not
// be stored, and unsigned outbound mail fails the DMARC alignment that
// PLAN.md section 5 depends on.
var dkimRepo *postgres.DkimRepository
if vaultErr != nil {
log.Printf("WARNING: %v - DKIM signing is disabled, outbound mail will not be signed", vaultErr)
} else {
dkimRepo = postgres.NewDkimRepository(pool, secretVault)
}
certProvider, err := tlsprovider.NewLocalFileCertProvider(
os.Getenv("PROTOCOLS_CERT_PATH"),
os.Getenv("PROTOCOLS_KEY_PATH"),
)
if err != nil {
log.Printf("WARNING: could not load configured TLS certificate (%v) - falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for real deployments", err)
certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider()
// ------------------------------------------------------------- inbound
inboundUC := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
inboundUC.SetAuthenticator(mailauth.NewAuthenticator(cfg.PrimaryMailHost))
if scanner := buildScanner(cfg); scanner != nil {
inboundUC.SetScanner(scanner)
}
// ------------------------------------------------------------ outbound
var signer port.DkimSigner
if dkimRepo != nil {
signer = dkim.NewSigner(dkimRepo)
}
submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer)
mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost)
outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg))
if relay := relayConfig(cfg); relay.Configured() {
outboundWorker.SetRelay(relay)
log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address())
}
// ARC needs a signing key, so it can only be enabled where DKIM is.
if cfg.ArcSealEnabled && dkimRepo != nil {
if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil {
inboundUC.SetArcSealer(sealer)
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
}
}
// --------------------------------------------------------- access UCs
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
inboundUC.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
// ----------------------------------------------------------- listeners
startSMTPListeners(cfg, certProvider, inboundUC, submissionUC)
startIMAPListeners(cfg, certProvider, imapUseCase)
startPOP3Listeners(certProvider, pop3UseCase)
startManageSieveListener(certProvider, sieveUseCase)
// ------------------------------------------------------- background jobs
go runDeliveryWorker(ctx, outboundWorker)
if cfg.CloudflareToken != "" {
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo)
} else {
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
}
// ------------------------------------------------------------- HTTP API
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
applyMtaStsMode(router, cfg)
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("lambdamail-protocols HTTP service listening on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http serve: %v", err)
}
log.Printf("lambdamail-protocols shut down")
}
// buildCertProvider picks the certificate source. Mode "traefik" reads the
// store Traefik maintains and keeps watching it, because the certificate is
// replaced every sixty days and a stale one takes every listener down
// (PLAN.md section 8).
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider {
// Mode B: LambdaMail obtains the certificate itself. This is the only
// mode DANE can run in, because it is the only one where the key for the
// next certificate exists before that certificate does
// (PLAN.md sections 5.1 and 8.3).
if cfg.TLSMode == "acme" {
if vaultErr != nil {
log.Fatalf("TLS_MODE=acme needs LAMBDAMAIL_MASTER_KEY to seal the account and certificate keys: %v", vaultErr)
}
if cfg.CloudflareToken == "" {
log.Fatalf("TLS_MODE=acme needs CLOUDFLARE_API_TOKEN: the DNS-01 challenge is answered through the Cloudflare API")
}
provider, err := startAcmeManager(ctx, cfg, pool, secretVault)
if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err)
log.Fatalf("start self-managed ACME: %v", err)
}
return provider
}
if cfg.TraefikAcmeDir != "" {
watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost)
if err == nil {
if !watcher.HasCertificateFor(cfg.PrimaryMailHost) {
log.Printf("CRITICAL: the ACME store has no certificate for %s - declare a Traefik router for that host so ACME issues one (PLAN.md section 8.2)", cfg.PrimaryMailHost)
}
go watcher.Watch(ctx.Done(), cfg.CertPollInterval)
log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval)
return watcher
}
log.Printf("WARNING: could not read the ACME store (%v) - falling back", err)
}
if cfg.StaticCertPath != "" {
if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil {
return provider
} else {
log.Printf("WARNING: could not load the configured TLS certificate: %v", err)
}
}
backend := smtppresentation.NewBackend(useCase)
smtpServer := gosmtp.NewServer(backend)
smtpServer.Addr = ":25"
smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost")
smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration
smtpServer.MaxRecipients = 100
smtpServer.TLSConfig = &tls.Config{
log.Printf("CRITICAL: falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for a real deployment")
provider, err := tlsprovider.NewEphemeralSelfSignedCertProvider()
if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err)
}
return provider
}
func relayConfig(cfg config) usecase.RelayConfig {
return usecase.RelayConfig{
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPass,
}
}
// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what
// a verifier resolves through the same selector.
func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer {
domain := cfg.domain()
active, err := keys.FindActiveKeys(ctx, domain)
if err != nil || len(active) == 0 {
log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain)
return nil
}
for _, key := range active {
signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM)
if err != nil {
continue
}
return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer)
}
log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain)
return nil
}
func buildScanner(cfg config) port.ContentScanner {
var scanners []port.ContentScanner
if cfg.ClamavAddr != "" {
scanners = append(scanners, clamav.NewClamAVAdapter(cfg.ClamavAddr))
}
if cfg.RspamdURL != "" {
scanners = append(scanners, rspamd.NewRspamdAdapter(cfg.RspamdURL))
}
if len(scanners) == 0 {
return nil
}
log.Printf("lambdamail-protocols content scanning enabled (clamav: %q, rspamd: %q)", cfg.ClamavAddr, cfg.RspamdURL)
return usecase.NewScanningPipeline(scanners...)
}
// buildPolicyResolver assembles the outbound transport security policy of
// PLAN.md section 6.2.
func buildPolicyResolver(cfg config) port.TLSPolicyResolver {
// With a smarthost the relay takes responsibility for transport security
// (PLAN.md section 10.4), so applying DANE and MTA-STS here would only
// evaluate policies against the wrong hop.
if cfg.RelayHost != "" {
log.Printf("lambdamail-protocols relaying through %s: DANE and MTA-STS are handled by the relay", cfg.RelayHost)
return tlspolicy.NewResolver(tlspolicy.WithMtaSts(false), tlspolicy.WithDane(false, nil))
}
options := []tlspolicy.Option{tlspolicy.WithMtaSts(cfg.OutboundMtaSts)}
if cfg.OutboundDane {
options = append(options, tlspolicy.WithDane(true, netdns.NewTlsaResolver(cfg.DnssecResolver)))
}
log.Printf("lambdamail-protocols outbound TLS policy: dane=%v mta-sts=%v", cfg.OutboundDane, cfg.OutboundMtaSts)
return tlspolicy.NewResolver(options...)
}
func applyMtaStsMode(router *httppresentation.Router, cfg config) {
mode := valueobject.MtaStsMode(cfg.MtaStsMode)
switch mode {
case valueobject.MtaStsModeTesting, valueobject.MtaStsModeEnforce, valueobject.MtaStsModeNone:
router.SetMtaStsMode(mode)
log.Printf("lambdamail-protocols MTA-STS policy mode: %s", mode)
default:
log.Printf("WARNING: ignoring unknown MTA_STS_MODE %q, serving %q", mode, valueobject.MtaStsModeTesting)
}
}
// startSMTPListeners brings up the three SMTP ports of PLAN.md section 4. The
// MX port and the submission ports run different backends: port 25 never
// authenticates and only accepts local mail, while submission always
// authenticates and relays outward.
func startSMTPListeners(cfg config, certProvider port.CertProvider, inboundUC *usecase.ProcessInboundEmailUseCase, submissionUC *usecase.ProcessOutboundEmailUseCase) {
tlsConfig := mailTLSConfig(certProvider)
// Port 25: STARTTLS is always advertised but never required. RFC 5321
// obliges an MX to accept cleartext, and refusing it loses legitimate
// mail without improving any audit score (PLAN.md section 4).
mx := gosmtp.NewServer(smtppresentation.NewBackend(inboundUC))
mx.Addr = ":25"
mx.Domain = cfg.PrimaryMailHost
mx.MaxMessageBytes = cfg.MaxMessageBytes
mx.MaxRecipients = 100
mx.ReadTimeout = 5 * time.Minute
mx.WriteTimeout = 5 * time.Minute
mx.TLSConfig = tlsConfig
serveInBackground("SMTP MX", ":25", mx.ListenAndServe)
// Port 587: submission over STARTTLS. AllowInsecureAuth stays false, so
// go-smtp withholds the AUTH capability until the connection is
// encrypted - credentials never travel in the clear.
submission := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
submission.Addr = ":587"
submission.Domain = cfg.PrimaryMailHost
submission.MaxMessageBytes = cfg.MaxMessageBytes
submission.MaxRecipients = 100
submission.ReadTimeout = 5 * time.Minute
submission.WriteTimeout = 5 * time.Minute
submission.TLSConfig = tlsConfig
submission.AllowInsecureAuth = false
serveInBackground("SMTP submission", ":587", submission.ListenAndServe)
// Port 465: implicit TLS (RFC 8314), the recommended submission port.
submissions := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
submissions.Addr = ":465"
submissions.Domain = cfg.PrimaryMailHost
submissions.MaxMessageBytes = cfg.MaxMessageBytes
submissions.MaxRecipients = 100
submissions.ReadTimeout = 5 * time.Minute
submissions.WriteTimeout = 5 * time.Minute
submissions.TLSConfig = tlsConfig
serveInBackground("SMTPS submission", ":465", submissions.ListenAndServeTLS)
}
func startIMAPListeners(cfg config, certProvider port.CertProvider, imapUseCase *usecase.ImapSessionUseCase) {
newServer := func() *imapserver.Server {
return imapserver.New(&imapserver.Options{
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return imappresentation.NewSession(c, imapUseCase)
},
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapMove: {},
imap.CapUIDPlus: {},
imap.CapIdle: {},
imap.CapCondStore: {},
},
TLSConfig: mailTLSConfig(certProvider),
// LOGINDISABLED is advertised before TLS: the client must
// upgrade before it may send credentials (PLAN.md section 4).
InsecureAuth: false,
})
}
serveInBackground("IMAP", ":143", func() error { return newServer().ListenAndServe(":143") })
serveInBackground("IMAPS", ":993", func() error {
return serveImplicitTLS(":993", mailTLSConfig(certProvider), newServer().Serve)
})
}
func startPOP3Listeners(certProvider port.CertProvider, pop3UseCase *usecase.Pop3SessionUseCase) {
serveInBackground("POP3", ":110", pop3presentation.NewServer(":110", pop3UseCase, certProvider).ListenAndServe)
serveInBackground("POP3S", ":995", func() error {
server := pop3presentation.NewServer(":995", pop3UseCase, certProvider)
return serveImplicitTLS(":995", mailTLSConfig(certProvider), server.Serve)
})
}
func startManageSieveListener(certProvider port.CertProvider, sieveUseCase *usecase.ManageSieveSessionUseCase) {
serveInBackground("ManageSieve", ":4190", managesievepresentation.NewServer(":4190", sieveUseCase, certProvider).ListenAndServe)
}
// serveImplicitTLS wraps a plain listener in TLS, which is what the implicit
// ports (465, 993, 995) need: the handshake happens before any protocol
// greeting rather than after a STARTTLS command.
func serveImplicitTLS(addr string, tlsConfig *tls.Config, serve func(net.Listener) error) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return serve(tls.NewListener(ln, tlsConfig))
}
// serveInBackground runs a listener and logs a fatal error if it dies. A
// protocol port that silently fails to bind would look healthy while being
// unreachable, so failure is loud.
func serveInBackground(name, addr string, serve func() error) {
go func() {
log.Printf("lambdamail-protocols %s listening on %s", name, addr)
if err := serve(); err != nil {
log.Fatalf("%s serve on %s: %v", name, addr, err)
}
}()
}
// mailTLSConfig is the cipher policy of PLAN.md section 4: TLS 1.2 floor,
// forward secrecy only, and the three curves named there.
func mailTLSConfig(certProvider port.CertProvider) *tls.Config {
return &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
@@ -93,99 +437,120 @@ func main() {
},
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
}
}
go func() {
log.Printf("lambdamail-protocols SMTP listening on %s", smtpServer.Addr)
if err := smtpServer.ListenAndServe(); err != nil {
log.Fatalf("smtp serve: %v", err)
}
}()
func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCase) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
authRepo := postgres.NewAuthRepository(pool)
imapFolders := postgres.NewImapFolderRepository(pool)
messageQuery := postgres.NewMessageQueryRepository(pool)
flagRepo := postgres.NewFlagRepository(pool)
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
expungeRepo := postgres.NewExpungeRepository(pool)
copyRepo := postgres.NewCopyRepository(pool)
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
useCase.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
imapServer := imapserver.New(&imapserver.Options{
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return imappresentation.NewSession(c, imapUseCase)
},
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapMove: {},
imap.CapUIDPlus: {},
imap.CapIdle: {},
imap.CapCondStore: {},
},
TLSConfig: &tls.Config{GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12},
InsecureAuth: false,
})
go func() {
log.Printf("lambdamail-protocols IMAP listening on :143")
if err := imapServer.ListenAndServe(":143"); err != nil {
log.Fatalf("imap serve: %v", err)
}
}()
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
pop3Server := pop3presentation.NewServer(":110", pop3UseCase, certProvider)
go func() {
log.Printf("lambdamail-protocols POP3 listening on :110")
if err := pop3Server.ListenAndServe(); err != nil {
log.Fatalf("pop3 serve: %v", err)
}
}()
sieveRepo := postgres.NewSieveRepository(pool)
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider)
go func() {
log.Printf("lambdamail-protocols ManageSieve listening on :4190")
if err := sieveServer.ListenAndServe(); err != nil {
log.Fatalf("managesieve serve: %v", err)
}
}()
reportRepo := postgres.NewReportRepository(pool)
reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
outboundRepo := postgres.NewOutboundRepository(pool)
mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, useCase, mailboxes, smtpServer.Domain)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := worker.ProcessBatch(ctx, "protocols-worker-1", 10); err != nil {
log.Printf("delivery worker batch failed: %v", err)
}
}
}()
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
addr := ":8080"
log.Printf("lambdamail-protocols HTTP service listening on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("http serve: %v", err)
}
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
// runDnsReconciler provisions the DKIM keys and reconciles the zone, then
// repeats on the interval of PLAN.md section 7.5 to detect drift.
func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository, dkimRepo *postgres.DkimRepository) {
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier())
var provisioner *usecase.ProvisionDkimKeysUseCase
if dkimRepo != nil {
provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey)
}
reconcile := func() {
domain := cfg.domain()
input := usecase.SyncDnsRecordsInput{
DomainName: domain,
MailHost: cfg.PrimaryMailHost,
ServerIPv4: cfg.PublicIPv4,
ServerIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
// A smarthost sends on the domain's behalf, so the hard-fail SPF
// has to authorise it or every relayed message fails
// (PLAN.md section 10.4).
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
}
if provisioner != nil {
keys, err := provisioner.Execute(ctx, domain)
if err != nil {
log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err)
return
}
input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey
if len(keys.Created) > 0 {
log.Printf("DNS reconcile: generated DKIM keys for %s: %v", domain, keys.Created)
}
}
out, err := syncUC.Execute(ctx, input)
if err != nil {
log.Printf("DNS reconcile for %s failed: %v", domain, err)
return
}
log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount)
// Conflicts and unresolvable records are reported individually
// because each needs a different operator action.
for _, conflict := range out.Conflicts {
log.Printf("DNS conflict (left untouched): %s", conflict)
}
for _, unverified := range out.Unverified {
log.Printf("DNS record not yet visible to public resolvers: %s", unverified)
}
for _, e := range out.Errors {
log.Printf("DNS reconcile error: %s", e)
}
}
reconcile()
ticker := time.NewTicker(cfg.DnsSyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcile()
}
}
}
// generateDkimKey adapts the key generator to the use case's signature,
// keeping the crypto in the infrastructure layer.
func generateDkimKey(algorithm string) ([]byte, string, error) {
generated, err := dkim.Generate(algorithm)
if err != nil {
return nil, "", err
}
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
}
func runHealthcheck() {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://127.0.0.1:8080/health")
if err != nil {
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "healthcheck failed: HTTP %d\n", resp.StatusCode)
os.Exit(1)
}
return fallback
}
@@ -0,0 +1,112 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"time"
"lambdamail/protocols/internal/application/usecase"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
)
// runPreflight executes the environment checks of PLAN.md section 15 and
// exits non-zero when a critical one fails, so the deploy pipeline can gate
// on it.
func runPreflight(cfg config) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
report := usecase.NewPreflightUseCase(buildPreflightDeps(cfg)).Execute(ctx, usecase.PreflightInput{
PrimaryMailHost: cfg.PrimaryMailHost,
MailDomain: cfg.domain(),
PublicIPv4: cfg.PublicIPv4,
PublicIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
TLSMode: cfg.TLSMode,
SmarthostSet: cfg.RelayHost != "",
})
fmt.Print(report)
if report.Blocking() {
fmt.Fprintln(os.Stderr, "\npreflight FAILED: at least one critical check did not pass")
os.Exit(1)
}
fmt.Println("\npreflight passed")
}
func buildPreflightDeps(cfg config) usecase.PreflightDeps {
resolver := net.DefaultResolver
deps := usecase.PreflightDeps{
DialPort25: func(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
return conn.Close()
},
LookupAddr: resolver.LookupAddr,
LookupHost: resolver.LookupHost,
LookupTXT: resolver.LookupTXT,
FetchMtaSts: func(ctx context.Context, domain string) error {
url := fmt.Sprintf("https://mta-sts.%s/.well-known/mta-sts.txt", domain)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// The certificate is validated on purpose: RFC 8461 section 3.3
// requires the policy to be served under a valid certificate for
// the mta-sts host, and a sender that cannot validate it ignores
// the policy entirely.
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
},
FreeDiskPercent: func() (float64, error) { return freeDiskPercent(cfg.SpoolDir) },
Now: time.Now,
NetworkTime: httpDateTime,
}
// The certificate check only means something when a store is configured;
// otherwise it stays skipped rather than reporting a false pass.
if cfg.TraefikAcmeDir != "" {
if watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost); err == nil {
deps.HasCertificateFor = watcher.HasCertificateFor
}
}
return deps
}
// httpDateTime reads a trusted clock from the Date header of a well-known
// HTTPS endpoint. It is not NTP, but it is accurate to about a second, which
// is enough to catch the drift that breaks DKIM and TOTP.
func httpDateTime(ctx context.Context) (time.Time, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://www.cloudflare.com/", nil)
if err != nil {
return time.Time{}, err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
dateHeader := resp.Header.Get("Date")
if dateHeader == "" {
return time.Time{}, fmt.Errorf("no Date header in the response")
}
return http.ParseTime(dateHeader)
}