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 // 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 { aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID) var sendAsOptions []string for _, al := range aliases { if al.CanSendAs && al.IsActive { sendAsOptions = append(sendAsOptions, al.Email) } } identities, _ := a.DB.ListSMIMEIdentities(mbox.ID) pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID) return M{"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts} } // 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" } 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 = `
` + html.EscapeString(parsed.TextBody) + `
` } 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)) } } } 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
 so line breaks survive.
func quotedBodyHTML(parsed *mailview.Message) string {
	if parsed.HTMLBody != "" {
		return htmlBodyPolicy.Sanitize(parsed.HTMLBody)
	}
	return `
` + html.EscapeString(plainBody(parsed)) + `
` } // 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 = "


" func quoteBodyHTML(parsed *mailview.Message) string { return composeCursorHome + "

On " + html.EscapeString(parsed.Header.Date) + ", " + html.EscapeString(parsed.Header.From) + " wrote:

" + `
` + quotedBodyHTML(parsed) + "
" } func forwardBodyHTML(parsed *mailview.Message) string { return composeCursorHome + "

---------- Forwarded message ----------
" + "From: " + html.EscapeString(parsed.Header.From) + "
" + "Date: " + html.EscapeString(parsed.Header.Date) + "
" + "Subject: " + html.EscapeString(parsed.Header.Subject) + "
" + "To: " + html.EscapeString(parsed.Header.To) + "

" + 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 _, 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) } // 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) } } loggedBody := plainText if wantEncrypt { // The whole point of checking "Encrypt" is that nobody but the recipient (and // the sender's own Sent copy) can read it — logging the plaintext into the // admin-visible email log would defeat that even though the wire content is // genuinely encrypted. 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 for _, res := range results { if res.Status != "success" { allSucceeded = false reason := res.ErrorMessage if reason == "" { reason = res.ServerResponse } failures = append(failures, res.Recipient+": "+reason) } } if allSucceeded { setFlash(w, "success", "Message sent") } else { setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; ")) } 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 } // 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 }