session.go raw
1 package smtp
2
3 import (
4 "fmt"
5 "io"
6 "strings"
7 "time"
8
9 gosmtp "github.com/emersion/go-smtp"
10 "next.orly.dev/pkg/lol/log"
11 )
12
13 // backend implements gosmtp.Backend.
14 type backend struct {
15 domain string
16 handler InboundHandler
17 }
18
19 func (b *backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
20 return &session{
21 domain: b.domain,
22 handler: b.handler,
23 }, nil
24 }
25
26 // session implements gosmtp.Session for a single SMTP transaction.
27 type session struct {
28 domain string
29 handler InboundHandler
30 from string
31 to []string
32 }
33
34 // Mail is called when the client issues MAIL FROM.
35 func (s *session) Mail(from string, opts *gosmtp.MailOptions) error {
36 s.from = from
37 log.D.F("SMTP MAIL FROM: %s", from)
38 return nil
39 }
40
41 // Rcpt is called when the client issues RCPT TO.
42 // Validates that the recipient is in the bridge's domain.
43 func (s *session) Rcpt(to string, opts *gosmtp.RcptOptions) error {
44 // Extract domain from recipient
45 parts := strings.SplitN(to, "@", 2)
46 if len(parts) != 2 {
47 return fmt.Errorf("invalid recipient: %s", to)
48 }
49
50 recipientDomain := strings.ToLower(parts[1])
51 if recipientDomain != strings.ToLower(s.domain) {
52 return fmt.Errorf("relay access denied for domain %s", recipientDomain)
53 }
54
55 // The local part should be an npub or hex pubkey.
56 // We validate format here but defer pubkey resolution to the handler.
57 localPart := strings.ToLower(parts[0])
58 if localPart == "" {
59 return fmt.Errorf("empty local part in recipient")
60 }
61
62 s.to = append(s.to, to)
63 log.D.F("SMTP RCPT TO: %s", to)
64 return nil
65 }
66
67 // Data is called when the client sends the message body.
68 func (s *session) Data(r io.Reader) error {
69 if len(s.to) == 0 {
70 return fmt.Errorf("no valid recipients")
71 }
72
73 // Read the full message
74 data, err := io.ReadAll(r)
75 if err != nil {
76 return fmt.Errorf("read message: %w", err)
77 }
78
79 email := &InboundEmail{
80 From: s.from,
81 To: s.to,
82 RawMessage: data,
83 ReceivedAt: time.Now(),
84 }
85
86 log.D.F("SMTP received message from %s to %v (%d bytes)", s.from, s.to, len(data))
87
88 if err := s.handler(email); err != nil {
89 log.E.F("handler error for message from %s: %v", s.from, err)
90 return fmt.Errorf("message processing failed: %w", err)
91 }
92
93 return nil
94 }
95
96 // Reset is called on RSET command.
97 func (s *session) Reset() {
98 s.from = ""
99 s.to = nil
100 }
101
102 // Logout is called when the connection is closed.
103 func (s *session) Logout() error {
104 return nil
105 }
106