137 lines
3.6 KiB
Go
137 lines
3.6 KiB
Go
package smtp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
gosmtp "github.com/emersion/go-smtp"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/mail/connect"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
|
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
|
)
|
|
|
|
type Sender struct {
|
|
db *pgxpool.Pool
|
|
logger *slog.Logger
|
|
credentials *credentials.Manager
|
|
oauth *mailoauth.Service
|
|
}
|
|
|
|
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager, oauthSvc *mailoauth.Service) *Sender {
|
|
return &Sender{
|
|
db: db,
|
|
logger: slog.Default().With("component", "smtp-sender"),
|
|
credentials: credManager,
|
|
oauth: oauthSvc,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cred, err := s.resolveCredential(ctx, req.AccountID, 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, err := connect.SMTPClient(cred)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp auth: %w", err)
|
|
}
|
|
|
|
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) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
|
|
if len(creds) == 0 {
|
|
return credentials.Credential{}, errors.New("missing credentials")
|
|
}
|
|
if !credentials.IsEncrypted(creds) {
|
|
return credentials.Credential{}, errors.New("plaintext credentials forbidden")
|
|
}
|
|
if s.credentials == nil {
|
|
return credentials.Credential{}, errors.New("credential manager not configured")
|
|
}
|
|
cred, err := s.credentials.DecryptCredential(creds)
|
|
if err != nil {
|
|
return cred, err
|
|
}
|
|
if s.oauth != nil && cred.IsOAuth() {
|
|
return mailoauth.RefreshAccountCredential(ctx, s.db, s.credentials, s.oauth, accountID, cred)
|
|
}
|
|
return cred, nil
|
|
}
|
|
|
|
// kept for tests that referenced parseCredentials
|
|
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
|
|
if s.credentials == nil {
|
|
return "", "", errors.New("credential manager not configured")
|
|
}
|
|
return s.credentials.Decrypt(creds)
|
|
}
|