all repos — postern @ fc287763b9ce6b6f9823e11c0b1173da0446ebc5

Modern mail management

internal/submission/submission.go (view raw)

  1package submission
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/tls"
  7	"database/sql"
  8	"errors"
  9	"io"
 10	"log/slog"
 11	"sync"
 12
 13	"postern/internal/db"
 14	"postern/internal/dkim"
 15	"postern/internal/model"
 16	"postern/internal/policy"
 17
 18	"github.com/emersion/go-sasl"
 19	smtpserver "github.com/emersion/go-smtp"
 20	"github.com/google/uuid"
 21)
 22
 23type Config struct {
 24	DB                *db.DB
 25	DKIM              *dkim.DKIM
 26	Listen            string
 27	TLS               *tls.Config
 28	PolicyEngine      *policy.Engine
 29	IsLocalSubmission bool // If set, will disable TLS and Auth and accept all local mails for delivery.
 30}
 31type Server struct {
 32	db           *db.DB
 33	dkim         *dkim.DKIM
 34	listen       string
 35	tlsConfig    *tls.Config
 36	policyEngine *policy.Engine
 37
 38	mu                sync.Mutex
 39	smtpSrv           *smtpserver.Server
 40	status            string
 41	isLocalSubmission bool
 42}
 43
 44func NewServer(config *Config) (*Server, error) {
 45	return &Server{
 46		db:                config.DB,
 47		dkim:              config.DKIM,
 48		listen:            config.Listen,
 49		tlsConfig:         config.TLS,
 50		policyEngine:      config.PolicyEngine,
 51		isLocalSubmission: config.IsLocalSubmission,
 52	}, nil
 53}
 54
 55func (i *Server) Start() error {
 56	be := &backend{
 57		db:                i.db,
 58		dkim:              i.dkim,
 59		isLocalSubmission: i.isLocalSubmission,
 60		policyEngine:      i.policyEngine,
 61	}
 62	s := smtpserver.NewServer(be)
 63
 64	s.Addr = i.listen
 65	s.MaxMessageBytes = 20_000_000
 66
 67	i.mu.Lock()
 68	i.smtpSrv = s
 69	i.status = "running"
 70	i.mu.Unlock()
 71
 72	if i.isLocalSubmission {
 73		err := s.ListenAndServe()
 74		if err != nil {
 75			return err
 76		}
 77	} else {
 78		s.TLSConfig = i.tlsConfig
 79		err := s.ListenAndServeTLS()
 80		if err != nil {
 81			return err
 82		}
 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	dkim              *dkim.DKIM
107	conn              *smtpserver.Conn
108	authenticatedUser model.User
109	sessionID         string
110	mailFrom          string
111	rcptTo            []string
112	logger            *slog.Logger
113	isLocalSubmission bool
114	policyEngine      *policy.Engine
115}
116
117// The backend implements SMTP server methods.
118type backend struct {
119	db                *db.DB
120	dkim              *dkim.DKIM
121	isLocalSubmission bool
122	policyEngine      *policy.Engine
123}
124
125// NewSession is called after client greeting (EHLO, HELO).
126func (bkd *backend) NewSession(c *smtpserver.Conn) (smtpserver.Session, error) {
127	sessionID := uuid.NewString()
128	l := slog.With("component", "submission", "remote_ip", c.Conn().RemoteAddr().String(), "session_id", sessionID)
129	l.Info("new session")
130	return &smtpSession{
131		db:                bkd.db,
132		dkim:              bkd.dkim,
133		conn:              c,
134		authenticatedUser: model.User{},
135		sessionID:         sessionID,
136		logger:            l,
137		rcptTo:            make([]string, 0),
138		isLocalSubmission: bkd.isLocalSubmission,
139		policyEngine:      bkd.policyEngine,
140	}, nil
141}
142
143// AuthMechanisms returns a slice of available auth mechanisms
144func (s *smtpSession) AuthMechanisms() []string {
145	return []string{sasl.Plain}
146}
147
148// Auth is the handler for supported authenticators.
149func (s *smtpSession) Auth(_ string) (sasl.Server, error) {
150	return sasl.NewPlainServer(func(identity, username, password string) error {
151		u, err := s.db.GetUser(context.Background(), username)
152		if err != nil {
153			if errors.Is(err, sql.ErrNoRows) {
154				s.logger.Info("user not found")
155			} else {
156				s.logger.Error("getting user", "error", err)
157			}
158			return smtpserver.ErrAuthFailed
159		}
160
161		err = u.VerifyPassword([]byte(password))
162		if err != nil {
163			slog.Info("invalid password", "username", username)
164			return smtpserver.ErrAuthFailed
165		}
166
167		s.authenticatedUser = u
168		s.logger.Info("authenticated", "user_id", u.ID, "username", username)
169		return nil
170	}), nil
171}
172
173func (s *smtpSession) Reset() {
174	s.mailFrom = ""
175	s.rcptTo = make([]string, 0)
176}
177
178func (s *smtpSession) Logout() error {
179	s.authenticatedUser = model.User{}
180	return nil
181}
182
183func (s *smtpSession) Mail(from string, _ *smtpserver.MailOptions) error {
184	ctx := context.Background()
185	s.logger.InfoContext(ctx, "MAIL FROM", "from", from)
186
187	if !s.isLocalSubmission && s.authenticatedUser.ID == 0 {
188		s.logger.InfoContext(ctx, "no authenticated user")
189		return smtpserver.ErrAuthFailed
190	}
191
192	s.mailFrom = from
193	return nil
194}
195
196func (s *smtpSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
197	ctx := context.Background()
198	s.logger.InfoContext(ctx, "RCPT TO", "to", to)
199
200	if !s.isLocalSubmission && s.authenticatedUser.ID == 0 {
201		s.logger.InfoContext(ctx, "no authenticated user")
202		return smtpserver.ErrAuthFailed
203	}
204
205	s.rcptTo = append(s.rcptTo, to)
206	return nil
207}
208
209func (s *smtpSession) Data(r io.Reader) error {
210	ctx := context.Background()
211	s.logger.InfoContext(ctx, "DATA")
212
213	if !s.isLocalSubmission && s.authenticatedUser.ID == 0 {
214		s.logger.InfoContext(ctx, "no authenticated user")
215		return smtpserver.ErrAuthFailed
216	}
217
218	// TODO Check if this mail is for us or for the relay
219
220	var buffer bytes.Buffer
221	_, err := io.Copy(&buffer, r)
222	if err != nil {
223		s.logger.ErrorContext(ctx, "Copy", "error", err)
224		return errInternalServerError
225	}
226
227	message := buffer.Bytes()
228
229	signed, err := s.dkim.Sign(ctx, message)
230	if err != nil {
231		s.logger.ErrorContext(ctx, "dkim sign", "error", err)
232		return errInternalServerError
233	}
234
235	// Call policy hook to determine relay config
236	msgCtx := policy.MessageContextFromEnvelope(s.mailFrom, "", int64(len(signed)))
237	user := policy.UserToStarlark(s.authenticatedUser, s.db)
238	relayConfig, err := s.policyEngine.OnMessageSubmit(user, msgCtx)
239	if err != nil {
240		s.logger.ErrorContext(ctx, "policy on_message_submit", "error", err)
241		return errInternalServerError
242	}
243
244	err = shipMessage(s.mailFrom, s.rcptTo, bytes.NewReader(signed), relayConfig)
245	if err != nil {
246		s.logger.ErrorContext(ctx, "ship message", "error", err)
247	}
248	return err
249}