all repos — postern

@ 9799a3215ac0185fb9b4e7d6ec0645c2cb2ce55b

Modern mail management

clone
git clone https:///postern.git

internal/inbound/inbound.go (view raw)

  1package inbound
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/tls"
  7	"database/sql"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"log/slog"
 12	"net"
 13	"net/mail"
 14	"os"
 15	"strings"
 16	"sync"
 17	"time"
 18
 19	"postern/internal/db"
 20	"postern/internal/model"
 21	"postern/internal/policy"
 22
 23	"github.com/emersion/go-imap/v2/imapserver"
 24	"github.com/emersion/go-sasl"
 25	smtpserver "github.com/emersion/go-smtp"
 26	"github.com/google/uuid"
 27)
 28
 29type MessageDeliverer interface {
 30	SubmitMessage(ctx context.Context, conn net.Conn, user model.User, r io.Reader) error
 31}
 32
 33type Config struct {
 34	DB           *db.DB
 35	Deliverer    MessageDeliverer
 36	Listen       string
 37	TLS          *tls.Config
 38	PolicyEngine *policy.Engine
 39}
 40type Server struct {
 41	db           *db.DB
 42	listen       string
 43	deliverer    MessageDeliverer
 44	tlsConfig    *tls.Config
 45	policyEngine *policy.Engine
 46
 47	mu      sync.Mutex
 48	smtpSrv *smtpserver.Server
 49	status  string
 50}
 51
 52func NewServer(config *Config) (*Server, error) {
 53	return &Server{
 54		db:           config.DB,
 55		listen:       config.Listen,
 56		deliverer:    config.Deliverer,
 57		tlsConfig:    config.TLS,
 58		policyEngine: config.PolicyEngine,
 59	}, nil
 60}
 61
 62func (i *Server) Start() error {
 63	be := &backend{
 64		db:           i.db,
 65		deliverer:    i.deliverer,
 66		policyEngine: i.policyEngine,
 67	}
 68	s := smtpserver.NewServer(be)
 69
 70	s.Addr = i.listen
 71	s.AllowInsecureAuth = false
 72	s.TLSConfig = i.tlsConfig
 73	s.MaxMessageBytes = 20_000_000
 74
 75	i.mu.Lock()
 76	i.smtpSrv = s
 77	i.status = "running"
 78	i.mu.Unlock()
 79
 80	err := s.ListenAndServe()
 81	if err != nil {
 82		return err
 83	}
 84
 85	return nil
 86}
 87
 88func (i *Server) Stop() error {
 89	i.mu.Lock()
 90	defer i.mu.Unlock()
 91	i.status = "stopped"
 92	if i.smtpSrv != nil {
 93		return i.smtpSrv.Close()
 94	}
 95	return nil
 96}
 97
 98func (i *Server) Status() string {
 99	i.mu.Lock()
100	defer i.mu.Unlock()
101	return i.status
102}
103
104type smtpSession struct {
105	db                *db.DB
106	conn              *smtpserver.Conn
107	deliverer         MessageDeliverer
108	authenticatedUser *model.SMTPAuth
109	toAddress         string
110	fromAddress       string
111	destinationUser   model.User
112	sessionID         string
113	policyEngine      *policy.Engine
114	logger            *slog.Logger
115}
116
117// The backend implements SMTP server methods.
118type backend struct {
119	db           *db.DB
120	deliverer    MessageDeliverer
121	policyEngine *policy.Engine
122}
123
124// NewSession is called after client greeting (EHLO, HELO).
125func (bkd *backend) NewSession(c *smtpserver.Conn) (smtpserver.Session, error) {
126	sessionID := uuid.NewString()
127	l := slog.With("component", "inbound", "remote_ip", c.Conn().RemoteAddr().String(), "session_id", sessionID)
128	l.Info("new session")
129	return &smtpSession{
130		db:                bkd.db,
131		conn:              c,
132		deliverer:         bkd.deliverer,
133		authenticatedUser: &model.SMTPAuth{},
134		sessionID:         sessionID,
135		logger:            l,
136		policyEngine:      bkd.policyEngine,
137	}, nil
138}
139
140// AuthMechanisms returns a slice of available auth mechanisms
141func (s *smtpSession) AuthMechanisms() []string {
142	return []string{sasl.Plain}
143}
144
145// Auth is the handler for supported authenticators.
146func (s *smtpSession) Auth(_ string) (sasl.Server, error) {
147	return sasl.NewPlainServer(func(identity, username, password string) error {
148		u, err := s.db.GetSMTPAuthUser(context.Background(), username)
149		if err != nil {
150			if errors.Is(err, sql.ErrNoRows) {
151				s.logger.Info("user not found")
152			} else {
153				s.logger.Error("getting user", "error", err)
154			}
155			return imapserver.ErrAuthFailed
156		}
157
158		err = u.VerifyPassword([]byte(password))
159		if err != nil {
160			slog.Info("invalid password", "username", username)
161			return imapserver.ErrAuthFailed
162		}
163
164		s.authenticatedUser = &u
165		s.logger.Info("authenticated", "user_id", u.ID, "username", username)
166		return nil
167	}), nil
168}
169
170func (s *smtpSession) Reset() {
171	s.toAddress = ""
172	s.fromAddress = ""
173}
174
175func (s *smtpSession) Logout() error {
176	s.authenticatedUser = nil
177	return nil
178}
179
180func (s *smtpSession) Mail(from string, _ *smtpserver.MailOptions) error {
181	if s.authenticatedUser == nil {
182		return smtpserver.ErrAuthFailed
183	}
184
185	s.logger.Info("MAIL FROM", "from", from)
186	s.fromAddress = from
187
188	return nil
189
190}
191
192func (s *smtpSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
193	if s.authenticatedUser == nil {
194		return smtpserver.ErrAuthFailed
195	}
196	ctx := context.Background()
197	s.logger.InfoContext(ctx, "RCPT TO", "to", to)
198
199	addr, err := mail.ParseAddress(to)
200	if err != nil {
201		s.logger.InfoContext(ctx, "RCPT TO", "error", err)
202		return &smtpserver.SMTPError{
203			Code:         501,
204			EnhancedCode: smtpserver.EnhancedCode{5, 1, 3},
205			Message:      "Bad recipient address syntax",
206		}
207	}
208
209	atIndex := strings.LastIndex(addr.Address, "@")
210	if atIndex == -1 {
211		s.logger.InfoContext(ctx, "RCPT TO", "error", "malformed email: missing @ domain separator")
212		return &smtpserver.SMTPError{
213			Code:         501,
214			EnhancedCode: smtpserver.EnhancedCode{5, 1, 3},
215			Message:      "Bad recipient address syntax",
216		}
217	}
218
219	localPart := addr.Address[:atIndex]
220	domain := addr.Address[atIndex+1:]
221	baseName, tag, _ := strings.Cut(localPart, "+")
222
223	s.logger.InfoContext(ctx, "parsed address", "baseName", baseName, "tag", tag, "domain", domain)
224
225	u, err := s.db.GetUserForAddress(ctx, fmt.Sprintf("%s@%s", baseName, domain))
226	if err != nil {
227		if errors.Is(err, sql.ErrNoRows) {
228			s.logger.InfoContext(ctx, "RCPT TO address not found")
229			return &smtpserver.SMTPError{
230				Code:         550,
231				EnhancedCode: smtpserver.EnhancedCode{5, 1, 1},
232				Message:      "Mailbox unavailable",
233			}
234		}
235		s.logger.ErrorContext(ctx, "RCPT TO get address", "error", err)
236		return &smtpserver.SMTPError{
237			Code:         451,
238			EnhancedCode: smtpserver.EnhancedCode{4, 3, 0},
239			Message:      "Temporary local problem, please try again later",
240		}
241	}
242
243	s.destinationUser = u
244	s.toAddress = to
245
246	s.logger.InfoContext(ctx, "RCPT TO", "destination_user", u.Name, "destination_user_id", u.ID)
247
248	return nil
249}
250
251func (s *smtpSession) Data(r io.Reader) error {
252	if s.authenticatedUser == nil {
253		return smtpserver.ErrAuthFailed
254	}
255
256	ctx := context.Background()
257	s.logger.InfoContext(ctx, "DATA")
258
259	var buffer bytes.Buffer
260
261	// Prepend headers
262	_, _ = fmt.Fprintf(&buffer, "Return-Path: %s\r\n", s.fromAddress)
263	myHostname, err := os.Hostname()
264	if err != nil {
265		myHostname = "postern.local"
266	}
267	with := "ESMTPSA" // we only allow encrypted (S) and authenticated (A) connections for inbound
268	receivedTimestampLayout := "Mon, 02 Jan 2006 15:04:05 -0700 (UTC)"
269	receivedTimestamp := time.Now().UTC().Format(receivedTimestampLayout)
270	_, _ = fmt.Fprintf(&buffer, "Received: from %s (%s) by %s (Postern p25.dev) with %s id %s for <%s>; %s\r\n",
271		s.conn.Hostname(), s.conn.Conn().RemoteAddr().String(), myHostname, with, s.sessionID, s.toAddress, receivedTimestamp)
272
273	_, err = io.Copy(&buffer, r)
274	if err != nil {
275		s.logger.ErrorContext(ctx, "Copy", "error", err)
276		return errInternalServerError
277	}
278
279	message := buffer.Bytes()
280	err = s.deliverer.SubmitMessage(ctx, s.conn.Conn(), s.destinationUser, bytes.NewReader(message))
281	if err != nil {
282		if errors.Is(err, model.ErrMissingFromHeader) {
283			return &smtpserver.SMTPError{
284				Code:         550,
285				EnhancedCode: smtpserver.EnhancedCode{5, 7, 1},
286				Message:      "From header is required but missing",
287			}
288		}
289		s.logger.ErrorContext(ctx, "SubmitMessage", "error", err)
290		return errInternalServerError
291	}
292	return nil
293}