ultisuite-backend/internal/mail/smtp/sender.go
R3D347HR4Y 65fc9e517a Implement outbox management features with scheduling and attachment support
- Added new API endpoints for sending, rescheduling, and canceling scheduled outbox messages.
- Implemented outbox processing logic to handle attachments and manage message statuses.
- Introduced a dead-letter strategy for failed outbox messages, enhancing reliability.
- Updated database schema to support new outbox statuses and dead-letter entries.
- Enhanced unit tests for outbox functionalities, ensuring robust error handling and validation.
- Improved attachment handling in the outbox processor to support inline and regular attachments.
2026-05-22 17:46:30 +02:00

115 lines
2.8 KiB
Go

package smtp
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
type Sender struct {
db *pgxpool.Pool
logger *slog.Logger
credentials *credentials.Manager
}
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender {
return &Sender{
db: db,
logger: slog.Default().With("component", "smtp-sender"),
credentials: credManager,
}
}
type SendRequest struct {
AccountID string
From string
To []string
Cc []string
Bcc []string
Subject string
BodyText string
BodyHTML string
InReplyTo string
References []string
Attachments []SendAttachment
}
func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
var host string
var port int
var useTLS bool
var creds []byte
err := s.db.QueryRow(ctx, `
SELECT smtp_host, smtp_port, smtp_tls, credentials
FROM mail_accounts WHERE id = $1
`, req.AccountID).Scan(&host, &port, &useTLS, &creds)
if err != nil {
return fmt.Errorf("query account: %w", err)
}
username, password, err := s.parseCredentials(creds)
if err != nil {
return fmt.Errorf("decrypt credentials: %w", err)
}
addr := fmt.Sprintf("%s:%d", host, port)
msg := buildMessage(req)
allRecipients := make([]string, 0, len(req.To)+len(req.Cc)+len(req.Bcc))
allRecipients = append(allRecipients, req.To...)
allRecipients = append(allRecipients, req.Cc...)
allRecipients = append(allRecipients, req.Bcc...)
auth := sasl.NewPlainClient("", username, password)
var sendErr error
if useTLS {
sendErr = gosmtp.SendMailTLS(addr, auth, req.From, allRecipients, strings.NewReader(msg))
} else {
sendErr = gosmtp.SendMail(addr, auth, req.From, allRecipients, strings.NewReader(msg))
}
if sendErr != nil {
return fmt.Errorf("send: %w", sendErr)
}
s.logger.Info("email sent", "from", req.From, "to", req.To, "subject", req.Subject)
return nil
}
func generateMessageID(from string) string {
domain := "ultimail.local"
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
domain = from[i+1:]
}
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
token = []byte(fmt.Sprintf("%d", time.Now().UnixNano()))
}
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain)
}
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
if len(creds) == 0 {
return "", "", errors.New("missing credentials")
}
if !credentials.IsEncrypted(creds) {
return "", "", errors.New("plaintext credentials forbidden")
}
if s.credentials == nil {
return "", "", errors.New("credential manager not configured")
}
return s.credentials.Decrypt(creds)
}