mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
fix attachments
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,4 +3,6 @@ data/*.db
|
||||
data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gowebmail.conf
|
||||
data/*.txt
|
||||
data/*.txt
|
||||
gowebmail-devplan.md
|
||||
testrun/
|
||||
@@ -169,6 +169,8 @@ func main() {
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
||||
|
||||
@@ -180,6 +182,8 @@ func main() {
|
||||
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
|
||||
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
||||
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
|
||||
api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST")
|
||||
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
|
||||
|
||||
// Folders
|
||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
||||
@@ -189,6 +193,7 @@ func main() {
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
|
||||
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
||||
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
||||
@@ -306,7 +311,7 @@ func runDisableMFA(username string) {
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`GoMail — Admin CLI
|
||||
fmt.Print(`GoWebMail — Admin CLI
|
||||
|
||||
Usage:
|
||||
gowebmail Start the mail server
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package config loads and persists GoMail configuration from data/gowebmail.conf
|
||||
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -59,7 +59,7 @@ var allFields = []configField{
|
||||
defVal: "localhost",
|
||||
comments: []string{
|
||||
"--- Server ---",
|
||||
"Public hostname of this GoMail instance (no port, no protocol).",
|
||||
"Public hostname of this GoWebMail instance (no port, no protocol).",
|
||||
"Examples: localhost | mail.example.com | 192.168.1.10",
|
||||
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
||||
"Also used in security checks to reject requests with unexpected Host headers.",
|
||||
@@ -92,7 +92,7 @@ var allFields = []configField{
|
||||
key: "SECURE_COOKIE",
|
||||
defVal: "false",
|
||||
comments: []string{
|
||||
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
|
||||
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
|
||||
"Marks session cookies as Secure so browsers only send them over TLS.",
|
||||
},
|
||||
},
|
||||
@@ -109,7 +109,7 @@ var allFields = []configField{
|
||||
comments: []string{
|
||||
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
||||
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
|
||||
"which GoMail uses to determine the real client IP and whether TLS is in use.",
|
||||
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
|
||||
" Examples:",
|
||||
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
||||
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
||||
@@ -228,7 +228,7 @@ func Load() (*Config, error) {
|
||||
|
||||
// get returns env var if set, else file value, else ""
|
||||
get := func(key string) string {
|
||||
// Only check env vars that are explicitly GoMail-namespaced or well-known.
|
||||
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
|
||||
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
||||
// picking up cloud-platform env vars unintentionally.
|
||||
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
||||
@@ -443,7 +443,7 @@ func readConfigFile(path string) (map[string]string, error) {
|
||||
|
||||
func writeConfigFile(path string, values map[string]string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# GoMail Configuration\n")
|
||||
sb.WriteString("# GoWebMail Configuration\n")
|
||||
sb.WriteString("# =====================\n")
|
||||
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
||||
sb.WriteString("# Edit freely — your values are always preserved.\n")
|
||||
@@ -576,7 +576,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
|
||||
}
|
||||
|
||||
func logStartupInfo(cfg *Config) {
|
||||
fmt.Printf("GoMail starting:\n")
|
||||
fmt.Printf("GoWebMail starting:\n")
|
||||
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
||||
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
||||
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
// Package middleware provides HTTP middleware for GoMail.
|
||||
// Package middleware provides HTTP middleware for GoWebMail.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -369,10 +369,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
|
||||
/* ---- Attachment chips ---- */
|
||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
|
||||
text-decoration:none;color:inherit}
|
||||
.attachment-chip:hover{background:var(--border2)}
|
||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
||||
/* Drag-and-drop compose overlay */
|
||||
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
|
||||
|
||||
/* ── Email tag input ─────────────────────────────────────────── */
|
||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GoMail Admin SPA
|
||||
// GoWebMail Admin SPA
|
||||
|
||||
const adminRoutes = {
|
||||
'/admin': renderUsers,
|
||||
@@ -26,7 +26,7 @@ async function renderUsers() {
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Users</h1>
|
||||
<p>Manage GoMail accounts and permissions.</p>
|
||||
<p>Manage GoWebMail accounts and permissions.</p>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
@@ -67,16 +67,19 @@ async function loadUsersTable() {
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
||||
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||
el.innerHTML = `<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
|
||||
<tbody>${r.map(u => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(u.username)}</td>
|
||||
<td style="color:var(--muted)">${esc(u.email)}</td>
|
||||
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
||||
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
||||
<td style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
|
||||
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
|
||||
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
@@ -139,6 +142,23 @@ async function deleteUser(userId) {
|
||||
else toast((r && r.error) || 'Delete failed', 'error');
|
||||
}
|
||||
|
||||
async function disableMFA(userId, username) {
|
||||
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
|
||||
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
|
||||
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
|
||||
else toast((r && r.error) || 'Failed to disable MFA', 'error');
|
||||
}
|
||||
|
||||
function openResetPassword(userId, username) {
|
||||
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
|
||||
if (!pw) return;
|
||||
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
|
||||
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
|
||||
if (r && r.ok) toast('Password reset for ' + username, 'success');
|
||||
else toast((r && r.error) || 'Failed to reset password', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GoMail app.js — full client
|
||||
// GoWebMail app.js — full client
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
@@ -6,12 +6,8 @@ const S = {
|
||||
folders: [], messages: [], totalMessages: 0,
|
||||
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
||||
currentMessage: null, selectedMessageId: null,
|
||||
searchQuery: '', composeMode: 'new', composeReplyToId: null,
|
||||
remoteWhitelist: new Set(),
|
||||
draftTimer: null, draftDirty: false,
|
||||
composeVisible: false, composeMinimised: false,
|
||||
// Message list filters
|
||||
filterUnread: false,
|
||||
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
||||
filterUnread: false, filterAttachment: false,
|
||||
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
||||
};
|
||||
|
||||
@@ -387,6 +383,7 @@ function showFolderMenu(e, folderId) {
|
||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
||||
${enableAllEntry}
|
||||
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()">✓ Mark all as read</div>
|
||||
<div class="ctx-sep"></div>
|
||||
${moveEntry}
|
||||
${emptyEntry}
|
||||
@@ -401,6 +398,15 @@ async function syncFolderNow(folderId) {
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
async function markFolderAllRead(folderId) {
|
||||
const r=await api('POST','/folders/'+folderId+'/mark-all-read');
|
||||
if(r?.ok){
|
||||
toast(`Marked ${r.marked||0} message(s) as read`,'success');
|
||||
loadFolders();
|
||||
loadMessages();
|
||||
} else toast(r?.error||'Failed','error');
|
||||
}
|
||||
|
||||
async function toggleFolderSync(folderId) {
|
||||
const f = S.folders.find(f=>f.id===folderId);
|
||||
if (!f) return;
|
||||
@@ -533,18 +539,19 @@ async function loadMessages(append) {
|
||||
|
||||
function setFilter(mode) {
|
||||
S.filterUnread = (mode === 'unread');
|
||||
S.sortOrder = (mode === 'unread' || mode === 'default') ? 'date-desc' : mode;
|
||||
S.filterAttachment = (mode === 'attachment');
|
||||
S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode;
|
||||
|
||||
// Update checkmarks
|
||||
['default','unread','date-desc','date-asc','size-desc'].forEach(k => {
|
||||
['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => {
|
||||
const el = document.getElementById('fopt-'+k);
|
||||
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
|
||||
});
|
||||
|
||||
// Update button label
|
||||
const labels = {
|
||||
'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date',
|
||||
'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
||||
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
|
||||
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
||||
};
|
||||
const labelEl = document.getElementById('filter-label');
|
||||
if (labelEl) {
|
||||
@@ -569,6 +576,7 @@ function renderMessageList() {
|
||||
|
||||
// Filter
|
||||
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
|
||||
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
|
||||
|
||||
// Sort
|
||||
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
|
||||
@@ -576,7 +584,8 @@ function renderMessageList() {
|
||||
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
|
||||
|
||||
if (!msgs.length){
|
||||
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${S.filterUnread?'No unread messages':'No messages'}</p></div>`;
|
||||
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
|
||||
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${emptyMsg}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -642,6 +651,7 @@ function handleMsgClick(e, id, idx) {
|
||||
function getFilteredSortedMsgs() {
|
||||
let msgs=[...S.messages];
|
||||
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
|
||||
if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment);
|
||||
if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date));
|
||||
else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0));
|
||||
else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date));
|
||||
@@ -717,21 +727,36 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
|
||||
|
||||
// CSS injected into every iframe — forces white background so dark-themed emails
|
||||
// don't inherit our app dark theme. allow-scripts is needed for some email onclick events.
|
||||
// don't inherit our app dark theme.
|
||||
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
|
||||
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
|
||||
`img{max-width:100%;height:auto}</style>`;
|
||||
|
||||
// Script injected into srcdoc to report content height via postMessage.
|
||||
// Required because removing allow-same-origin means contentDocument is null from parent.
|
||||
const heightScript = `<script>
|
||||
function _reportH(){parent.postMessage({type:'gomail-frame-h',h:document.documentElement.scrollHeight},'*');}
|
||||
document.addEventListener('DOMContentLoaded',_reportH);
|
||||
window.addEventListener('load',_reportH);
|
||||
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
|
||||
<\/script>`;
|
||||
|
||||
// NOTE: allow-scripts is needed for the height-reporting script above.
|
||||
// allow-same-origin is intentionally excluded to prevent sandbox escape.
|
||||
// Inline CID images are resolved to data: URIs during sync so no cid: scheme needed.
|
||||
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
let bodyHtml='';
|
||||
if (msg.body_html) {
|
||||
if (allowed) {
|
||||
const srcdoc = cssReset + msg.body_html;
|
||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
||||
style="width:100%;border:none;min-height:400px;display:block"
|
||||
const srcdoc = cssReset + heightScript + msg.body_html;
|
||||
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||
style="width:100%;border:none;min-height:200px;display:block"
|
||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||
} else {
|
||||
// Block external http(s) images but preserve data: URIs (inline/CID already resolved)
|
||||
const stripped = msg.body_html
|
||||
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])[^'"]*\2/gi, '<img$1src="" data-blocked="1"')
|
||||
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi, '<img$1src="" data-blocked-src="$3"')
|
||||
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi, 'url()')
|
||||
.replace(/<link[^>]*>/gi, '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
@@ -742,9 +767,9 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
|
||||
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||
</div>
|
||||
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
||||
style="width:100%;border:none;min-height:400px;display:block"
|
||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||
<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||
style="width:100%;border:none;min-height:200px;display:block"
|
||||
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'"')}"></iframe>`;
|
||||
}
|
||||
} else {
|
||||
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||
@@ -754,10 +779,20 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
if (msg.attachments?.length) {
|
||||
attachHtml=`<div class="attachments-bar">
|
||||
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
|
||||
${msg.attachments.map(a=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.filename)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
</div>`).join('')}
|
||||
${msg.attachments.map(a=>{
|
||||
const url=`/api/messages/${msg.id}/attachments/${a.id}`;
|
||||
const viewable=/^(image\/|text\/|application\/pdf$|video\/|audio\/)/.test(a.content_type||'');
|
||||
if(viewable){
|
||||
return `<a class="attachment-chip" href="${url}" target="_blank" rel="noopener" title="Open ${esc(a.filename)}">
|
||||
📎 <span>${esc(a.filename)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
</a>`;
|
||||
}
|
||||
return `<a class="attachment-chip" href="${url}" download="${esc(a.filename)}" title="Download ${esc(a.filename)}">
|
||||
📎 <span>${esc(a.filename)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
</a>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -777,6 +812,7 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
<div class="detail-actions">
|
||||
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
||||
<button class="action-btn" onclick="openForward()">↪ Forward</button>
|
||||
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment">↪ Fwd as Attachment</button>
|
||||
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
||||
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||
@@ -786,24 +822,24 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
${attachHtml}
|
||||
<div class="detail-body">${bodyHtml}</div>`;
|
||||
|
||||
// Auto-size iframe to content height using ResizeObserver
|
||||
// Auto-size iframe via postMessage from injected height-reporting script.
|
||||
// We cannot use contentDocument (null without allow-same-origin in sandbox).
|
||||
if (msg.body_html) {
|
||||
const frame=document.getElementById('msg-frame');
|
||||
const frame = document.getElementById('msg-frame');
|
||||
if (frame) {
|
||||
const sizeFrame = () => {
|
||||
try {
|
||||
const h = frame.contentDocument?.documentElement?.scrollHeight;
|
||||
if (h && h > 50) frame.style.height = (h + 20) + 'px';
|
||||
} catch(e) {}
|
||||
};
|
||||
frame.onload = () => {
|
||||
sizeFrame();
|
||||
// Also observe content changes (images loading)
|
||||
try {
|
||||
const ro = new ResizeObserver(sizeFrame);
|
||||
ro.observe(frame.contentDocument.documentElement);
|
||||
} catch(e) {}
|
||||
// Clean up any previous listener
|
||||
if (window._frameMsgHandler) window.removeEventListener('message', window._frameMsgHandler);
|
||||
let lastH = 0;
|
||||
window._frameMsgHandler = (e) => {
|
||||
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
|
||||
const h = e.data.h + 24;
|
||||
if (Math.abs(h - lastH) > 4) { // avoid micro-flicker
|
||||
lastH = h;
|
||||
frame.style.height = h + 'px';
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', window._frameMsgHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -946,6 +982,7 @@ function showCompose() {
|
||||
d.style.display='flex';
|
||||
m.style.display='none';
|
||||
S.composeVisible=true; S.composeMinimised=false;
|
||||
initComposeDragDrop();
|
||||
}
|
||||
|
||||
function minimizeCompose() {
|
||||
@@ -995,13 +1032,30 @@ function openReplyTo(msgId) {
|
||||
function openForward() {
|
||||
if (!S.currentMessage) return;
|
||||
const msg=S.currentMessage;
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward', title:'Forward',
|
||||
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||
subject:'Fwd: '+(msg.subject||''),
|
||||
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
}
|
||||
|
||||
function openForwardAsAttachment() {
|
||||
if (!S.currentMessage) return;
|
||||
const msg=S.currentMessage;
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
||||
subject:'Fwd: '+(msg.subject||''),
|
||||
body:'',
|
||||
});
|
||||
// Add a visual placeholder chip (the actual EML is fetched server-side)
|
||||
composeAttachments=[{name: sanitizeSubject(msg.subject||'message')+'.eml', size:0, isForward:true}];
|
||||
updateAttachList();
|
||||
}
|
||||
|
||||
function sanitizeSubject(s){return s.replace(/[/\\:*?"<>|]/g,'_').slice(0,60)||'message';}
|
||||
|
||||
// ── Email Tag Input ────────────────────────────────────────────────────────
|
||||
function initTagField(containerId) {
|
||||
const container=document.getElementById(containerId);
|
||||
@@ -1077,25 +1131,64 @@ function clearDraftAutosave() {
|
||||
|
||||
async function saveDraft(silent) {
|
||||
S.draftDirty=false;
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
if(!accountId){ if(!silent) toast('Draft saved locally','success'); return; }
|
||||
const editor=document.getElementById('compose-editor');
|
||||
const meta={
|
||||
account_id:accountId,
|
||||
to:getTagValues('compose-to'),
|
||||
subject:document.getElementById('compose-subject').value,
|
||||
body_html:editor.innerHTML.trim(),
|
||||
body_text:editor.innerText.trim(),
|
||||
};
|
||||
const r=await api('POST','/draft',meta);
|
||||
if(!silent) toast('Draft saved','success');
|
||||
else toast('Draft auto-saved','success');
|
||||
else if(r?.ok) toast('Draft auto-saved to server','success');
|
||||
}
|
||||
|
||||
// ── Compose formatting ─────────────────────────────────────────────────────
|
||||
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
||||
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
|
||||
function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); }
|
||||
function removeAttachment(i) {
|
||||
// Don't remove EML forward placeholder (isForward) from UI; it's handled server-side
|
||||
if(composeAttachments[i]?.isForward && S.composeMode==='forward-attachment'){
|
||||
toast('The original message will be attached when sent','info'); return;
|
||||
}
|
||||
composeAttachments.splice(i,1); updateAttachList();
|
||||
}
|
||||
function updateAttachList() {
|
||||
const el=document.getElementById('compose-attach-list');
|
||||
if(!composeAttachments.length){el.innerHTML='';return;}
|
||||
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.name)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${a.size?formatSize(a.size):''}</span>
|
||||
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Compose drag-and-drop attachments ──────────────────────────────────────
|
||||
function initComposeDragDrop() {
|
||||
const dialog=document.getElementById('compose-dialog');
|
||||
if(!dialog) return;
|
||||
dialog.addEventListener('dragover', e=>{
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dialog.classList.add('drag-over');
|
||||
});
|
||||
dialog.addEventListener('dragleave', e=>{
|
||||
if(!dialog.contains(e.relatedTarget)) dialog.classList.remove('drag-over');
|
||||
});
|
||||
dialog.addEventListener('drop', e=>{
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dialog.classList.remove('drag-over');
|
||||
if(e.dataTransfer?.files?.length){
|
||||
for(const file of e.dataTransfer.files) composeAttachments.push({file,name:file.name,size:file.size});
|
||||
updateAttachList(); S.draftDirty=true;
|
||||
toast(`${e.dataTransfer.files.length} file(s) attached`,'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
const to=getTagValues('compose-to');
|
||||
@@ -1104,15 +1197,44 @@ async function sendMessage() {
|
||||
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
||||
const btn=document.getElementById('send-btn');
|
||||
btn.disabled=true; btn.textContent='Sending…';
|
||||
const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send';
|
||||
const r=await api('POST',endpoint,{
|
||||
|
||||
const endpoint=S.composeMode==='reply'?'/reply'
|
||||
:S.composeMode==='forward'?'/forward'
|
||||
:S.composeMode==='forward-attachment'?'/forward-attachment'
|
||||
:'/send';
|
||||
|
||||
const meta={
|
||||
account_id:accountId, to,
|
||||
cc:getTagValues('compose-cc-tags'),
|
||||
bcc:getTagValues('compose-bcc-tags'),
|
||||
subject:document.getElementById('compose-subject').value,
|
||||
body_text:bodyText, body_html:bodyHTML,
|
||||
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
||||
});
|
||||
forward_from_id:(S.composeMode==='forward'||S.composeMode==='forward-attachment')?S.composeForwardFromId:0,
|
||||
};
|
||||
|
||||
let r;
|
||||
// Use FormData when there are real file attachments, OR when forwarding as attachment
|
||||
// (server needs multipart so it can read forward_from_id from meta and fetch the EML itself)
|
||||
const hasRealFiles = composeAttachments.some(a => a.file instanceof Blob);
|
||||
const needsFormData = hasRealFiles || S.composeMode === 'forward-attachment';
|
||||
if(needsFormData){
|
||||
const fd=new FormData();
|
||||
fd.append('meta', JSON.stringify(meta));
|
||||
for(const a of composeAttachments){
|
||||
if(a.file instanceof Blob){ // only append real File/Blob objects
|
||||
fd.append('file', a.file, a.name);
|
||||
}
|
||||
// isForward placeholders are intentionally skipped — the EML is fetched server-side
|
||||
}
|
||||
try{
|
||||
const resp=await fetch('/api'+endpoint,{method:'POST',body:fd});
|
||||
r=await resp.json();
|
||||
}catch(e){ r={error:String(e)}; }
|
||||
} else {
|
||||
r=await api('POST',endpoint,meta);
|
||||
}
|
||||
|
||||
btn.disabled=false; btn.textContent='Send';
|
||||
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
||||
else toast(r?.error||'Send failed','error');
|
||||
@@ -1386,7 +1508,7 @@ function updateUnreadBadgeFromPoll(inboxUnread) {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
// Update browser tab title
|
||||
const base = 'GoMail';
|
||||
const base = 'GoWebMail';
|
||||
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1566,7 @@ function sendOSNotification(msgs) {
|
||||
const first = msgs[0];
|
||||
const title = count === 1
|
||||
? (first.from_name || first.from_email || 'New message')
|
||||
: `${count} new messages in GoMail`;
|
||||
: `${count} new messages in GoWebMail`;
|
||||
const body = count === 1
|
||||
? (first.subject || '(no subject)')
|
||||
: `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GoMail shared utilities - loaded on every page
|
||||
// GoWebMail shared utilities - loaded on every page
|
||||
|
||||
// ---- API helper ----
|
||||
async function api(method, path, body) {
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script src="/static/js/admin.js?v=15"></script>
|
||||
{{end}}
|
||||
@@ -58,13 +58,14 @@
|
||||
<span id="filter-label">Filter</span>
|
||||
</button>
|
||||
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
|
||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,5 +309,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=12"></script>
|
||||
<script src="/static/js/app.js?v=15"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=15">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gowebmail.js?v=12"></script>
|
||||
<script src="/static/js/gowebmail.js?v=15"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user