Add LMTP server
This commit is contained in:
parent
18c2f95112
commit
3867fa36a2
9 changed files with 187 additions and 35 deletions
49
README.md
49
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-<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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
144
internal/lmtp/server.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue