Files
mailgosend/internal/webclient/compose.go
T
2026-05-24 17:15:48 +00:00

204 lines
5.8 KiB
Go

package webclient
import (
"bytes"
"context"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
)
// ComposeParams holds parsed compose form fields.
type ComposeParams struct {
From string // "Display Name <email@host>"
FromEmail string // bare email
To []string // each a valid RFC 5322 address
CC []string
BCC []string
Subject string
BodyText string
InReplyTo string
References string
MessageID string // auto-generated if empty
}
// BuildRFC5322 creates a raw RFC 5322 message.
func BuildRFC5322(p *ComposeParams) ([]byte, error) {
if p.MessageID == "" {
randHex, err := appCrypto.RandomHex(16)
if err != nil {
return nil, fmt.Errorf("message-id random: %w", err)
}
fromDomain := "localhost"
if idx := strings.LastIndex(p.FromEmail, "@"); idx >= 0 {
fromDomain = p.FromEmail[idx+1:]
}
p.MessageID = "<" + randHex + "@" + fromDomain + ">"
}
var buf bytes.Buffer
writeHeader := func(k, v string) {
if v != "" {
buf.WriteString(k + ": " + v + "\r\n")
}
}
writeHeader("From", p.From)
writeHeader("To", strings.Join(p.To, ", "))
if len(p.CC) > 0 {
writeHeader("Cc", strings.Join(p.CC, ", "))
}
writeHeader("Subject", mime.QEncoding.Encode("utf-8", p.Subject))
writeHeader("Date", time.Now().UTC().Format(time.RFC1123Z))
writeHeader("Message-Id", p.MessageID)
writeHeader("MIME-Version", "1.0")
if p.InReplyTo != "" {
writeHeader("In-Reply-To", sanitizeHeaderValue(p.InReplyTo))
}
if p.References != "" {
writeHeader("References", sanitizeHeaderValue(p.References))
}
// Write body as quoted-printable text/plain.
mw := multipart.NewWriter(&buf)
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + mw.Boundary() + "\"\r\n")
buf.WriteString("\r\n")
// text/plain part
th := make(textproto.MIMEHeader)
th.Set("Content-Type", "text/plain; charset=utf-8")
th.Set("Content-Transfer-Encoding", "quoted-printable")
pw, err := mw.CreatePart(th)
if err != nil {
return nil, fmt.Errorf("create text part: %w", err)
}
qw := quotedprintable.NewWriter(pw)
if _, err := qw.Write([]byte(p.BodyText)); err != nil {
return nil, fmt.Errorf("write body: %w", err)
}
if err := qw.Close(); err != nil {
return nil, fmt.Errorf("close qp: %w", err)
}
if err := mw.Close(); err != nil {
return nil, fmt.Errorf("close multipart: %w", err)
}
return buf.Bytes(), nil
}
// parseAddressList parses a comma-separated address string into valid email addresses.
// Returns only the bare email addresses (no display names in returned slice).
func parseAddressList(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
addrs, err := mail.ParseAddressList(raw)
if err != nil {
// Try as a single address.
addr, err2 := mail.ParseAddress(raw)
if err2 != nil {
return nil, fmt.Errorf("invalid address %q: %w", raw, err)
}
return []string{addr.Address}, nil
}
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.Address)
}
return out, nil
}
// addressListRFC formats a list of bare emails as RFC 5322 addresses.
func addressListRFC(emails []string) []string {
out := make([]string, len(emails))
for i, e := range emails {
out[i] = e
}
return out
}
// deliverLocally saves a message to a local recipient's INBOX.
func (s *Server) deliverLocally(ctx context.Context, recipientEmail string, raw []byte, msg *storage.IncomingMessage) error {
user, err := s.deps.DB.ResolveEmail(ctx, recipientEmail)
if err != nil {
return fmt.Errorf("resolve %s: %w", recipientEmail, err)
}
if user == nil || !user.Enabled {
return fmt.Errorf("recipient %s not found or disabled", recipientEmail)
}
inbox, err := s.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxInbox)
if err != nil || inbox == nil {
return fmt.Errorf("inbox not found for %s", recipientEmail)
}
if _, err := s.deps.Store.SaveIncoming(ctx, user.ID, inbox.ID, msg); err != nil {
return fmt.Errorf("save incoming: %w", err)
}
return nil
}
// saveSentCopy saves a copy of a sent message to the sender's Sent folder.
func (s *Server) saveSentCopy(ctx context.Context, senderID int64, raw []byte, msg *storage.IncomingMessage) error {
sentBox, err := s.deps.DB.GetMailboxByType(ctx, senderID, models.MailboxSent)
if err != nil || sentBox == nil {
return fmt.Errorf("sent mailbox not found")
}
_, err = s.deps.Store.SaveIncoming(ctx, senderID, sentBox.ID, msg)
return err
}
// enqueueForDelivery adds a message to the delivery queue for a remote recipient.
func (s *Server) enqueueForDelivery(ctx context.Context, fromEmail, toEmail string, raw []byte, msgID string) error {
// Determine domain for queue domain_id (best effort, nil if not local).
fromDomain := ""
if idx := strings.LastIndex(fromEmail, "@"); idx >= 0 {
fromDomain = fromEmail[idx+1:]
}
var domainID *int64
if dom, err := s.deps.DB.GetDomain(ctx, fromDomain); err == nil && dom != nil {
domainID = &dom.ID
}
maxAge := s.deps.Cfg.QueueMaxAgeHours
if maxAge <= 0 {
maxAge = 72
}
key, err := s.deps.Crypt.DeriveKeyGlobal("queue")
if err != nil {
return fmt.Errorf("queue key: %w", err)
}
rawEnc, err := appCrypto.Encrypt(key, raw)
if err != nil {
return fmt.Errorf("encrypt queue: %w", err)
}
domID := int64(0)
if domainID != nil {
domID = *domainID
}
_, err = s.deps.DB.EnqueueMessage(ctx, domID, fromEmail, toEmail, msgID, rawEnc, maxAge)
return err
}
// sanitizeHeaderValue strips CR and LF from a header value to prevent injection.
func sanitizeHeaderValue(s string) string {
return strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' {
return -1
}
return r
}, s)
}