package webui

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"html"
	"html/template"
	"io"
	"mime/multipart"
	"net/http"
	"net/mail"
	"net/textproto"
	"strconv"
	"strings"
	"time"

	"github.com/ProtonMail/go-crypto/openpgp"
	"mailgoserver/internal/db"
	"mailgoserver/internal/mailview"
	"mailgoserver/internal/pgp"
	"mailgoserver/internal/relay"
	"mailgoserver/internal/smime"
	"mailgoserver/internal/toolbox"
)

const maxComposeUploadBytes = 25 << 20 // 25MB, matching a typical provider's attachment cap

// sendAsAddresses returns a mailbox's active, send-as-capable aliases — the set of
// extra "From" addresses compose (and, for signatures, per-alias defaults) offers
// alongside the mailbox's own primary address.
func (a *App) sendAsAddresses(mbox *db.Mailbox) []string {
	aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID)
	var out []string
	for _, al := range aliases {
		if al.CanSendAs && al.IsActive {
			out = append(out, al.Email)
		}
	}
	return out
}

// composeFormData builds the template data every compose-page render needs
// regardless of why it's rendering (a fresh GET, a reply/forward prefill, or
// redisplaying the form after a failed send) — shared so those three paths can't
// drift out of sync with each other.
func (a *App) composeFormData(mbox *db.Mailbox) M {
	sendAsOptions := a.sendAsAddresses(mbox)
	identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
	pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
	signatures, _ := a.DB.ListSignatures(mbox.ID)

	// One default-signature-id lookup per (address, new-or-reply) pair, embedded as a
	// JS map so compose can swap the inserted signature live when the From-alias
	// picker changes — see webmail_compose.html's from-select handler. Otherwise a
	// per-alias default (Settings > Signatures) would only ever apply to the mailbox's
	// own primary address, since that's the only "from" compose knows about at
	// initial page load (the alias picker itself is client-side only).
	addresses := append([]string{mbox.Email}, sendAsOptions...)
	sigDefaults := make(map[string]map[string]int64, len(addresses))
	for _, addr := range addresses {
		entry := map[string]int64{"new": 0, "reply": 0}
		if sig, err := a.DB.GetDefaultSignature(mbox.ID, false, addr); err == nil && sig != nil {
			entry["new"] = sig.ID
		}
		if sig, err := a.DB.GetDefaultSignature(mbox.ID, true, addr); err == nil && sig != nil {
			entry["reply"] = sig.ID
		}
		sigDefaults[addr] = entry
	}
	sigDefaultsJSON, _ := json.Marshal(sigDefaults)

	return M{
		"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts,
		"signatures": signatures,
		// Always present (never a missing map key) so the template's {{eq
		// $.default_signature_id .ID}} comparison never has to handle a nil operand —
		// 0 is a safe "no default" sentinel since real ids start at 1 (AUTOINCREMENT).
		"default_signature_id": int64(0),
		// See json.Marshal HTML-escaping note on webmailRulesList's identical pattern —
		// safe to mark template.JS since json.Marshal already escapes <, >, &.
		"signature_defaults_json": template.JS(sigDefaultsJSON),
	}
}

// signatureMarkerID wraps every auto-inserted (or picker-inserted) signature's HTML in
// a stable-id div — the compose page's JS looks for this element to swap the
// signature in place when the user picks a different one, instead of needing to
// somehow diff/guess which part of their freely-edited body is "the signature".
const signatureMarkerID = "mailSignatureBlock"

func wrapSignatureHTML(contentHTML string) string {
	if strings.TrimSpace(contentHTML) == "" {
		return ""
	}
	return `<div id="` + signatureMarkerID + `">` + contentHTML + `</div>`
}

