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}