204 lines
5.8 KiB
Go
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)
|
|
}
|