813 lines
30 KiB
Go
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
|
|
}
|