Files
mailgoserver/internal/webui/webmail_compose.go
T

813 lines
30 KiB
Go

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 = `<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))
}
}
}
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 _, 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
}