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 " 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) }