// webmailComposeForm shows the compose page, optionally prefilled for a reply,
// reply-all, or forward (query params: reply=uid&folder=X, replyall=..., forward=...).
func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	data := a.composeFormData(mbox)
	data["flashes"] = popFlashes(w, r)
	q := r.URL.Query()
	folder := q.Get("folder")
	var uidStr, mode string
	switch {
	case q.Get("reply") != "":
		uidStr, mode = q.Get("reply"), "reply"
	case q.Get("replyall") != "":
		uidStr, mode = q.Get("replyall"), "replyall"
	case q.Get("forward") != "":
		uidStr, mode = q.Get("forward"), "forward"
	case q.Get("draft") != "":
		uidStr, mode = q.Get("draft"), "draft"
	}

	// Exposed to JS so the from-select handler knows which slot ("new" or "reply") of
	// signature_defaults_json to use when the picked address changes — a draft is
	// treated like "new" here (matching the auto-insertion skip below: a draft's
	// signature situation, if any, is whatever was already saved in it).
	data["compose_for_reply"] = mode == "reply" || mode == "replyall" || mode == "forward"

	if mode != "" && folder != "" {
		if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
			switch mode {
			case "reply":
				data["to"] = parsed.Header.From
				data["subject"] = replySubject(parsed.Header.Subject)
				data["body_html"] = template.HTML(quoteBodyHTML(parsed))
				data["in_reply_to"] = parsed.Header.MessageID
			case "replyall":
				to, cc := replyAllRecipients(parsed, mbox.Email)
				data["to"] = to
				data["cc"] = cc
				data["subject"] = replySubject(parsed.Header.Subject)
				data["body_html"] = template.HTML(quoteBodyHTML(parsed))
				data["in_reply_to"] = parsed.Header.MessageID
			case "draft":
				// Unlike reply/forward, a draft's own To/Cc/Subject/body are reloaded
				// as-is (not quoted) — continuing to edit the same message, not
				// replying to it. Bcc isn't recoverable: it's deliberately never
				// written into the stored message content (see buildEnvelopeHeaders).
				data["to"] = parsed.Header.To
				data["cc"] = parsed.Header.Cc
				data["subject"] = parsed.Header.Subject
				body := parsed.HTMLBody
				if body == "" && parsed.TextBody != "" {
					body = `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(parsed.TextBody) + `</pre>`
				}
				data["body_html"] = template.HTML(htmlBodyPolicy.Sanitize(body))
				data["draft_id"] = uidStr
			case "forward":
				data["subject"] = forwardSubject(parsed.Header.Subject)
				data["body_html"] = template.HTML(forwardBodyHTML(parsed))
			}
		}
	}

	// Signature auto-insertion: reply/forward gets the reply/forward default, a
	// brand-new compose (mode == "") gets the new-message default. A reloaded draft
	// is left exactly as saved — it may already contain whatever signature was there
	// when it was saved, or none, and re-adding one here would duplicate it.
	if mode != "draft" {
		forReply := mode == "reply" || mode == "replyall" || mode == "forward"
		if sig, err := a.DB.GetDefaultSignature(mbox.ID, forReply, ""); err == nil && sig != nil {
			data["default_signature_id"] = sig.ID
			if wrapped := wrapSignatureHTML(sig.ContentHTML); wrapped != "" {
				existing, _ := data["body_html"].(template.HTML)
				body := string(existing)
				if strings.HasPrefix(body, composeCursorHome) {
					body = composeCursorHome + wrapped + strings.TrimPrefix(body, composeCursorHome)
				} else {
					body = composeCursorHome + wrapped + body
				}
				data["body_html"] = template.HTML(body)
			}
		}
	}

	a.render(w, r, "webmail_compose.html", data)
}

// webmailRecipientSuggest backs the To/Cc/Bcc autocomplete — addresses this mailbox
// has previously exchanged mail with, matching the current fragment being typed.
func (a *App) webmailRecipientSuggest(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	var suggestions []string
	if q != "" {
		suggestions, _ = a.DB.SuggestRecipients(mbox.ID, q)
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(suggestions)
}

// webmailLoadForPrefill fetches+parses a message for reply/forward prefill, scoped to
// this mailbox and folder — returns nil (silently, no flash) on any failure, since
// the worst case is just an unprefilled compose form, not something worth erroring
// the whole page over.
func (a *App) webmailLoadForPrefill(mailboxID int64, folder string, uid int64) *mailview.Message {
	msgRow, err := a.DB.GetMessageByUID(mailboxID, uid)
	if err != nil || msgRow == nil || msgRow.Folder != folder {
		return nil
	}
	raw, err := a.Mailstore.FetchMessage(mailboxID, uid)
	if err != nil {
		return nil
	}
	parsed, err := mailview.Parse(raw)
	if err != nil {
		return nil
	}
	return parsed
}

func replySubject(s string) string {
	if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "re:") {
		return s
	}
	return "Re: " + s
}

func forwardSubject(s string) string {
	if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "fwd:") {
		return s
	}
	return "Fwd: " + s
}

// plainBody prefers the parsed message's text body; an HTML-only message can't be
// meaningfully quoted into a plain-text compose box, so it's called out instead.
func plainBody(parsed *mailview.Message) string {
	if parsed.TextBody != "" {
		return parsed.TextBody
	}
	if parsed.HTMLBody != "" {
		return "(original message was HTML-only — open it in its folder to view)"
	}
	return ""
}

// quotedBodyHTML renders the original message's body for embedding into a
// reply/forward compose: the original HTML body (sanitized) when present, otherwise
// the plain-text body escaped and wrapped in a <pre> so line breaks survive.
func quotedBodyHTML(parsed *mailview.Message) string {
	if parsed.HTMLBody != "" {
		return htmlBodyPolicy.Sanitize(parsed.HTMLBody)
	}
	return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(plainBody(parsed)) + `</pre>`
}

// composeCursorHome is an empty paragraph — Quill's own canonical markup for a
// blank line — prepended to reply/forward bodies so the new message has somewhere
// to go ABOVE the quoted original, with the cursor placed there automatically (see
// the body_html_seed handling in webmail_compose.html) instead of landing inside or
// after the quote.
const composeCursorHome = "<p><br></p>"

func quoteBodyHTML(parsed *mailview.Message) string {
	return composeCursorHome +
		"<p>On " + html.EscapeString(parsed.Header.Date) + ", " + html.EscapeString(parsed.Header.From) + " wrote:</p>" +
		`<blockquote style="border-left: 2px solid #999; margin: 0; padding-left: 1em;">` + quotedBodyHTML(parsed) + "</blockquote>"
}

func forwardBodyHTML(parsed *mailview.Message) string {
	return composeCursorHome +
		"<p>---------- Forwarded message ----------<br>" +
		"From: " + html.EscapeString(parsed.Header.From) + "<br>" +
		"Date: " + html.EscapeString(parsed.Header.Date) + "<br>" +
		"Subject: " + html.EscapeString(parsed.Header.Subject) + "<br>" +
		"To: " + html.EscapeString(parsed.Header.To) + "</p>" +
		quotedBodyHTML(parsed)
}

// replyAllRecipients puts the original sender in To and everyone else who received
// the original (To+Cc, minus the replying mailbox itself) in Cc — standard
// reply-all semantics.
func replyAllRecipients(parsed *mailview.Message, ownEmail string) (to, cc string) {
	seen := map[string]bool{strings.ToLower(ownEmail): true, strings.ToLower(bareAddress(parsed.Header.From)): true}
	var ccList []string
	for _, addr := range append(splitAddressList(parsed.Header.To), splitAddressList(parsed.Header.Cc)...) {
		bare := strings.ToLower(bareAddress(addr))
		if bare == "" || seen[bare] {
			continue
		}
		seen[bare] = true
		ccList = append(ccList, addr)
	}
	return parsed.Header.From, strings.Join(ccList, ", ")
}

func splitAddressList(raw string) []string {
	addrs, err := mail.ParseAddressList(raw)
	if err != nil {
		return nil
	}
	out := make([]string, len(addrs))
	for i, a := range addrs {
		out[i] = a.Address
	}
	return out
}

func bareAddress(raw string) string {
	if a, err := mail.ParseAddress(raw); err == nil {
		return a.Address
	}
	return raw
}

func domainOfAddress(addr string) string {
	if i := strings.LastIndex(addr, "@"); i >= 0 {
		return strings.ToLower(addr[i+1:])
	}
	return ""
}

type composeAttachment struct {
	Filename, ContentType string
	Data                  []byte
}

// buildEnvelopeHeaders returns a composed message's envelope headers — everything
// except the MIME entity's own Content-Type/Content-Transfer-Encoding, which come
// from buildMessageEntity instead. Kept separate so S/MIME's Sign/Encrypt
// (internal/smime) can transform just the entity, never touching From/To/Subject.
// Deliberately never includes a Bcc header (real mail clients never put one in the
// transmitted DATA either) — Bcc recipients still receive the mail via the
// envelope-level recipient list built by the caller, they just don't appear in the
// message content itself, matching standard practice.
func buildEnvelopeHeaders(from string, to, cc []string, subject, messageID, inReplyTo string) []string {
	headers := []string{
		"Message-ID: <" + messageID + ">",
		"Date: " + time.Now().Format(time.RFC1123Z),
		"From: " + from,
		"To: " + strings.Join(to, ", "),
	}
	if len(cc) > 0 {
		headers = append(headers, "Cc: "+strings.Join(cc, ", "))
	}
	headers = append(headers, "Subject: "+subject)
	if inReplyTo != "" {
		headers = append(headers, "In-Reply-To: "+inReplyTo, "References: "+inReplyTo)
	}
	headers = append(headers, "MIME-Version: 1.0")
	return headers
}

// buildBodyEntity builds just the message body part: a flat text/plain part when
// htmlBody is empty, otherwise multipart/alternative (plainText fallback + htmlBody)
// — the standard shape for an HTML-composed email so a plain-text-only mail client
// still gets something readable.
func buildBodyEntity(plainText, htmlBody string) (smime.Entity, error) {
	if htmlBody == "" {
		return smime.Entity{
			Headers: []string{`Content-Type: text/plain; charset="UTF-8"`, "Content-Transfer-Encoding: 8bit"},
			Body:    []byte(plainText),
		}, nil
	}

	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	tp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/plain; charset="UTF-8"`}})
	if err != nil {
		return smime.Entity{}, err
	}
	if _, err := tp.Write([]byte(plainText)); err != nil {
		return smime.Entity{}, err
	}
	hp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/html; charset="UTF-8"`}})
	if err != nil {
		return smime.Entity{}, err
	}
	if _, err := hp.Write([]byte(htmlBody)); err != nil {
		return smime.Entity{}, err
	}
	if err := mw.Close(); err != nil {
		return smime.Entity{}, err
	}
	return smime.Entity{
		Headers: []string{`Content-Type: multipart/alternative; boundary="` + mw.Boundary() + `"`},
		Body:    buf.Bytes(),
	}, nil
}

// headerLinesToMIMEHeader converts an smime.Entity's flat "Name: value" header
// lines into textproto.MIMEHeader, for embedding one entity's headers+body as a
// nested part inside another multipart.Writer (mw.CreatePart wants that shape).
func headerLinesToMIMEHeader(headers []string) textproto.MIMEHeader {
	h := textproto.MIMEHeader{}
	for _, line := range headers {
		if i := strings.Index(line, ":"); i >= 0 {
			h.Add(strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]))
		}
	}
	return h
}

// buildMessageEntity builds the MIME entity (Content-Type/CTE headers + body) for a
// composed message: the body (buildBodyEntity — plain, or multipart/alternative when
// htmlBody is set) alone if there are no attachments, otherwise nested as the first
// part of a multipart/mixed alongside each attachment.
func buildMessageEntity(plainText, htmlBody string, attachments []composeAttachment) (smime.Entity, error) {
	bodyEntity, err := buildBodyEntity(plainText, htmlBody)
	if err != nil {
		return smime.Entity{}, err
	}
	if len(attachments) == 0 {
		return bodyEntity, nil
	}

	var bodyBuf bytes.Buffer
	mw := multipart.NewWriter(&bodyBuf)
	bodyPart, err := mw.CreatePart(headerLinesToMIMEHeader(bodyEntity.Headers))
	if err != nil {
		return smime.Entity{}, err
	}
	if _, err := bodyPart.Write(bodyEntity.Body); err != nil {
		return smime.Entity{}, err
	}

	for _, att := range attachments {
		ct := att.ContentType
		if ct == "" {
			ct = "application/octet-stream"
		}
		part, err := mw.CreatePart(textproto.MIMEHeader{
			"Content-Type":              {ct + `; name="` + att.Filename + `"`},
			"Content-Disposition":       {`attachment; filename="` + att.Filename + `"`},
			"Content-Transfer-Encoding": {"base64"},
		})
		if err != nil {
			return smime.Entity{}, err
		}
		encoded := base64.StdEncoding.EncodeToString(att.Data)
		for i := 0; i < len(encoded); i += 76 {
			end := min(i+76, len(encoded))
			part.Write([]byte(encoded[i:end]))
			part.Write([]byte("\r\n"))
		}
	}
	if err := mw.Close(); err != nil {
		return smime.Entity{}, err
	}
	// A top-level Content-Transfer-Encoding is redundant on a multipart container by
	// MIME rules (RFC 2045 permits only 7bit/8bit/binary there anyway) but DKIM's
	// FixedHeaders list always includes it in the signed header set — omitting it
	// would leave that entry signing an absent header instead of a real one.
	return smime.Entity{
		Headers: []string{`Content-Type: multipart/mixed; boundary="` + mw.Boundary() + `"`, "Content-Transfer-Encoding: 7bit"},
		Body:    bodyBuf.Bytes(),
	}, nil
}

// assembleMessage concatenates envelope headers and a MIME entity (its own headers,
// then body) into a flat raw RFC822 message.
func assembleMessage(envelopeHeaders []string, entity smime.Entity) string {
	var b strings.Builder
	for _, h := range envelopeHeaders {
		b.WriteString(h)
		b.WriteString("\r\n")
	}
	for _, h := range entity.Headers {
		b.WriteString(h)
		b.WriteString("\r\n")
	}
	b.WriteString("\r\n")
	b.Write(entity.Body)
	return b.String()
}

// webmailComposeSend builds, signs, and delivers a composed message: local
// recipients go straight into their mailbox (through their own filter rules), the
// rest go out via the same direct-to-MX relay used for SMTP-received mail. A copy is
// always saved to the sender's own Sent folder, and the send is logged the same way
// an SMTP-relayed message is, so it shows up in the admin's email log too.
func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)

	// ErrNotMultipart is expected (and harmless) whenever the browser submits the
	// compose form without picking any attachment file — ParseMultipartForm still
	// calls ParseForm internally in that case, so every other field is available;
	// only a genuine parse/size failure should abort the send. This one failure mode
	// redirects (rather than redisplaying, like every other failure below) since a
	// parse failure means the posted fields can't be trusted to recover from.
	if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
		setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
		http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
		return
	}

	from := strings.TrimSpace(r.FormValue("from"))
	if from == "" {
		from = mbox.Email
	}
	toAddrs, errTo := parseComposeAddrs(r.FormValue("to"))
	ccAddrs, errCc := parseComposeAddrs(r.FormValue("cc"))
	bccAddrs, errBcc := parseComposeAddrs(r.FormValue("bcc"))
	subject := strings.TrimSpace(r.FormValue("subject"))
	// body_html is the Quill editor's content, sanitized the same way an incoming
	// message's HTML body is before display — cheap hygiene even though it's the
	// user's own browser-originated content. plainText is derived from it (not the
	// raw field) so the plain-text fallback part reflects exactly what actually got
	// sent, and so it can't itself carry anything sanitization would have stripped.
	htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
	plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
	inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))

	// fail redisplays the compose form in place with everything already typed still
	// filled in (a failed send used to wipe the form via a redirect to a blank GET —
	// attachments can't be restored into a native file input by a server response,
	// browsers don't allow it, so that's the one thing the user is asked to redo).
	fail := func(msg string) {
		if r.MultipartForm != nil && len(r.MultipartForm.File["attachments"]) > 0 {
			msg += " (attachments will need to be re-picked — browsers don't allow restoring them automatically)"
		}
		data := a.composeFormData(mbox)
		data["flashes"] = append(popFlashes(w, r), Flash{Category: "error", Message: msg})
		data["to"] = r.FormValue("to")
		data["cc"] = r.FormValue("cc")
		data["bcc"] = r.FormValue("bcc")
		data["subject"] = subject
		data["body_html"] = template.HTML(htmlBody)
		data["in_reply_to"] = inReplyTo
		data["draft_id"] = r.FormValue("draft_id")
		a.render(w, r, "webmail_compose.html", data)
	}

	if !strings.EqualFold(from, mbox.Email) {
		if canSendAs, err := a.DB.MailboxCanSendAs(mbox.ID, from); err != nil || !canSendAs {
			fail("You're not authorized to send as " + from)
			return
		}
	}
	if errTo != nil || errCc != nil || errBcc != nil {
		fail("One or more recipient addresses is invalid")
		return
	}
	if len(toAddrs) == 0 {
		fail("At least one recipient is required")
		return
	}

	var attachments []composeAttachment
	if r.MultipartForm != nil {
		for _, fh := range r.MultipartForm.File["attachments"] {
			f, err := fh.Open()
			if err != nil {
				continue
			}
			data, err := io.ReadAll(f)
			f.Close()
			if err != nil {
				continue
			}
			attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
		}
	}

	// Mirrors the client-side check in webmail_compose.html — enforced again here
	// since a blank send (e.g. via an accidental Enter-key form submit) must never
	// succeed even with JS disabled or bypassed.
	if subject == "" {
		fail("Please add a subject before sending")
		return
	}
	if plainText == "" && len(attachments) == 0 {
		fail("Please write a message or add an attachment before sending")
		return
	}

	heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
	if heloHostname == "" {
		heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
	}
	messageID := toolbox.GenerateMessageID(heloHostname)

	entity, err := buildMessageEntity(plainText, htmlBody, attachments)
	if err != nil {
		a.Logger.Error("build outbound webmail message: %v", err)
		fail("Could not build the message: " + err.Error())
		return
	}

	wantSign := r.FormValue("smime_sign") != ""
	wantEncrypt := r.FormValue("pgp_encrypt") != ""

	if wantSign {
		identities, err := a.DB.ListSMIMEIdentities(mbox.ID)
		if err != nil || len(identities) == 0 {
			fail("Signing requires your own S/MIME certificate — set one up on the Certs page first")
			return
		}
		chosen := identities[0] // most recently created, unless the form named a specific one
		if idStr := r.FormValue("smime_identity_id"); idStr != "" {
			wanted := int64(atoi(idStr))
			for _, id := range identities {
				if id.ID == wanted {
					chosen = id
					break
				}
			}
		}
		cert, err := smime.ParseCertPEM([]byte(chosen.CertPEM))
		if err != nil {
			a.Logger.Error("parse own smime cert for mailbox %d: %v", mbox.ID, err)
			fail("Your S/MIME certificate is corrupted")
			return
		}
		key, err := smime.ParseKeyPEM([]byte(chosen.KeyPEM))
		if err != nil {
			a.Logger.Error("parse own smime key for mailbox %d: %v", mbox.ID, err)
			fail("Your S/MIME certificate is corrupted")
			return
		}
		if entity, err = smime.Sign(entity, cert, key); err != nil {
			a.Logger.Error("smime sign for mailbox %d: %v", mbox.ID, err)
			fail("Could not sign the message: " + err.Error())
			return
		}
	}

	if wantEncrypt {
		// PGP handles encryption in this codebase (S/MIME, above, handles signing) —
		// encrypting only ever needs public keys, so unlike signing this never
		// prompts for a passphrase at compose time.
		pgpIdentities, err := a.DB.ListPGPIdentities(mbox.ID)
		if err != nil || len(pgpIdentities) == 0 {
			fail("Encrypting requires your own PGP key (so your Sent copy stays readable) — set one up on the Certs page first")
			return
		}
		ownPub, err := pgp.ParsePublicKey([]byte(pgpIdentities[0].PublicKeyArmor))
		if err != nil {
			a.Logger.Error("parse own pgp public key for mailbox %d: %v", mbox.ID, err)
			fail("Your PGP key is corrupted")
			return
		}
		recipKeys := []*openpgp.Entity{ownPub} // include the sender's own key so the Sent copy stays readable
		// Recipients are chosen explicitly by picking contacts from the dropdown, not
		// by matching a To/Cc/Bcc address against a contact's stored email — a
		// recipient's PGP key can be filed under any email, and this way there's no
		// silent "no key for this exact address" failure.
		pickedIDs := r.Form["pgp_recipient_id"]
		if len(pickedIDs) == 0 {
			fail("Select at least one PGP recipient key to encrypt to")
			return
		}
		var missing []string
		for _, idStr := range pickedIDs {
			contact, err := a.DB.GetPGPContactByID(mbox.ID, int64(atoi(idStr)))
			if err != nil || contact == nil {
				missing = append(missing, idStr)
				continue
			}
			rcptKey, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
			if err != nil {
				missing = append(missing, contact.Email)
				continue
			}
			recipKeys = append(recipKeys, rcptKey)
		}
		if len(missing) > 0 {
			fail("Could not use the selected PGP key(s) for: " + strings.Join(missing, ", "))
			return
		}
		// pgp.Entity and smime.Entity are deliberately identical structs (see both
		// packages' doc comments) so this conversion is just a type-name formality,
		// not a data transformation.
		pgpEntity, err := pgp.EncryptEntity(pgp.Entity(entity), recipKeys)
		if err != nil {
			a.Logger.Error("pgp encrypt for mailbox %d: %v", mbox.ID, err)
			fail("Could not encrypt the message: " + err.Error())
			return
		}
		entity = smime.Entity(pgpEntity)
	}

	raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)

	signed := raw
	dkimSigned := false
	if senderDomain := domainOfAddress(from); senderDomain != "" {
		s := a.DKIM.Sign(raw, senderDomain)
		dkimSigned = s != raw
		signed = s
	}

	allRcpts := append(append(append([]string{}, toAddrs...), ccAddrs...), bccAddrs...)
	types := make([]string, 0, len(allRcpts))
	for range toAddrs {
		types = append(types, "to")
	}
	for range ccAddrs {
		types = append(types, "cc")
	}
	for range bccAddrs {
		types = append(types, "bcc")
	}

	var localRcpts, localTypes, relayRcpts, relayTypes []string
	for i, rcpt := range allRcpts {
		if lm, err := a.Mailstore.ResolveRecipient(rcpt); err == nil && lm != nil {
			localRcpts = append(localRcpts, rcpt)
			localTypes = append(localTypes, types[i])
		} else {
			relayRcpts = append(relayRcpts, rcpt)
			relayTypes = append(relayTypes, types[i])
		}
	}

	var results []relay.Result
	if len(relayRcpts) > 0 {
		results = a.Relay.RelayEmailAsync(from, relayRcpts, signed, relayTypes)
	}
	for i, rcpt := range localRcpts {
		results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
	}

	if sentUID, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
		a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
	} else if err := a.DB.SetMessageFlags(mbox.ID, sentUID, `\Seen`); err != nil {
		// Mail you just sent yourself was never "unread" to begin with — StoreMessage
		// has no way to set initial flags, so this mirrors deliverWebmailComposeLocally's
		// existing store-then-mark-read pattern rather than threading a flags param
		// through StoreMessage for what only these two Sent/Drafts call sites need.
		a.Logger.Error("mark sent copy %d read for mailbox %d: %v", sentUID, mbox.ID, err)
	}

	// Sending a draft removes it from Drafts, same as any real mail client.
	if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
		if err := a.Mailstore.DeleteMessage(mbox.ID, int64(atoi(draftIDStr))); err != nil {
			a.Logger.Error("delete sent draft %s for mailbox %d: %v", draftIDStr, mbox.ID, err)
		}
	}

	// Privacy default (matches the inbound SMTP path — see session.go's Data()): the
	// admin-visible log never gets the body content, webmail-sent mail included. There's
	// no per-mailbox "store content" opt-in for outbound webmail sends the way there is
	// for inbound senders/IPs, and it would be redundant anyway — the sender's own Sent
	// folder already keeps the real, encrypted-at-rest copy of what they sent.
	loggedBody := "[content not stored by default — see the sender's Sent folder for the full message]"
	if wantEncrypt {
		loggedBody = "[PGP encrypted — plaintext not logged]"
	}
	if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
		subject, "", loggedBody, messageID, mbox.Email, dkimSigned, results); err != nil {
		a.Logger.Error("log webmail send: %v", err)
	}

	allSucceeded := len(results) > 0
	var failures []string
	var failed []relay.Result
	for _, res := range results {
		if res.Status != "success" {
			allSucceeded = false
			reason := res.ErrorMessage
			if reason == "" {
				reason = res.ServerResponse
			}
			failures = append(failures, res.Recipient+": "+reason)
			failed = append(failed, res)
		}
	}
	if allSucceeded {
		setFlash(w, "success", "Message sent")
	} else {
		setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
		// There's no separate "sending MTA" here to retry/bounce it the way a real
		// inbound SMTP client would — the flash message above only exists for the
		// moment right after clicking Send, so a persistent copy lands in the
		// sender's own INBOX too, same as a real bounce from any other mail provider.
		if err := a.Relay.SendBounce(from, subject, messageID, failed); err != nil {
			a.Logger.Error("send bounce to %s: %v", from, err)
		}
	}
	http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
}

// webmailComposeSaveDraft stores the current compose form into the Drafts folder
// without sending it — deliberately skips the recipient/subject/body validation
// webmailComposeSend enforces (a draft can be incomplete by definition) and never
// signs or encrypts (a draft isn't going anywhere yet, so there's nothing to sign or
// encrypt to). Re-saving an already-open draft (draft_id set) replaces the old copy
// rather than accumulating duplicates.
func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
	mbox := mailboxFromContext(r)
	if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
		setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
		http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
		return
	}

	from := strings.TrimSpace(r.FormValue("from"))
	if from == "" {
		from = mbox.Email
	}
	toAddrs, _ := parseComposeAddrs(r.FormValue("to"))
	ccAddrs, _ := parseComposeAddrs(r.FormValue("cc"))
	subject := strings.TrimSpace(r.FormValue("subject"))
	htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
	plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
	inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))

	var attachments []composeAttachment
	if r.MultipartForm != nil {
		for _, fh := range r.MultipartForm.File["attachments"] {
			f, err := fh.Open()
			if err != nil {
				continue
			}
			data, err := io.ReadAll(f)
			f.Close()
			if err != nil {
				continue
			}
			attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
		}
	}

	heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
	if heloHostname == "" {
		heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
	}
	messageID := toolbox.GenerateMessageID(heloHostname)

	entity, err := buildMessageEntity(plainText, htmlBody, attachments)
	if err != nil {
		a.Logger.Error("build draft for mailbox %d: %v", mbox.ID, err)
		setFlash(w, "error", "Could not save the draft: "+err.Error())
		http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
		return
	}
	raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)

	newUID, err := a.Mailstore.StoreMessage(mbox.ID, "Drafts", []byte(raw), messageID, from, subject)
	if err != nil {
		a.Logger.Error("save draft for mailbox %d: %v", mbox.ID, err)
		setFlash(w, "error", "Could not save the draft: "+err.Error())
		http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
		return
	}
	if err := a.DB.SetMessageFlags(mbox.ID, newUID, `\Seen`); err != nil {
		a.Logger.Error("mark draft %d read for mailbox %d: %v", newUID, mbox.ID, err)
	}

	// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
	if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
		if oldUID := int64(atoi(draftIDStr)); oldUID != newUID {
			if err := a.Mailstore.DeleteMessage(mbox.ID, oldUID); err != nil {
				a.Logger.Error("delete superseded draft %d for mailbox %d: %v", oldUID, mbox.ID, err)
			}
		}
	}

	setFlash(w, "success", "Draft saved")
	http.Redirect(w, r, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(newUID, 10)+"&folder=Drafts", http.StatusFound)
}

// deliverWebmailComposeLocally stores a composed message into another local
// mailbox's own folder per their filter rules — mirrors smtpserver's deliverLocally,
// minus spam/DKIM-verification scoring, which is for untrusted inbound mail; a
// message an authenticated webmail user just composed doesn't need to be
// heuristically judged as spam against itself.
func (a *App) deliverWebmailComposeLocally(rcpt, rcptType, from, subject, signed, messageID string) relay.Result {
	lm, err := a.Mailstore.ResolveRecipient(rcpt)
	if err != nil || lm == nil {
		return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: "recipient not found"}
	}
	folder := "INBOX"
	markRead := false
	if action, err := a.Mailstore.ApplyRules(lm.ID, map[string]string{"from": from, "to": rcpt, "subject": subject}); err == nil {
		if action.Drop {
			return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success", ServerResponse: "Discarded by recipient's filter rule"}
		}
		if action.Folder != "" {
			folder = action.Folder
		}
		markRead = action.MarkRead
	}
	uid, err := a.Mailstore.StoreMessage(lm.ID, folder, []byte(signed), messageID, from, subject)
	if err != nil {
		return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: err.Error()}
	}
	if markRead {
		if err := a.DB.SetMessageFlags(lm.ID, uid, `\Seen`); err != nil {
			a.Logger.Error("mark_read rule failed for message %d: %v", uid, err)
		}
	}
	return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success"}
}

// parseComposeAddrs parses a comma-separated address list, dropping any display name
// — every downstream consumer (ResolveRecipient, MailboxCanSendAs, RelayEmailAsync)
// expects bare addresses. Empty input is not an error (an empty Cc/Bcc is normal).
func parseComposeAddrs(raw string) ([]string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, nil
	}
	addrs, err := mail.ParseAddressList(raw)
	if err != nil {
		return nil, err
	}
	out := make([]string, len(addrs))
	for i, a := range addrs {
		out[i] = a.Address
	}
	return out, nil
}
