diff --git a/README.md b/README.md index 93c1c43..c76e248 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a - **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports -- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails +- **LMTP Server**: Built-in LMTP server for seamless MTA integration - **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings @@ -90,54 +90,47 @@ happyDeliver will not perform thoses checks, it relies instead on standard softw Choose one of the following way to integrate happyDeliver in your existing setup: -#### Postfix Transport rule +#### Postfix LMTP Transport -You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html). +You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP. -1. Append the following lines at the end of your `master.cf` file: +1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`): - ```diff - + - +# happyDeliver analyzer - receives emails matching transport_maps - +happydeliver unix - n n - - pipe - + flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient} + ```bash + ./happyDeliver server ``` -2. Create the file `/etc/postfix/transport_happyDeliver` with the following content: + You can customize the LMTP address with the `-lmtp-addr` flag or in the config file. + +2. Create the file `/etc/postfix/transport_happydeliver` with the following content: ``` - # Transport map - route test emails to happyDeliver analyzer - # Pattern: test-@yourdomain.com -> happydeliver pipe + # Transport map - route test emails to happyDeliver LMTP server + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver: + /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 ``` 3. Append the created file to `transport_maps` in your `main.cf`: ```diff -transport_maps = texthash:/etc/postfix/transport - +transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_maps + +transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver ``` If your `transport_maps` option is not set, just append this line: ``` - transport_maps = pcre:/etc/postfix/transport_maps + transport_maps = pcre:/etc/postfix/transport_happydeliver ``` Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. -#### Postfix Aliases +4. Reload Postfix configuration: -You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`). - -Add the following line in your `/etc/postfix/aliases`: - -```diff -+test: "|/path/to/happyDeliver analyze" -``` - -Note that the recipient address has to be present in header. + ```bash + postfix reload + ``` #### 4. Create a Test @@ -175,9 +168,9 @@ curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 | `/api/report/{id}/raw` | GET | Get raw annotated email | | `/api/status` | GET | Service health and status | -## Email Analyzer (MDA Mode) +## Email Analyzer (CLI Mode) -To process an email from an MTA pipe: +For manual testing or debugging, you can analyze emails from the command line: ```bash cat email.eml | ./happyDeliver analyze @@ -189,6 +182,8 @@ Or specify recipient explicitly: cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com ``` +**Note:** In production, emails are delivered via LMTP (see integration instructions above). + ## Scoring System The deliverability score is calculated from 0 to 10 based on: diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index a12b13f..92976a4 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -81,7 +81,3 @@ policy-spf unix - n n - 0 spawn # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} - -# happyDeliver analyzer - receives emails matching transport_maps -happydeliver unix - n n - - pipe - flags=DRXhu user=happydeliver argv=/usr/local/bin/happyDeliver analyze -config /etc/happydeliver/config.yaml -recipient ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index c12f4cc..49fdb98 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ -# Transport map - route test emails to happyDeliver analyzer -# Pattern: test-@domain.com -> happydeliver pipe +# Transport map - route test emails to happyDeliver LMTP server +# Pattern: test-@domain.com -> LMTP on localhost:2525 -/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ happydeliver: +/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/go.mod b/go.mod index 74c97cd..ce87ef6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.happydns.org/happyDeliver go 1.24.6 require ( + github.com/emersion/go-smtp v0.24.0 github.com/getkin/kin-openapi v0.132.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 @@ -19,6 +20,7 @@ require ( github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/go.sum b/go.sum index 4b1490e..cf49874 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= diff --git a/internal/app/server.go b/internal/app/server.go index 9c7d28b..8db4b59 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -29,11 +29,12 @@ import ( "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/web" ) -// RunServer starts the API server server +// RunServer starts the API server and LMTP server func RunServer(cfg *config.Config) error { if err := cfg.Validate(); err != nil { return err @@ -48,6 +49,13 @@ func RunServer(cfg *config.Config) error { log.Printf("Connected to %s database", cfg.Database.Type) + // Start LMTP server in background + go func() { + if err := lmtp.StartServer(cfg.Email.LMTPAddr, store, cfg); err != nil { + log.Fatalf("Failed to start LMTP server: %v", err) + } + }() + // Create API handler handler := api.NewAPIHandler(store, cfg) diff --git a/internal/config/cli.go b/internal/config/cli.go index eee2c4c..cfd5908 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -33,6 +33,7 @@ func declareFlags(o *Config) { flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path") flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") + flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") diff --git a/internal/config/config.go b/internal/config/config.go index a866fbd..3304a8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ type DatabaseConfig struct { type EmailConfig struct { Domain string TestAddressPrefix string + LMTPAddr string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -73,6 +74,7 @@ func DefaultConfig() *Config { Email: EmailConfig{ Domain: "happydeliver.local", TestAddressPrefix: "test-", + LMTPAddr: "127.0.0.1:2525", }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go new file mode 100644 index 0000000..1d9a720 --- /dev/null +++ b/internal/lmtp/server.go @@ -0,0 +1,144 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package lmtp + +import ( + "fmt" + "io" + "log" + "net" + + "github.com/emersion/go-smtp" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/receiver" + "git.happydns.org/happyDeliver/internal/storage" +) + +// Backend implements smtp.Backend for LMTP server +type Backend struct { + receiver *receiver.EmailReceiver + config *config.Config +} + +// NewBackend creates a new LMTP backend +func NewBackend(store storage.Storage, cfg *config.Config) *Backend { + return &Backend{ + receiver: receiver.NewEmailReceiver(store, cfg), + config: cfg, + } +} + +// NewSession creates a new SMTP/LMTP session +func (b *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) { + return &Session{backend: b}, nil +} + +// Session implements smtp.Session for handling LMTP connections +type Session struct { + backend *Backend + from string + recipients []string +} + +// AuthPlain implements PLAIN authentication (not used for local LMTP) +func (s *Session) AuthPlain(username, password string) error { + // No authentication required for local LMTP + return nil +} + +// Mail is called when MAIL FROM command is received +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { + log.Printf("LMTP: MAIL FROM: %s", from) + s.from = from + return nil +} + +// Rcpt is called when RCPT TO command is received +func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { + log.Printf("LMTP: RCPT TO: %s", to) + s.recipients = append(s.recipients, to) + return nil +} + +// Data is called when DATA command is received and email content is being transferred +func (s *Session) Data(r io.Reader) error { + log.Printf("LMTP: Receiving message data for %d recipient(s)", len(s.recipients)) + + // Read the entire email + emailData, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read email data: %w", err) + } + + log.Printf("LMTP: Received %d bytes", len(emailData)) + + // Process email for each recipient + // LMTP requires per-recipient status, but go-smtp handles this internally + for _, recipient := range s.recipients { + if err := s.backend.receiver.ProcessEmailBytes(emailData, recipient); err != nil { + log.Printf("LMTP: Failed to process email for %s: %v", recipient, err) + return fmt.Errorf("failed to process email for %s: %w", recipient, err) + } + log.Printf("LMTP: Successfully processed email for %s", recipient) + } + + return nil +} + +// Reset is called when RSET command is received +func (s *Session) Reset() { + log.Printf("LMTP: Session reset") + s.from = "" + s.recipients = nil +} + +// Logout is called when the session is closed +func (s *Session) Logout() error { + log.Printf("LMTP: Session logout") + return nil +} + +// StartServer starts an LMTP server on the specified address +func StartServer(addr string, store storage.Storage, cfg *config.Config) error { + backend := NewBackend(store, cfg) + + server := smtp.NewServer(backend) + server.Addr = addr + server.Domain = cfg.Email.Domain + server.AllowInsecureAuth = true + server.LMTP = true // Enable LMTP mode + + log.Printf("Starting LMTP server on %s", addr) + + // Create TCP listener explicitly + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to create LMTP listener: %w", err) + } + + if err := server.Serve(listener); err != nil { + return fmt.Errorf("LMTP server error: %w", err) + } + + return nil +}