282 lines
8.0 KiB
Go
282 lines
8.0 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
gosmtp "github.com/emersion/go-smtp"
|
|
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
|
)
|
|
|
|
// SubmissionBackend implements gosmtp.Backend for ports 587/465.
|
|
// Requires authenticated users. Signs outbound mail with DKIM. Queues for delivery.
|
|
type SubmissionBackend struct {
|
|
deps *Deps
|
|
}
|
|
|
|
func (b *SubmissionBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
|
|
clientIP, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var banned bool
|
|
if b.deps.Brute != nil {
|
|
banned, _ = b.deps.Brute.IsBanned(ctx, clientIP)
|
|
}
|
|
if banned {
|
|
return nil, fmt.Errorf("connection refused")
|
|
}
|
|
|
|
return &SubmissionSession{
|
|
deps: b.deps,
|
|
clientIP: clientIP,
|
|
}, nil
|
|
}
|
|
|
|
// SubmissionSession handles one authenticated submission connection.
|
|
type SubmissionSession struct {
|
|
deps *Deps
|
|
clientIP string
|
|
user *models.User // set after AUTH
|
|
from string
|
|
rcpts []string
|
|
}
|
|
|
|
func (s *SubmissionSession) AuthPlain(username, password string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
user, err := s.deps.DB.GetUserByEmail(ctx, username)
|
|
if err != nil {
|
|
log.Printf("[smtp/submission] auth lookup error %s: %v", username, err)
|
|
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"}
|
|
}
|
|
if user == nil || !user.Enabled {
|
|
s.logAttempt(ctx, username, false)
|
|
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"}
|
|
}
|
|
|
|
if err := crypto.CheckPassword(user.PasswordHash, password); err != nil {
|
|
s.logAttempt(ctx, username, false)
|
|
log.Printf("[smtp/submission] auth failed for %s from %s", username, s.clientIP)
|
|
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"}
|
|
}
|
|
|
|
s.user = user
|
|
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
|
log.Printf("[smtp/submission] auth OK for %s from %s", username, s.clientIP)
|
|
return nil
|
|
}
|
|
|
|
func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error {
|
|
if s.user == nil {
|
|
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
|
|
}
|
|
|
|
addr, err := mail.ParseAddress(from)
|
|
if err != nil {
|
|
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender"}
|
|
}
|
|
|
|
// Sender must be user's own address or an alias they own.
|
|
fromEmail := strings.ToLower(addr.Address)
|
|
if !strings.EqualFold(fromEmail, s.user.Email) {
|
|
// Check aliases.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
resolved, err := s.deps.DB.ResolveEmail(ctx, fromEmail)
|
|
if err != nil || resolved == nil || resolved.ID != s.user.ID {
|
|
return &gosmtp.SMTPError{Code: 553, EnhancedCode: gosmtp.EnhancedCode{5, 1, 8}, Message: "sender not owned by authenticated user"}
|
|
}
|
|
}
|
|
|
|
s.from = addr.Address
|
|
return nil
|
|
}
|
|
|
|
func (s *SubmissionSession) Rcpt(to string, opts *gosmtp.RcptOptions) error {
|
|
if s.user == nil {
|
|
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
|
|
}
|
|
|
|
addr, err := mail.ParseAddress(to)
|
|
if err != nil {
|
|
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"}
|
|
}
|
|
|
|
s.rcpts = append(s.rcpts, addr.Address)
|
|
return nil
|
|
}
|
|
|
|
func (s *SubmissionSession) Data(r io.Reader) error {
|
|
if s.user == nil {
|
|
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
|
|
}
|
|
if len(s.rcpts) == 0 {
|
|
return &gosmtp.SMTPError{Code: 503, Message: "no recipients"}
|
|
}
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(r, s.deps.Cfg.MaxMessageSize+1))
|
|
if err != nil {
|
|
return fmt.Errorf("read submission data: %w", err)
|
|
}
|
|
if int64(len(raw)) > s.deps.Cfg.MaxMessageSize {
|
|
return &gosmtp.SMTPError{Code: 552, EnhancedCode: gosmtp.EnhancedCode{5, 3, 4}, Message: "message too large"}
|
|
}
|
|
|
|
// Parse for basic header validation.
|
|
_, err = mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 6, 0}, Message: "malformed message"}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// DKIM-sign the message if the sender's domain has keys configured.
|
|
senderDomain := domainOf(s.from)
|
|
raw = s.signDKIM(ctx, raw, senderDomain)
|
|
|
|
msgID := extractMsgID(raw)
|
|
|
|
// Queue each recipient for delivery.
|
|
// For local recipients we could deliver directly, but queuing is simpler and
|
|
// provides a consistent audit trail.
|
|
dom, err := s.deps.DB.GetDomain(ctx, senderDomain)
|
|
var domainID int64
|
|
if err == nil && dom != nil {
|
|
domainID = dom.ID
|
|
}
|
|
|
|
// Encrypt raw for queue storage using a global (non-user) key.
|
|
queueKey, err := s.deps.Crypt.DeriveKeyGlobal("queue")
|
|
if err != nil {
|
|
return fmt.Errorf("queue key: %w", err)
|
|
}
|
|
rawEnc, err := crypto.Encrypt(queueKey, raw)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt for queue: %w", err)
|
|
}
|
|
|
|
for _, rcpt := range s.rcpts {
|
|
_, err := s.deps.DB.EnqueueMessage(ctx, domainID, s.from, rcpt, msgID, rawEnc, s.deps.Cfg.QueueMaxAgeHours)
|
|
if err != nil {
|
|
log.Printf("[smtp/submission] enqueue to %s: %v", rcpt, err)
|
|
return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "queue error"}
|
|
}
|
|
log.Printf("[smtp/submission] queued %s → %s", s.from, rcpt)
|
|
}
|
|
|
|
// Also save a copy in sender's Sent folder.
|
|
s.saveSentCopy(ctx, raw)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SubmissionSession) Reset() {
|
|
s.from = ""
|
|
s.rcpts = s.rcpts[:0]
|
|
}
|
|
|
|
func (s *SubmissionSession) Logout() error { return nil }
|
|
|
|
// signDKIM signs the message with the sender domain's DKIM key if available.
|
|
// Returns the original raw on any error (DKIM is best-effort).
|
|
func (s *SubmissionSession) signDKIM(ctx context.Context, raw []byte, senderDomain string) []byte {
|
|
dom, err := s.deps.DB.GetDomain(ctx, senderDomain)
|
|
if err != nil || dom == nil || dom.DKIMPrivateEnc == nil {
|
|
return raw // no key configured
|
|
}
|
|
|
|
privPEM, err := s.deps.Crypt.DecryptGlobal("dkim", dom.DKIMPrivateEnc)
|
|
if err != nil {
|
|
log.Printf("[smtp/submission] dkim key decrypt for %s: %v", senderDomain, err)
|
|
return raw
|
|
}
|
|
|
|
signer, err := dkim.NewSigner(string(privPEM), senderDomain, dom.DKIMSelector)
|
|
if err != nil {
|
|
log.Printf("[smtp/submission] dkim signer for %s: %v", senderDomain, err)
|
|
return raw
|
|
}
|
|
|
|
header, err := signer.Sign(raw)
|
|
if err != nil {
|
|
log.Printf("[smtp/submission] dkim sign for %s: %v", senderDomain, err)
|
|
return raw
|
|
}
|
|
|
|
// Prepend DKIM-Signature header.
|
|
return append([]byte(header+"\r\n"), raw...)
|
|
}
|
|
|
|
// saveSentCopy stores a copy in the user's Sent mailbox (best-effort).
|
|
func (s *SubmissionSession) saveSentCopy(ctx context.Context, raw []byte) {
|
|
mbox, err := s.deps.DB.GetMailboxByType(ctx, s.user.ID, models.MailboxSent)
|
|
if err != nil || mbox == nil {
|
|
return
|
|
}
|
|
|
|
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
subject := msg.Header.Get("Subject")
|
|
msgDate, _ := mail.ParseDate(msg.Header.Get("Date"))
|
|
if msgDate.IsZero() {
|
|
msgDate = time.Now().UTC()
|
|
}
|
|
|
|
incoming := &storage.IncomingMessage{
|
|
Raw: raw,
|
|
FromEmail: s.from,
|
|
ToList: strings.Join(s.rcpts, ", "),
|
|
Subject: subject,
|
|
Date: msgDate,
|
|
MessageID: extractMsgID(raw),
|
|
SpamScore: 0,
|
|
}
|
|
|
|
if _, err := s.deps.Store.SaveIncoming(ctx, s.user.ID, mbox.ID, incoming); err != nil {
|
|
log.Printf("[smtp/submission] save sent copy: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func (s *SubmissionSession) logAttempt(ctx context.Context, email string, success bool) {
|
|
if s.deps.Brute != nil {
|
|
s.deps.Brute.RecordAttempt(ctx, s.clientIP, email, success) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func domainOf(email string) string {
|
|
at := strings.LastIndex(email, "@")
|
|
if at < 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(email[at+1:])
|
|
}
|
|
|
|
func extractMsgID(raw []byte) string {
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return msg.Header.Get("Message-ID")
|
|
}
|