mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-08-04 09:27:46 +01:00
fix attachments
This commit is contained in:
@@ -4,3 +4,5 @@ data/*.db-shm
|
|||||||
data/*db-wal
|
data/*db-wal
|
||||||
data/gowebmail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
|
gowebmail-devplan.md
|
||||||
|
testrun/
|
||||||
+6
-1
@@ -169,6 +169,8 @@ func main() {
|
|||||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
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]+}/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]+}/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/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
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("/send", h.API.SendMessage).Methods("POST")
|
||||||
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
||||||
api.HandleFunc("/forward", h.API.ForwardMessage).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
|
// Folders
|
||||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
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]+}/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]+}/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]+}/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("/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("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
||||||
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
||||||
@@ -306,7 +311,7 @@ func runDisableMFA(username string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Print(`GoMail — Admin CLI
|
fmt.Print(`GoWebMail — Admin CLI
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
gowebmail Start the mail server
|
gowebmail Start the mail server
|
||||||
|
|||||||
+7
-7
@@ -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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -59,7 +59,7 @@ var allFields = []configField{
|
|||||||
defVal: "localhost",
|
defVal: "localhost",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"--- Server ---",
|
"--- 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",
|
"Examples: localhost | mail.example.com | 192.168.1.10",
|
||||||
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
||||||
"Also used in security checks to reject requests with unexpected Host headers.",
|
"Also used in security checks to reject requests with unexpected Host headers.",
|
||||||
@@ -92,7 +92,7 @@ var allFields = []configField{
|
|||||||
key: "SECURE_COOKIE",
|
key: "SECURE_COOKIE",
|
||||||
defVal: "false",
|
defVal: "false",
|
||||||
comments: []string{
|
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.",
|
"Marks session cookies as Secure so browsers only send them over TLS.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +109,7 @@ var allFields = []configField{
|
|||||||
comments: []string{
|
comments: []string{
|
||||||
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
"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,",
|
"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:",
|
" Examples:",
|
||||||
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
||||||
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
" 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 returns env var if set, else file value, else ""
|
||||||
get := func(key string) string {
|
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
|
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
||||||
// picking up cloud-platform env vars unintentionally.
|
// picking up cloud-platform env vars unintentionally.
|
||||||
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
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 {
|
func writeConfigFile(path string, values map[string]string) error {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("# GoMail Configuration\n")
|
sb.WriteString("# GoWebMail Configuration\n")
|
||||||
sb.WriteString("# =====================\n")
|
sb.WriteString("# =====================\n")
|
||||||
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
||||||
sb.WriteString("# Edit freely — your values are always preserved.\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) {
|
func logStartupInfo(cfg *Config) {
|
||||||
fmt.Printf("GoMail starting:\n")
|
fmt.Printf("GoWebMail starting:\n")
|
||||||
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
||||||
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
||||||
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
||||||
|
|||||||
+130
-1
@@ -1,4 +1,4 @@
|
|||||||
// Package db provides encrypted SQLite storage for GoMail.
|
// Package db provides encrypted SQLite storage for GoWebMail.
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -868,7 +868,14 @@ func (d *DB) UpsertMessage(m *models.Message) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// LastInsertId returns 0 on conflict in SQLite — always look up the real ID.
|
||||||
id, _ := res.LastInsertId()
|
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 {
|
if m.ID == 0 {
|
||||||
m.ID = id
|
m.ID = id
|
||||||
}
|
}
|
||||||
@@ -910,6 +917,12 @@ func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) {
|
|||||||
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
|
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
|
||||||
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
|
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
|
||||||
|
|
||||||
|
// Load attachment metadata
|
||||||
|
if m.HasAttachment {
|
||||||
|
atts, _ := d.GetAttachmentsByMessage(m.ID, userID)
|
||||||
|
m.Attachments = atts
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,3 +1577,119 @@ func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]inte
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
+348
-18
@@ -392,8 +392,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
|
|||||||
return m, nil
|
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
|
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
||||||
// text/plain, text/html and attachment metadata.
|
// 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) {
|
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -405,12 +411,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
|
|||||||
ct = "text/plain"
|
ct = "text/plain"
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(msg.Body)
|
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
|
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.
|
// parsePart recursively handles a MIME part.
|
||||||
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
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)
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(body), "", nil
|
return string(body), "", nil
|
||||||
@@ -430,11 +568,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
return string(decoded), "", nil
|
return string(decoded), "", nil
|
||||||
}
|
}
|
||||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||||
|
partIdx := 0
|
||||||
for {
|
for {
|
||||||
part, err := mr.NextPart()
|
part, err := mr.NextPart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
partIdx++
|
||||||
|
childPath := append(append([]int{}, path...), partIdx)
|
||||||
|
|
||||||
partBody, _ := io.ReadAll(part)
|
partBody, _ := io.ReadAll(part)
|
||||||
partCT := part.Header.Get("Content-Type")
|
partCT := part.Header.Get("Content-Type")
|
||||||
if partCT == "" {
|
if partCT == "" {
|
||||||
@@ -444,24 +586,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
disposition := part.Header.Get("Content-Disposition")
|
disposition := part.Header.Get("Content-Disposition")
|
||||||
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||||
|
|
||||||
if strings.EqualFold(dispType, "attachment") {
|
// Filename from Content-Disposition or Content-Type params
|
||||||
filename := dispParams["filename"]
|
filename := dispParams["filename"]
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = part.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 == "" {
|
if filename == "" {
|
||||||
filename = "attachment"
|
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{
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
ContentType: partMedia,
|
ContentType: partMedia,
|
||||||
Size: int64(len(partBody)),
|
Size: int64(len(partBody)),
|
||||||
|
ContentID: mimePartPath, // reuse ContentID to store part path
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
t, h, atts := parsePart(partCT, partTE, partBody)
|
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
|
||||||
if text == "" && t != "" {
|
if text == "" && t != "" {
|
||||||
text = t
|
text = t
|
||||||
}
|
}
|
||||||
@@ -471,13 +630,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
attachments = append(attachments, atts...)
|
attachments = append(attachments, atts...)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Any other type – treat as attachment if it has a filename
|
// Any other non-text type with a filename → treat as attachment
|
||||||
mt, _, _ := mime.ParseMediaType(contentType)
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||||
_ = mt
|
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
|
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 {
|
func decodeTransfer(encoding string, data []byte) []byte {
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
case "base64":
|
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 {
|
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
||||||
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
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
|
// Use the sender's actual domain for Message-ID so it passes spam filters
|
||||||
domain := account.EmailAddress
|
domain := account.EmailAddress
|
||||||
if at := strings.Index(domain, "@"); at >= 0 {
|
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("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
||||||
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\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("MIME-Version: 1.0\r\n")
|
||||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
|
|
||||||
buf.WriteString("\r\n")
|
|
||||||
|
|
||||||
// Plain text part
|
hasAttachments := len(req.Attachments) > 0
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
if hasAttachments {
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
// Outer multipart/mixed wraps body + attachments
|
||||||
qpw := quotedprintable.NewWriter(buf)
|
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
|
plainText := req.BodyText
|
||||||
if plainText == "" && req.BodyHTML != "" {
|
if plainText == "" && req.BodyHTML != "" {
|
||||||
plainText = htmlToPlainText(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.Write([]byte(plainText))
|
||||||
qpw.Close()
|
qpw.Close()
|
||||||
buf.WriteString("\r\n")
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
// HTML part
|
buf.WriteString("--" + altBoundary + "\r\n")
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
qpw2 := quotedprintable.NewWriter(buf)
|
qpw2 := quotedprintable.NewWriter(buf)
|
||||||
@@ -809,8 +999,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
|||||||
}
|
}
|
||||||
qpw2.Close()
|
qpw2.Close()
|
||||||
buf.WriteString("\r\n")
|
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
|
return msgID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +1088,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
|
|||||||
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
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 {
|
func htmlEscape(s string) string {
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
DisableMFA bool `json:"disable_mfa"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
@@ -133,6 +134,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
adminID := middleware.GetUserID(r)
|
||||||
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -532,6 +533,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.db.MarkMessageRead(messageID, userID, true)
|
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)
|
h.writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,14 +673,55 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
h.handleSend(w, r, "forward")
|
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) {
|
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
|
||||||
userID := middleware.GetUserID(r)
|
userID := middleware.GetUserID(r)
|
||||||
|
|
||||||
var req models.ComposeRequest
|
var req models.ComposeRequest
|
||||||
|
|
||||||
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
account, err := h.db.GetAccount(req.AccountID)
|
account, err := h.db.GetAccount(req.AccountID)
|
||||||
if err != nil || account == nil || account.UserID != userID {
|
if err != nil || account == nil || account.UserID != userID {
|
||||||
@@ -667,6 +729,30 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
return
|
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 {
|
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
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})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
|
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
|
||||||
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
|
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OTPAuthURL builds an otpauth:// URI for QR code generation.
|
// 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 {
|
func OTPAuthURL(issuer, accountName, secret string) string {
|
||||||
v := url.Values{}
|
v := url.Values{}
|
||||||
v.Set("secret", secret)
|
v.Set("secret", secret)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package middleware provides HTTP middleware for GoMail.
|
// Package middleware provides HTTP middleware for GoWebMail.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "time"
|
|||||||
|
|
||||||
// ---- Users ----
|
// ---- Users ----
|
||||||
|
|
||||||
// UserRole controls access level within GoMail.
|
// UserRole controls access level within GoWebMail.
|
||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -12,7 +12,7 @@ const (
|
|||||||
RoleUser UserRole = "user"
|
RoleUser UserRole = "user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a GoMail application user.
|
// User represents a GoWebMail application user.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -213,6 +213,8 @@ type ComposeRequest struct {
|
|||||||
// For reply/forward
|
// For reply/forward
|
||||||
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
||||||
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
||||||
|
// Attachments: populated from multipart/form-data or inline base64
|
||||||
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Search ----
|
// ---- Search ----
|
||||||
|
|||||||
@@ -446,6 +446,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
|||||||
msg.FolderID = dbFolder.ID
|
msg.FolderID = dbFolder.ID
|
||||||
if err := s.db.UpsertMessage(msg); err == nil {
|
if err := s.db.UpsertMessage(msg); err == nil {
|
||||||
newMessages++
|
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)
|
uid := uint32(0)
|
||||||
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
||||||
|
|||||||
@@ -369,10 +369,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
|||||||
|
|
||||||
/* ---- Attachment chips ---- */
|
/* ---- Attachment chips ---- */
|
||||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
.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)}
|
.attachment-chip:hover{background:var(--border2)}
|
||||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
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 ─────────────────────────────────────────── */
|
/* ── Email tag input ─────────────────────────────────────────── */
|
||||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||||
|
|||||||
+24
-4
@@ -1,4 +1,4 @@
|
|||||||
// GoMail Admin SPA
|
// GoWebMail Admin SPA
|
||||||
|
|
||||||
const adminRoutes = {
|
const adminRoutes = {
|
||||||
'/admin': renderUsers,
|
'/admin': renderUsers,
|
||||||
@@ -26,7 +26,7 @@ async function renderUsers() {
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="admin-page-header">
|
<div class="admin-page-header">
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<p>Manage GoMail accounts and permissions.</p>
|
<p>Manage GoWebMail accounts and permissions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<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) { 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; }
|
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||||
el.innerHTML = `<table class="data-table">
|
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 => `
|
<tbody>${r.map(u => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-weight:500">${esc(u.username)}</td>
|
<td style="font-weight:500">${esc(u.username)}</td>
|
||||||
<td style="color:var(--muted)">${esc(u.email)}</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.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.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="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="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>
|
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -139,6 +142,23 @@ async function deleteUser(userId) {
|
|||||||
else toast((r && r.error) || 'Delete failed', 'error');
|
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
|
// Settings
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+168
-46
@@ -1,4 +1,4 @@
|
|||||||
// GoMail app.js — full client
|
// GoWebMail app.js — full client
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
const S = {
|
const S = {
|
||||||
@@ -6,12 +6,8 @@ const S = {
|
|||||||
folders: [], messages: [], totalMessages: 0,
|
folders: [], messages: [], totalMessages: 0,
|
||||||
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
||||||
currentMessage: null, selectedMessageId: null,
|
currentMessage: null, selectedMessageId: null,
|
||||||
searchQuery: '', composeMode: 'new', composeReplyToId: null,
|
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
||||||
remoteWhitelist: new Set(),
|
filterUnread: false, filterAttachment: false,
|
||||||
draftTimer: null, draftDirty: false,
|
|
||||||
composeVisible: false, composeMinimised: false,
|
|
||||||
// Message list filters
|
|
||||||
filterUnread: false,
|
|
||||||
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
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="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||||
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
||||||
${enableAllEntry}
|
${enableAllEntry}
|
||||||
|
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()">✓ Mark all as read</div>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
${moveEntry}
|
${moveEntry}
|
||||||
${emptyEntry}
|
${emptyEntry}
|
||||||
@@ -401,6 +398,15 @@ async function syncFolderNow(folderId) {
|
|||||||
else toast(r?.error||'Sync failed','error');
|
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) {
|
async function toggleFolderSync(folderId) {
|
||||||
const f = S.folders.find(f=>f.id===folderId);
|
const f = S.folders.find(f=>f.id===folderId);
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
@@ -533,18 +539,19 @@ async function loadMessages(append) {
|
|||||||
|
|
||||||
function setFilter(mode) {
|
function setFilter(mode) {
|
||||||
S.filterUnread = (mode === 'unread');
|
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
|
// 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);
|
const el = document.getElementById('fopt-'+k);
|
||||||
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
|
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update button label
|
// Update button label
|
||||||
const labels = {
|
const labels = {
|
||||||
'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date',
|
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
|
||||||
'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
||||||
};
|
};
|
||||||
const labelEl = document.getElementById('filter-label');
|
const labelEl = document.getElementById('filter-label');
|
||||||
if (labelEl) {
|
if (labelEl) {
|
||||||
@@ -569,6 +576,7 @@ function renderMessageList() {
|
|||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
|
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
|
||||||
|
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
|
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));
|
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
|
||||||
|
|
||||||
if (!msgs.length){
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,6 +651,7 @@ function handleMsgClick(e, id, idx) {
|
|||||||
function getFilteredSortedMsgs() {
|
function getFilteredSortedMsgs() {
|
||||||
let msgs=[...S.messages];
|
let msgs=[...S.messages];
|
||||||
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
|
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));
|
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 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));
|
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);
|
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
|
||||||
|
|
||||||
// CSS injected into every iframe — forces white background so dark-themed emails
|
// 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;` +
|
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}` +
|
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
|
||||||
`img{max-width:100%;height:auto}</style>`;
|
`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='';
|
let bodyHtml='';
|
||||||
if (msg.body_html) {
|
if (msg.body_html) {
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
const srcdoc = cssReset + msg.body_html;
|
const srcdoc = cssReset + heightScript + msg.body_html;
|
||||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||||
style="width:100%;border:none;min-height:400px;display:block"
|
style="width:100%;border:none;min-height:200px;display:block"
|
||||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||||
} else {
|
} else {
|
||||||
|
// Block external http(s) images but preserve data: URIs (inline/CID already resolved)
|
||||||
const stripped = msg.body_html
|
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(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi, 'url()')
|
||||||
.replace(/<link[^>]*>/gi, '')
|
.replace(/<link[^>]*>/gi, '')
|
||||||
.replace(/<script[\s\S]*?<\/script>/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="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>
|
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||||
style="width:100%;border:none;min-height:400px;display:block"
|
style="width:100%;border:none;min-height:200px;display:block"
|
||||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'"')}"></iframe>`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||||
@@ -754,10 +779,20 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
if (msg.attachments?.length) {
|
if (msg.attachments?.length) {
|
||||||
attachHtml=`<div class="attachments-bar">
|
attachHtml=`<div class="attachments-bar">
|
||||||
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
|
<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">
|
${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>${esc(a.filename)}</span>
|
||||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||||
</div>`).join('')}
|
</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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +812,7 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
||||||
<button class="action-btn" onclick="openForward()">↪ Forward</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="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="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||||
@@ -786,24 +822,24 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
${attachHtml}
|
${attachHtml}
|
||||||
<div class="detail-body">${bodyHtml}</div>`;
|
<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) {
|
if (msg.body_html) {
|
||||||
const frame=document.getElementById('msg-frame');
|
const frame = document.getElementById('msg-frame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
const sizeFrame = () => {
|
// Clean up any previous listener
|
||||||
try {
|
if (window._frameMsgHandler) window.removeEventListener('message', window._frameMsgHandler);
|
||||||
const h = frame.contentDocument?.documentElement?.scrollHeight;
|
let lastH = 0;
|
||||||
if (h && h > 50) frame.style.height = (h + 20) + 'px';
|
window._frameMsgHandler = (e) => {
|
||||||
} catch(e) {}
|
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
|
||||||
};
|
const h = e.data.h + 24;
|
||||||
frame.onload = () => {
|
if (Math.abs(h - lastH) > 4) { // avoid micro-flicker
|
||||||
sizeFrame();
|
lastH = h;
|
||||||
// Also observe content changes (images loading)
|
frame.style.height = h + 'px';
|
||||||
try {
|
}
|
||||||
const ro = new ResizeObserver(sizeFrame);
|
}
|
||||||
ro.observe(frame.contentDocument.documentElement);
|
|
||||||
} catch(e) {}
|
|
||||||
};
|
};
|
||||||
|
window.addEventListener('message', window._frameMsgHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -946,6 +982,7 @@ function showCompose() {
|
|||||||
d.style.display='flex';
|
d.style.display='flex';
|
||||||
m.style.display='none';
|
m.style.display='none';
|
||||||
S.composeVisible=true; S.composeMinimised=false;
|
S.composeVisible=true; S.composeMinimised=false;
|
||||||
|
initComposeDragDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
function minimizeCompose() {
|
function minimizeCompose() {
|
||||||
@@ -995,13 +1032,30 @@ function openReplyTo(msgId) {
|
|||||||
function openForward() {
|
function openForward() {
|
||||||
if (!S.currentMessage) return;
|
if (!S.currentMessage) return;
|
||||||
const msg=S.currentMessage;
|
const msg=S.currentMessage;
|
||||||
|
S.composeForwardFromId=msg.id;
|
||||||
openCompose({
|
openCompose({
|
||||||
mode:'forward', title:'Forward',
|
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||||
subject:'Fwd: '+(msg.subject||''),
|
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>`,
|
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 ────────────────────────────────────────────────────────
|
// ── Email Tag Input ────────────────────────────────────────────────────────
|
||||||
function initTagField(containerId) {
|
function initTagField(containerId) {
|
||||||
const container=document.getElementById(containerId);
|
const container=document.getElementById(containerId);
|
||||||
@@ -1077,25 +1131,64 @@ function clearDraftAutosave() {
|
|||||||
|
|
||||||
async function saveDraft(silent) {
|
async function saveDraft(silent) {
|
||||||
S.draftDirty=false;
|
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');
|
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 ─────────────────────────────────────────────────────
|
// ── Compose formatting ─────────────────────────────────────────────────────
|
||||||
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
||||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
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 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() {
|
function updateAttachList() {
|
||||||
const el=document.getElementById('compose-attach-list');
|
const el=document.getElementById('compose-attach-list');
|
||||||
if(!composeAttachments.length){el.innerHTML='';return;}
|
if(!composeAttachments.length){el.innerHTML='';return;}
|
||||||
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
||||||
📎 <span>${esc(a.name)}</span>
|
📎 <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>
|
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
|
||||||
</div>`).join('');
|
</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() {
|
async function sendMessage() {
|
||||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||||
const to=getTagValues('compose-to');
|
const to=getTagValues('compose-to');
|
||||||
@@ -1104,15 +1197,44 @@ async function sendMessage() {
|
|||||||
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
||||||
const btn=document.getElementById('send-btn');
|
const btn=document.getElementById('send-btn');
|
||||||
btn.disabled=true; btn.textContent='Sending…';
|
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,
|
account_id:accountId, to,
|
||||||
cc:getTagValues('compose-cc-tags'),
|
cc:getTagValues('compose-cc-tags'),
|
||||||
bcc:getTagValues('compose-bcc-tags'),
|
bcc:getTagValues('compose-bcc-tags'),
|
||||||
subject:document.getElementById('compose-subject').value,
|
subject:document.getElementById('compose-subject').value,
|
||||||
body_text:bodyText, body_html:bodyHTML,
|
body_text:bodyText, body_html:bodyHTML,
|
||||||
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
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';
|
btn.disabled=false; btn.textContent='Send';
|
||||||
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
||||||
else toast(r?.error||'Send failed','error');
|
else toast(r?.error||'Send failed','error');
|
||||||
@@ -1386,7 +1508,7 @@ function updateUnreadBadgeFromPoll(inboxUnread) {
|
|||||||
badge.style.display = 'none';
|
badge.style.display = 'none';
|
||||||
}
|
}
|
||||||
// Update browser tab title
|
// Update browser tab title
|
||||||
const base = 'GoMail';
|
const base = 'GoWebMail';
|
||||||
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
|
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1566,7 @@ function sendOSNotification(msgs) {
|
|||||||
const first = msgs[0];
|
const first = msgs[0];
|
||||||
const title = count === 1
|
const title = count === 1
|
||||||
? (first.from_name || first.from_email || 'New message')
|
? (first.from_name || first.from_email || 'New message')
|
||||||
: `${count} new messages in GoMail`;
|
: `${count} new messages in GoWebMail`;
|
||||||
const body = count === 1
|
const body = count === 1
|
||||||
? (first.subject || '(no subject)')
|
? (first.subject || '(no subject)')
|
||||||
: `${first.from_name || first.from_email}: ${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 ----
|
// ---- API helper ----
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
|
|||||||
@@ -35,5 +35,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js"></script>
|
<script src="/static/js/admin.js?v=15"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
<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-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-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-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-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||||
@@ -308,5 +309,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/app.js?v=12"></script>
|
<script src="/static/js/app.js?v=15"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<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 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}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{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}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user