fix attachments

This commit is contained in:
ghostersk
2026-03-08 11:48:27 +00:00
parent 964a345657
commit ac43075d62
19 changed files with 1002 additions and 106 deletions
+130 -1
View File
@@ -1,4 +1,4 @@
// Package db provides encrypted SQLite storage for GoMail.
// Package db provides encrypted SQLite storage for GoWebMail.
package db
import (
@@ -868,7 +868,14 @@ func (d *DB) UpsertMessage(m *models.Message) error {
if err != nil {
return err
}
// LastInsertId returns 0 on conflict in SQLite — always look up the real ID.
id, _ := res.LastInsertId()
if id == 0 {
d.sql.QueryRow(
`SELECT id FROM messages WHERE account_id=? AND folder_id=? AND remote_uid=?`,
m.AccountID, m.FolderID, m.RemoteUID,
).Scan(&id)
}
if m.ID == 0 {
m.ID = id
}
@@ -910,6 +917,12 @@ func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) {
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
// Load attachment metadata
if m.HasAttachment {
atts, _ := d.GetAttachmentsByMessage(m.ID, userID)
m.Attachments = atts
}
return m, nil
}
@@ -1564,3 +1577,119 @@ func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]inte
}
return result, rows.Err()
}
// ---- Attachment metadata ----
// SaveAttachmentMeta saves attachment metadata for a message (no binary data).
// Uses INSERT OR REPLACE so a re-sync always refreshes the part path (ContentID).
func (d *DB) SaveAttachmentMeta(messageID int64, atts []models.Attachment) error {
// Delete stale rows first so re-syncs don't leave orphans
d.sql.Exec(`DELETE FROM attachments WHERE message_id=?`, messageID)
for _, a := range atts {
_, err := d.sql.Exec(`
INSERT INTO attachments (message_id, filename, content_type, size, content_id)
VALUES (?,?,?,?,?)`,
messageID, a.Filename, a.ContentType, a.Size, a.ContentID,
)
if err != nil {
return err
}
}
return nil
}
// GetAttachmentsByMessage returns attachment metadata for a message.
func (d *DB) GetAttachmentsByMessage(messageID, userID int64) ([]models.Attachment, error) {
rows, err := d.sql.Query(`
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
FROM attachments a
JOIN messages m ON m.id=a.message_id
JOIN email_accounts ac ON ac.id=m.account_id
WHERE a.message_id=? AND ac.user_id=?`, messageID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Attachment
for rows.Next() {
var a models.Attachment
rows.Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
result = append(result, a)
}
return result, rows.Err()
}
// GetAttachment returns a single attachment record (ownership via userID check).
func (d *DB) GetAttachment(attachmentID, userID int64) (*models.Attachment, error) {
var a models.Attachment
err := d.sql.QueryRow(`
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
FROM attachments a
JOIN messages m ON m.id=a.message_id
JOIN email_accounts ac ON ac.id=m.account_id
WHERE a.id=? AND ac.user_id=?`, attachmentID, userID,
).Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
if err == sql.ErrNoRows {
return nil, nil
}
return &a, err
}
// ---- Mark all read ----
// MarkFolderAllRead marks every message in a folder as read and enqueues IMAP flag ops.
// Returns the list of (remoteUID, folderPath, accountID) for IMAP ops.
func (d *DB) MarkFolderAllRead(folderID, userID int64) ([]PendingIMAPOp, error) {
// Verify folder ownership
var accountID int64
var fullPath string
err := d.sql.QueryRow(`
SELECT f.account_id, f.full_path FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE f.id=? AND a.user_id=?`, folderID, userID,
).Scan(&accountID, &fullPath)
if err != nil {
return nil, fmt.Errorf("folder not found or not owned: %w", err)
}
// Get all unread messages in folder for IMAP ops
rows, err := d.sql.Query(`
SELECT remote_uid FROM messages WHERE folder_id=? AND is_read=0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var ops []PendingIMAPOp
for rows.Next() {
var uid string
rows.Scan(&uid)
var uidNum uint32
fmt.Sscanf(uid, "%d", &uidNum)
if uidNum > 0 {
ops = append(ops, PendingIMAPOp{
AccountID: accountID, OpType: "flag_read",
RemoteUID: uidNum, FolderPath: fullPath, Extra: "1",
})
}
}
rows.Close()
// Bulk mark read in DB
_, err = d.sql.Exec(`UPDATE messages SET is_read=1 WHERE folder_id=?`, folderID)
if err != nil {
return nil, err
}
d.UpdateFolderCounts(folderID)
return ops, nil
}
// ---- Admin MFA disable ----
// AdminDisableMFAByID disables MFA for a user by ID (admin action).
func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
_, err := d.sql.Exec(`
UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending=''
WHERE id=?`, targetUserID)
return err
}
+351 -21
View File
@@ -392,8 +392,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
return m, nil
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
@@ -405,12 +411,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
// cidMap: Content-ID → base64 data URI for inline images
cidMap := make(map[string]string)
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
// Rewrite cid: references in HTML to data: URIs
if html != "" && len(cidMap) > 0 {
html = rewriteCIDReferences(html, cidMap)
}
return
}
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
func rewriteCIDReferences(html string, cidMap map[string]string) string {
for cid, dataURI := range cidMap {
// Match both with and without angle brackets
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
// Some clients wrap CID in angle brackets in the src attribute
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
}
return html
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
return parsePartIndexed(contentType, transferEncoding, body, []int{})
}
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
}
mediaType = strings.ToLower(mediaType)
decoded := decodeTransfer(transferEncoding, body)
switch {
case mediaType == "text/plain":
text = decodeCharset(params["charset"], decoded)
case mediaType == "text/html":
html = decodeCharset(params["charset"], decoded)
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
disposition := part.Header.Get("Content-Disposition")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
if filename != "" {
wd := mime.WordDecoder{}
if dec, e := wd.DecodeHeader(filename); e == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
partMediaLower := strings.ToLower(partMedia)
// Inline image with Content-ID → embed as data URI for cid: resolution
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
decodedPart := decodeTransfer(partTE, partBody)
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
cidMap[contentID] = dataURI
// Don't add as attachment chip — it's inline
continue
}
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
!strings.HasPrefix(partMediaLower, "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath,
})
continue
}
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
@@ -430,11 +568,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
@@ -444,24 +586,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
disposition := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
if strings.EqualFold(dispType, "attachment") {
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
// Filename from Content-Disposition or Content-Type params
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
// Decode RFC 2047 encoded filename
if filename != "" {
wd := mime.WordDecoder{}
if dec, err := wd.DecodeHeader(filename); err == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
partMedia, _, _ := mime.ParseMediaType(partCT)
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePart(partCT, partTE, partBody)
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
@@ -471,13 +630,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
attachments = append(attachments, atts...)
}
default:
// Any other type treat as attachment if it has a filename
mt, _, _ := mime.ParseMediaType(contentType)
_ = mt
// Any other non-text type with a filename → treat as attachment
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// mimePathString converts an int path like [1,2] to "1.2".
func mimePathString(path []int) string {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
return strings.Join(parts, ".")
}
func decodeTransfer(encoding string, data []byte) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
@@ -763,7 +944,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// Use the sender's actual domain for Message-ID so it passes spam filters
domain := account.EmailAddress
if at := strings.Index(domain, "@"); at >= 0 {
@@ -781,24 +963,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
buf.WriteString("\r\n")
// Plain text part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
hasAttachments := len(req.Attachments) > 0
if hasAttachments {
// Outer multipart/mixed wraps body + attachments
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
buf.WriteString("--" + mixedBoundary + "\r\n")
}
// Inner multipart/alternative: text/plain + text/html
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
// HTML part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw2 := quotedprintable.NewWriter(buf)
@@ -809,8 +999,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
}
qpw2.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "--\r\n")
if hasAttachments {
for _, att := range req.Attachments {
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
buf.WriteString(encoded[i:end] + "\r\n")
}
}
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
}
buf.WriteString("--" + boundary + "--\r\n")
return msgID
}
@@ -875,6 +1088,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
// Returns the folder name that was used (for sync purposes).
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
var draftsName string
for _, mb := range mailboxes {
ft := InferFolderType(mb.Name, mb.Attributes)
if ft == "drafts" {
draftsName = mb.Name
break
}
}
if draftsName == "" {
return "", nil // no Drafts folder, skip silently
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
}
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
// raw message and parsing the requested MIME part path.
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
raw, err := c.FetchRawByUID(mailboxName, uid)
if err != nil {
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
}
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, "", "", fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
if err != nil {
return nil, "", "", err
}
return data, filename, contentType, nil
}
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
}
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
}
decoded := decodeTransfer(transferEncoding, body)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, "", "", fmt.Errorf("no boundary")
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, currentPath...), partIdx)
childPathStr := mimePathString(childPath)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
if childPathStr == targetPath {
// Found it
disposition := part.Header.Get("Content-Disposition")
_, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
wd2 := mime.WordDecoder{}
if dec, e := wd2.DecodeHeader(filename); e == nil {
filename = dec
}
partMedia, _, _ := mime.ParseMediaType(partCT)
return decodeTransfer(partTE, partBody), filename, partMedia, nil
}
// Recurse into multipart children
partMedia, _, _ := mime.ParseMediaType(partCT)
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
return data, fn, ct2, nil
}
}
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
// Leaf node — only matches if path is root (empty)
if targetPath == "" || targetPath == "1" {
return decoded, "", strings.ToLower(mediaType), nil
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
+10 -3
View File
@@ -108,9 +108,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
DisableMFA bool `json:"disable_mfa"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -133,6 +134,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.DisableMFA {
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
+274 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -532,6 +533,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
h.db.MarkMessageRead(messageID, userID, true)
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
if msg.HasAttachment && len(msg.Attachments) == 0 {
if uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID); iErr == nil && uid != 0 && account != nil {
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
_, _, atts := email.ParseMIMEFull(raw)
if len(atts) > 0 {
h.db.SaveAttachmentMeta(messageID, atts)
if fresh, fErr := h.db.GetAttachmentsByMessage(messageID, userID); fErr == nil {
msg.Attachments = fresh
}
}
}
c.Close()
}
}
}
h.writeJSON(w, msg)
}
@@ -652,13 +673,54 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward")
}
func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward-attachment")
}
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Parse multipart form (attachments present)
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
metaStr := r.FormValue("meta")
if err := json.NewDecoder(strings.NewReader(metaStr)).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid meta JSON")
return
}
if r.MultipartForm != nil {
for _, fheaders := range r.MultipartForm.File {
for _, fh := range fheaders {
f, err := fh.Open()
if err != nil {
continue
}
data, _ := io.ReadAll(f)
f.Close()
fileCT := fh.Header.Get("Content-Type")
if fileCT == "" {
fileCT = "application/octet-stream"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: fh.Filename,
ContentType: fileCT,
Size: int64(len(data)),
Data: data,
})
}
}
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
}
account, err := h.db.GetAccount(req.AccountID)
@@ -667,6 +729,30 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
return
}
// Forward-as-attachment: fetch original message as EML and attach it
if mode == "forward-attachment" && req.ForwardFromID > 0 {
origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID)
if origMsg != nil {
uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID)
if iErr == nil && uid != 0 && origAccount != nil {
if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
safe := sanitizeFilename(origMsg.Subject)
if safe == "" {
safe = "message"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: safe + ".eml",
ContentType: "message/rfc822",
Data: raw,
})
}
c.Close()
}
}
}
}
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
h.db.WriteAudit(&userID, models.AuditAppError,
@@ -1062,3 +1148,188 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
}
h.writeJSON(w, map[string]interface{}{"messages": msgs})
}
// ---- Attachment download ----
// DownloadAttachment fetches and streams a message attachment from IMAP.
func (h *APIHandler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
// Get attachment metadata from DB
attachmentID := pathInt64(r, "att_id")
att, err := h.db.GetAttachment(attachmentID, userID)
if err != nil || att == nil {
h.writeError(w, http.StatusNotFound, "attachment not found")
return
}
_ = messageID // already verified via GetAttachment ownership check
// Get IMAP info for the message
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(att.MessageID, userID)
if iErr != nil || uid == 0 || account == nil {
h.writeError(w, http.StatusNotFound, "message IMAP info not found")
return
}
c, cErr := email.Connect(context.Background(), account)
if cErr != nil {
h.writeError(w, http.StatusBadGateway, "IMAP connect failed: "+cErr.Error())
return
}
defer c.Close()
// att.ContentID stores the MIME part path (set during parse)
mimePartPath := att.ContentID
if mimePartPath == "" {
h.writeError(w, http.StatusNotFound, "attachment part path not stored")
return
}
data, filename, ct, fetchErr := c.FetchAttachmentRaw(folderPath, uid, mimePartPath)
if fetchErr != nil {
h.writeError(w, http.StatusBadGateway, "fetch failed: "+fetchErr.Error())
return
}
if filename == "" {
filename = att.Filename
}
if ct == "" {
ct = att.ContentType
}
if ct == "" {
ct = "application/octet-stream"
}
safe := sanitizeFilename(filename)
// For browser-viewable types, use inline disposition so they open in a new tab.
// For everything else, force download.
disposition := "attachment"
ctLower := strings.ToLower(ct)
if strings.HasPrefix(ctLower, "image/") ||
strings.HasPrefix(ctLower, "text/") ||
strings.HasPrefix(ctLower, "video/") ||
strings.HasPrefix(ctLower, "audio/") ||
ctLower == "application/pdf" {
disposition = "inline"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe))
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// ListAttachments returns stored attachment metadata for a message.
func (h *APIHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
atts, err := h.db.GetAttachmentsByMessage(messageID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list attachments")
return
}
if atts == nil {
atts = []models.Attachment{}
}
// Strip raw data from response, keep metadata only
type attMeta struct {
ID int64 `json:"id"`
MessageID int64 `json:"message_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
result := make([]attMeta, len(atts))
for i, a := range atts {
result[i] = attMeta{a.ID, a.MessageID, a.Filename, a.ContentType, a.Size}
}
h.writeJSON(w, result)
}
// ---- Mark folder all read ----
func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
ops, err := h.db.MarkFolderAllRead(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Enqueue all flag_read ops and trigger sync
accountIDs := map[int64]bool{}
for _, op := range ops {
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: op.AccountID, OpType: "flag_read",
RemoteUID: op.RemoteUID, FolderPath: op.FolderPath, Extra: "1",
})
accountIDs[op.AccountID] = true
}
for accID := range accountIDs {
h.syncer.TriggerAccountSync(accID)
}
h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)})
}
// ---- Save draft (IMAP APPEND to Drafts) ----
func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
json.NewDecoder(strings.NewReader(r.FormValue("meta"))).Decode(&req)
} else {
json.NewDecoder(r.Body).Decode(&req)
}
account, err := h.db.GetAccount(req.AccountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusBadRequest, "account not found")
return
}
// Build the MIME message bytes
var buf strings.Builder
buf.WriteString("From: " + account.EmailAddress + "\r\n")
if len(req.To) > 0 {
buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n")
}
buf.WriteString("Subject: " + req.Subject + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(req.BodyHTML)
raw := []byte(buf.String())
// Append to IMAP Drafts in background
go func() {
c, err := email.Connect(context.Background(), account)
if err != nil {
log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err)
return
}
defer c.Close()
draftsFolder, err := c.AppendToDrafts(raw)
if err != nil {
log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err)
return
}
if draftsFolder != "" {
// Trigger a sync of the drafts folder to pick up the saved draft
h.syncer.TriggerAccountSync(account.ID)
}
}()
h.writeJSON(w, map[string]bool{"ok": true})
}
+2 -2
View File
@@ -142,8 +142,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
+1 -1
View File
@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
}
// OTPAuthURL builds an otpauth:// URI for QR code generation.
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
func OTPAuthURL(issuer, accountName, secret string) string {
v := url.Values{}
v.Set("secret", secret)
+1 -1
View File
@@ -1,4 +1,4 @@
// Package middleware provides HTTP middleware for GoMail.
// Package middleware provides HTTP middleware for GoWebMail.
package middleware
import (
+4 -2
View File
@@ -4,7 +4,7 @@ import "time"
// ---- Users ----
// UserRole controls access level within GoMail.
// UserRole controls access level within GoWebMail.
type UserRole string
const (
@@ -12,7 +12,7 @@ const (
RoleUser UserRole = "user"
)
// User represents a GoMail application user.
// User represents a GoWebMail application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
@@ -213,6 +213,8 @@ type ComposeRequest struct {
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
}
// ---- Search ----
+4
View File
@@ -446,6 +446,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
msg.FolderID = dbFolder.ID
if err := s.db.UpsertMessage(msg); err == nil {
newMessages++
// Save attachment metadata if any (enables download)
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)