Add LMTP server

This commit is contained in:
nemunaire 2025-10-18 11:41:28 +07:00
commit 3867fa36a2
9 changed files with 187 additions and 35 deletions

View file

@ -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-<uuid>@yourdomain.com -> happydeliver pipe
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@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:

View file

@ -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}

View file

@ -1,4 +1,4 @@
# Transport map - route test emails to happyDeliver analyzer
# Pattern: test-<uuid>@domain.com -> happydeliver pipe
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@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

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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)

View file

@ -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)")

View file

@ -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,

144
internal/lmtp/server.go Normal file
View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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
}