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