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

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