138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
package smtp
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
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 buildMessage(req *SendRequest) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(fmt.Sprintf("From: %s\r\n", req.From))
|
|
b.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(req.To, ", ")))
|
|
if len(req.Cc) > 0 {
|
|
b.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(req.Cc, ", ")))
|
|
}
|
|
b.WriteString(fmt.Sprintf("Subject: %s\r\n", req.Subject))
|
|
b.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
|
|
|
if req.InReplyTo != "" {
|
|
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", req.InReplyTo))
|
|
}
|
|
if len(req.References) > 0 {
|
|
b.WriteString(fmt.Sprintf("References: %s\r\n", strings.Join(req.References, " ")))
|
|
}
|
|
|
|
if req.BodyHTML != "" {
|
|
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
|
|
b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
|
b.WriteString("\r\n")
|
|
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyText)
|
|
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
|
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyHTML)
|
|
b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
|
} else {
|
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyText)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
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)
|
|
}
|