diff --git a/.gitignore b/.gitignore index cad5a69..3754f62 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ data/*.db data/*.db-shm data/*db-wal data/gowebmail.conf -data/*.txt \ No newline at end of file +data/*.txt +gowebmail-devplan.md +testrun/ \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 7e28632..8ff3cc7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -169,6 +169,8 @@ func main() { api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT") api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE") api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET") @@ -180,6 +182,8 @@ func main() { api.HandleFunc("/send", h.API.SendMessage).Methods("POST") api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST") api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST") + api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST") + api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST") // Folders api.HandleFunc("/folders", h.API.ListFolders).Methods("GET") @@ -189,6 +193,7 @@ func main() { api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET") api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST") + api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE") api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST") api.HandleFunc("/poll", h.API.PollUnread).Methods("GET") @@ -306,7 +311,7 @@ func runDisableMFA(username string) { } func printHelp() { - fmt.Print(`GoMail — Admin CLI + fmt.Print(`GoWebMail — Admin CLI Usage: gowebmail Start the mail server diff --git a/config/config.go b/config/config.go index 3a9d83a..e8b5cb8 100644 --- a/config/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -// Package config loads and persists GoMail configuration from data/gowebmail.conf +// Package config loads and persists GoWebMail configuration from data/gowebmail.conf package config import ( @@ -59,7 +59,7 @@ var allFields = []configField{ defVal: "localhost", comments: []string{ "--- Server ---", - "Public hostname of this GoMail instance (no port, no protocol).", + "Public hostname of this GoWebMail instance (no port, no protocol).", "Examples: localhost | mail.example.com | 192.168.1.10", "Used to build BASE_URL and OAuth redirect URIs automatically.", "Also used in security checks to reject requests with unexpected Host headers.", @@ -92,7 +92,7 @@ var allFields = []configField{ key: "SECURE_COOKIE", defVal: "false", comments: []string{ - "Set to true when GoMail is served over HTTPS (directly or via proxy).", + "Set to true when GoWebMail is served over HTTPS (directly or via proxy).", "Marks session cookies as Secure so browsers only send them over TLS.", }, }, @@ -109,7 +109,7 @@ var allFields = []configField{ comments: []string{ "Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.", "Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,", - "which GoMail uses to determine the real client IP and whether TLS is in use.", + "which GoWebMail uses to determine the real client IP and whether TLS is in use.", " Examples:", " 127.0.0.1 (loopback only — Nginx/Traefik on same host)", " 10.0.0.0/8,172.16.0.0/12 (private networks)", @@ -228,7 +228,7 @@ func Load() (*Config, error) { // get returns env var if set, else file value, else "" get := func(key string) string { - // Only check env vars that are explicitly GoMail-namespaced or well-known. + // Only check env vars that are explicitly GoWebMail-namespaced or well-known. // We deliberately do NOT fall back to generic vars like PORT to avoid // picking up cloud-platform env vars unintentionally. if v := os.Getenv("GOMAIL_" + key); v != "" { @@ -443,7 +443,7 @@ func readConfigFile(path string) (map[string]string, error) { func writeConfigFile(path string, values map[string]string) error { var sb strings.Builder - sb.WriteString("# GoMail Configuration\n") + sb.WriteString("# GoWebMail Configuration\n") sb.WriteString("# =====================\n") sb.WriteString("# Auto-generated and updated on each startup.\n") sb.WriteString("# Edit freely — your values are always preserved.\n") @@ -576,7 +576,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) { } func logStartupInfo(cfg *Config) { - fmt.Printf("GoMail starting:\n") + fmt.Printf("GoWebMail starting:\n") fmt.Printf(" Listen : %s\n", cfg.ListenAddr) fmt.Printf(" Base URL: %s\n", cfg.BaseURL) fmt.Printf(" Hostname: %s\n", cfg.Hostname) diff --git a/internal/db/db.go b/internal/db/db.go index 20014ed..ff9d172 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,4 +1,4 @@ -// Package db provides encrypted SQLite storage for GoMail. +// Package db provides encrypted SQLite storage for GoWebMail. package db import ( @@ -868,7 +868,14 @@ func (d *DB) UpsertMessage(m *models.Message) error { if err != nil { return err } + // LastInsertId returns 0 on conflict in SQLite — always look up the real ID. id, _ := res.LastInsertId() + if id == 0 { + d.sql.QueryRow( + `SELECT id FROM messages WHERE account_id=? AND folder_id=? AND remote_uid=?`, + m.AccountID, m.FolderID, m.RemoteUID, + ).Scan(&id) + } if m.ID == 0 { m.ID = id } @@ -910,6 +917,12 @@ func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) { m.BodyText, _ = d.enc.Decrypt(bodyTextEnc) m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc) + // Load attachment metadata + if m.HasAttachment { + atts, _ := d.GetAttachmentsByMessage(m.ID, userID) + m.Attachments = atts + } + return m, nil } @@ -1564,3 +1577,119 @@ func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]inte } return result, rows.Err() } + +// ---- Attachment metadata ---- + +// SaveAttachmentMeta saves attachment metadata for a message (no binary data). +// Uses INSERT OR REPLACE so a re-sync always refreshes the part path (ContentID). +func (d *DB) SaveAttachmentMeta(messageID int64, atts []models.Attachment) error { + // Delete stale rows first so re-syncs don't leave orphans + d.sql.Exec(`DELETE FROM attachments WHERE message_id=?`, messageID) + for _, a := range atts { + _, err := d.sql.Exec(` + INSERT INTO attachments (message_id, filename, content_type, size, content_id) + VALUES (?,?,?,?,?)`, + messageID, a.Filename, a.ContentType, a.Size, a.ContentID, + ) + if err != nil { + return err + } + } + return nil +} + +// GetAttachmentsByMessage returns attachment metadata for a message. +func (d *DB) GetAttachmentsByMessage(messageID, userID int64) ([]models.Attachment, error) { + rows, err := d.sql.Query(` + SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id + FROM attachments a + JOIN messages m ON m.id=a.message_id + JOIN email_accounts ac ON ac.id=m.account_id + WHERE a.message_id=? AND ac.user_id=?`, messageID, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var result []models.Attachment + for rows.Next() { + var a models.Attachment + rows.Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID) + result = append(result, a) + } + return result, rows.Err() +} + +// GetAttachment returns a single attachment record (ownership via userID check). +func (d *DB) GetAttachment(attachmentID, userID int64) (*models.Attachment, error) { + var a models.Attachment + err := d.sql.QueryRow(` + SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id + FROM attachments a + JOIN messages m ON m.id=a.message_id + JOIN email_accounts ac ON ac.id=m.account_id + WHERE a.id=? AND ac.user_id=?`, attachmentID, userID, + ).Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +// ---- Mark all read ---- + +// MarkFolderAllRead marks every message in a folder as read and enqueues IMAP flag ops. +// Returns the list of (remoteUID, folderPath, accountID) for IMAP ops. +func (d *DB) MarkFolderAllRead(folderID, userID int64) ([]PendingIMAPOp, error) { + // Verify folder ownership + var accountID int64 + var fullPath string + err := d.sql.QueryRow(` + SELECT f.account_id, f.full_path FROM folders f + JOIN email_accounts a ON a.id=f.account_id + WHERE f.id=? AND a.user_id=?`, folderID, userID, + ).Scan(&accountID, &fullPath) + if err != nil { + return nil, fmt.Errorf("folder not found or not owned: %w", err) + } + + // Get all unread messages in folder for IMAP ops + rows, err := d.sql.Query(` + SELECT remote_uid FROM messages WHERE folder_id=? AND is_read=0`, folderID) + if err != nil { + return nil, err + } + defer rows.Close() + var ops []PendingIMAPOp + for rows.Next() { + var uid string + rows.Scan(&uid) + var uidNum uint32 + fmt.Sscanf(uid, "%d", &uidNum) + if uidNum > 0 { + ops = append(ops, PendingIMAPOp{ + AccountID: accountID, OpType: "flag_read", + RemoteUID: uidNum, FolderPath: fullPath, Extra: "1", + }) + } + } + rows.Close() + + // Bulk mark read in DB + _, err = d.sql.Exec(`UPDATE messages SET is_read=1 WHERE folder_id=?`, folderID) + if err != nil { + return nil, err + } + d.UpdateFolderCounts(folderID) + return ops, nil +} + +// ---- Admin MFA disable ---- + +// AdminDisableMFAByID disables MFA for a user by ID (admin action). +func (d *DB) AdminDisableMFAByID(targetUserID int64) error { + _, err := d.sql.Exec(` + UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending='' + WHERE id=?`, targetUserID) + return err +} diff --git a/internal/email/imap.go b/internal/email/imap.go index 8c50d49..d6e8d6b 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -392,8 +392,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g return m, nil } +// ParseMIMEFull is the exported version of parseMIME for use by handlers. +func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) { + return parseMIME(raw) +} + // parseMIME takes a full RFC822 raw message (with headers) and extracts // text/plain, text/html and attachment metadata. +// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs. func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) { msg, err := netmail.ReadMessage(bytes.NewReader(raw)) if err != nil { @@ -405,12 +411,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach ct = "text/plain" } body, _ := io.ReadAll(msg.Body) - text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body) + // cidMap: Content-ID → base64 data URI for inline images + cidMap := make(map[string]string) + text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap) + + // Rewrite cid: references in HTML to data: URIs + if html != "" && len(cidMap) > 0 { + html = rewriteCIDReferences(html, cidMap) + } return } +// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML. +func rewriteCIDReferences(html string, cidMap map[string]string) string { + for cid, dataURI := range cidMap { + // Match both with and without angle brackets + html = strings.ReplaceAll(html, `cid:`+cid, dataURI) + // Some clients wrap CID in angle brackets in the src attribute + html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI) + } + return html +} + // parsePart recursively handles a MIME part. func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) { + return parsePartIndexed(contentType, transferEncoding, body, []int{}) +} + +// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap. +func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return string(body), "", nil + } + mediaType = strings.ToLower(mediaType) + decoded := decodeTransfer(transferEncoding, body) + + switch { + case mediaType == "text/plain": + text = decodeCharset(params["charset"], decoded) + case mediaType == "text/html": + html = decodeCharset(params["charset"], decoded) + case strings.HasPrefix(mediaType, "multipart/"): + boundary := params["boundary"] + if boundary == "" { + return string(decoded), "", nil + } + mr := multipart.NewReader(bytes.NewReader(decoded), boundary) + partIdx := 0 + for { + part, err := mr.NextPart() + if err != nil { + break + } + partIdx++ + childPath := append(append([]int{}, path...), partIdx) + + partBody, _ := io.ReadAll(part) + partCT := part.Header.Get("Content-Type") + if partCT == "" { + partCT = "text/plain" + } + partTE := part.Header.Get("Content-Transfer-Encoding") + disposition := part.Header.Get("Content-Disposition") + contentID := strings.Trim(part.Header.Get("Content-ID"), "<>") + dispType, dispParams, _ := mime.ParseMediaType(disposition) + + filename := dispParams["filename"] + if filename == "" { + filename = part.FileName() + } + if filename != "" { + wd := mime.WordDecoder{} + if dec, e := wd.DecodeHeader(filename); e == nil { + filename = dec + } + } + + partMedia, _, _ := mime.ParseMediaType(partCT) + partMediaLower := strings.ToLower(partMedia) + + // Inline image with Content-ID → embed as data URI for cid: resolution + if contentID != "" && strings.HasPrefix(partMediaLower, "image/") { + decodedPart := decodeTransfer(partTE, partBody) + dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart) + cidMap[contentID] = dataURI + // Don't add as attachment chip — it's inline + continue + } + + isAttachment := strings.EqualFold(dispType, "attachment") || + (filename != "" && !strings.HasPrefix(partMediaLower, "text/") && + !strings.HasPrefix(partMediaLower, "multipart/")) + + if isAttachment { + if filename == "" { + filename = "attachment" + } + mimePartPath := mimePathString(childPath) + attachments = append(attachments, gomailModels.Attachment{ + Filename: filename, + ContentType: partMedia, + Size: int64(len(partBody)), + ContentID: mimePartPath, + }) + continue + } + + t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap) + if text == "" && t != "" { + text = t + } + if html == "" && h != "" { + html = h + } + attachments = append(attachments, atts...) + } + default: + if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil { + filename := mtParams["name"] + if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") { + wd := mime.WordDecoder{} + if dec, e2 := wd.DecodeHeader(filename); e2 == nil { + filename = dec + } + attachments = append(attachments, gomailModels.Attachment{ + Filename: filename, + ContentType: mt, + Size: int64(len(decoded)), + ContentID: mimePathString(path), + }) + } + } + } + return +} + +// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download. +func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) { mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { return string(body), "", nil @@ -430,11 +568,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st return string(decoded), "", nil } mr := multipart.NewReader(bytes.NewReader(decoded), boundary) + partIdx := 0 for { part, err := mr.NextPart() if err != nil { break } + partIdx++ + childPath := append(append([]int{}, path...), partIdx) + partBody, _ := io.ReadAll(part) partCT := part.Header.Get("Content-Type") if partCT == "" { @@ -444,24 +586,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st disposition := part.Header.Get("Content-Disposition") dispType, dispParams, _ := mime.ParseMediaType(disposition) - if strings.EqualFold(dispType, "attachment") { - filename := dispParams["filename"] - if filename == "" { - filename = part.FileName() + // Filename from Content-Disposition or Content-Type params + filename := dispParams["filename"] + if filename == "" { + filename = part.FileName() + } + // Decode RFC 2047 encoded filename + if filename != "" { + wd := mime.WordDecoder{} + if dec, err := wd.DecodeHeader(filename); err == nil { + filename = dec } + } + + partMedia, _, _ := mime.ParseMediaType(partCT) + + isAttachment := strings.EqualFold(dispType, "attachment") || + (filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") && + !strings.HasPrefix(strings.ToLower(partMedia), "multipart/")) + + if isAttachment { if filename == "" { filename = "attachment" } - partMedia, _, _ := mime.ParseMediaType(partCT) + // Build MIME part path string e.g. "1.2" for nested + mimePartPath := mimePathString(childPath) attachments = append(attachments, gomailModels.Attachment{ Filename: filename, ContentType: partMedia, Size: int64(len(partBody)), + ContentID: mimePartPath, // reuse ContentID to store part path }) continue } - t, h, atts := parsePart(partCT, partTE, partBody) + t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath) if text == "" && t != "" { text = t } @@ -471,13 +630,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st attachments = append(attachments, atts...) } default: - // Any other type – treat as attachment if it has a filename - mt, _, _ := mime.ParseMediaType(contentType) - _ = mt + // Any other non-text type with a filename → treat as attachment + if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil { + filename := mtParams["name"] + if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") { + wd := mime.WordDecoder{} + if dec, e2 := wd.DecodeHeader(filename); e2 == nil { + filename = dec + } + attachments = append(attachments, gomailModels.Attachment{ + Filename: filename, + ContentType: mt, + Size: int64(len(decoded)), + ContentID: mimePathString(path), + }) + } + } } return } +// mimePathString converts an int path like [1,2] to "1.2". +func mimePathString(path []int) string { + parts := make([]string, len(path)) + for i, n := range path { + parts[i] = fmt.Sprintf("%d", n) + } + return strings.Join(parts, ".") +} + func decodeTransfer(encoding string, data []byte) []byte { switch strings.ToLower(strings.TrimSpace(encoding)) { case "base64": @@ -763,7 +944,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string { from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress} - boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano()) + altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano()) + mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1) // Use the sender's actual domain for Message-ID so it passes spam filters domain := account.EmailAddress if at := strings.Index(domain, "@"); at >= 0 { @@ -781,24 +963,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n") buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n") buf.WriteString("MIME-Version: 1.0\r\n") - buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n") - buf.WriteString("\r\n") - // Plain text part - buf.WriteString("--" + boundary + "\r\n") - buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n") - buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") - qpw := quotedprintable.NewWriter(buf) + hasAttachments := len(req.Attachments) > 0 + + if hasAttachments { + // Outer multipart/mixed wraps body + attachments + buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n") + buf.WriteString("--" + mixedBoundary + "\r\n") + } + + // Inner multipart/alternative: text/plain + text/html + buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n") + plainText := req.BodyText if plainText == "" && req.BodyHTML != "" { plainText = htmlToPlainText(req.BodyHTML) } + + buf.WriteString("--" + altBoundary + "\r\n") + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qpw := quotedprintable.NewWriter(buf) qpw.Write([]byte(plainText)) qpw.Close() buf.WriteString("\r\n") - // HTML part - buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("--" + altBoundary + "\r\n") buf.WriteString("Content-Type: text/html; charset=utf-8\r\n") buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") qpw2 := quotedprintable.NewWriter(buf) @@ -809,8 +999,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req } qpw2.Close() buf.WriteString("\r\n") + buf.WriteString("--" + altBoundary + "--\r\n") + + if hasAttachments { + for _, att := range req.Attachments { + buf.WriteString("\r\n--" + mixedBoundary + "\r\n") + ct := att.ContentType + if ct == "" { + ct = "application/octet-stream" + } + encodedName := mime.QEncoding.Encode("utf-8", att.Filename) + buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n") + buf.WriteString("Content-Transfer-Encoding: base64\r\n") + buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n") + encoded := base64.StdEncoding.EncodeToString(att.Data) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + buf.WriteString(encoded[i:end] + "\r\n") + } + } + buf.WriteString("\r\n--" + mixedBoundary + "--\r\n") + } - buf.WriteString("--" + boundary + "--\r\n") return msgID } @@ -875,6 +1088,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error { return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg)) } +// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND. +// Returns the folder name that was used (for sync purposes). +func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) { + mailboxes, err := c.ListMailboxes() + if err != nil { + return "", err + } + var draftsName string + for _, mb := range mailboxes { + ft := InferFolderType(mb.Name, mb.Attributes) + if ft == "drafts" { + draftsName = mb.Name + break + } + } + if draftsName == "" { + return "", nil // no Drafts folder, skip silently + } + flags := []string{imap.DraftFlag, imap.SeenFlag} + now := time.Now() + return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg)) +} + +// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full +// raw message and parsing the requested MIME part path. +func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) { + raw, err := c.FetchRawByUID(mailboxName, uid) + if err != nil { + return nil, "", "", fmt.Errorf("fetch raw: %w", err) + } + + msg, err := netmail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return nil, "", "", fmt.Errorf("parse message: %w", err) + } + + ct := msg.Header.Get("Content-Type") + if ct == "" { + ct = "text/plain" + } + body, _ := io.ReadAll(msg.Body) + + data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath) + if err != nil { + return nil, "", "", err + } + return data, filename, contentType, nil +} + +// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2"). +func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) { + return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{}) +} + +func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, "", "", fmt.Errorf("parse content-type: %w", err) + } + decoded := decodeTransfer(transferEncoding, body) + + if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return nil, "", "", fmt.Errorf("no boundary") + } + mr := multipart.NewReader(bytes.NewReader(decoded), boundary) + partIdx := 0 + for { + part, err := mr.NextPart() + if err != nil { + break + } + partIdx++ + childPath := append(append([]int{}, currentPath...), partIdx) + childPathStr := mimePathString(childPath) + + partBody, _ := io.ReadAll(part) + partCT := part.Header.Get("Content-Type") + if partCT == "" { + partCT = "text/plain" + } + partTE := part.Header.Get("Content-Transfer-Encoding") + + if childPathStr == targetPath { + // Found it + disposition := part.Header.Get("Content-Disposition") + _, dispParams, _ := mime.ParseMediaType(disposition) + filename := dispParams["filename"] + if filename == "" { + filename = part.FileName() + } + wd2 := mime.WordDecoder{} + if dec, e := wd2.DecodeHeader(filename); e == nil { + filename = dec + } + partMedia, _, _ := mime.ParseMediaType(partCT) + return decodeTransfer(partTE, partBody), filename, partMedia, nil + } + // Recurse into multipart children + partMedia, _, _ := mime.ParseMediaType(partCT) + if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") { + if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil { + return data, fn, ct2, nil + } + } + } + return nil, "", "", fmt.Errorf("part %s not found", targetPath) + } + + // Leaf node — only matches if path is root (empty) + if targetPath == "" || targetPath == "1" { + return decoded, "", strings.ToLower(mediaType), nil + } + return nil, "", "", fmt.Errorf("part %s not found", targetPath) +} + func htmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 4482a42..96580ed 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -108,9 +108,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { targetID, _ := strconv.ParseInt(vars["id"], 10, 64) var req struct { - IsActive *bool `json:"is_active"` - Password string `json:"password"` - Role string `json:"role"` + IsActive *bool `json:"is_active"` + Password string `json:"password"` + Role string `json:"role"` + DisableMFA bool `json:"disable_mfa"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request") @@ -133,6 +134,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { return } } + if req.DisableMFA { + if err := h.db.AdminDisableMFAByID(targetID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to disable MFA") + return + } + } adminID := middleware.GetUserID(r) h.db.WriteAudit(&adminID, models.AuditUserUpdate, diff --git a/internal/handlers/api.go b/internal/handlers/api.go index d92803d..3dcef6d 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -532,6 +533,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) { return } h.db.MarkMessageRead(messageID, userID, true) + + // Lazy attachment backfill: if has_attachment=true but no rows in attachments table + // (message was synced before attachment parsing was added), fetch from IMAP now and save. + if msg.HasAttachment && len(msg.Attachments) == 0 { + if uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID); iErr == nil && uid != 0 && account != nil { + if c, cErr := email.Connect(context.Background(), account); cErr == nil { + if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil { + _, _, atts := email.ParseMIMEFull(raw) + if len(atts) > 0 { + h.db.SaveAttachmentMeta(messageID, atts) + if fresh, fErr := h.db.GetAttachmentsByMessage(messageID, userID); fErr == nil { + msg.Attachments = fresh + } + } + } + c.Close() + } + } + } + h.writeJSON(w, msg) } @@ -652,13 +673,54 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) { func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) { h.handleSend(w, r, "forward") } +func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) { + h.handleSend(w, r, "forward-attachment") +} func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) { userID := middleware.GetUserID(r) + var req models.ComposeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid request") - return + + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + // Parse multipart form (attachments present) + if err := r.ParseMultipartForm(32 << 20); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + metaStr := r.FormValue("meta") + if err := json.NewDecoder(strings.NewReader(metaStr)).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid meta JSON") + return + } + if r.MultipartForm != nil { + for _, fheaders := range r.MultipartForm.File { + for _, fh := range fheaders { + f, err := fh.Open() + if err != nil { + continue + } + data, _ := io.ReadAll(f) + f.Close() + fileCT := fh.Header.Get("Content-Type") + if fileCT == "" { + fileCT = "application/octet-stream" + } + req.Attachments = append(req.Attachments, models.Attachment{ + Filename: fh.Filename, + ContentType: fileCT, + Size: int64(len(data)), + Data: data, + }) + } + } + } + } else { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } } account, err := h.db.GetAccount(req.AccountID) @@ -667,6 +729,30 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str return } + // Forward-as-attachment: fetch original message as EML and attach it + if mode == "forward-attachment" && req.ForwardFromID > 0 { + origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID) + if origMsg != nil { + uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID) + if iErr == nil && uid != 0 && origAccount != nil { + if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil { + if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil { + safe := sanitizeFilename(origMsg.Subject) + if safe == "" { + safe = "message" + } + req.Attachments = append(req.Attachments, models.Attachment{ + Filename: safe + ".eml", + ContentType: "message/rfc822", + Data: raw, + }) + } + c.Close() + } + } + } + } + if err := email.SendMessageFull(context.Background(), account, &req); err != nil { log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err) h.db.WriteAudit(&userID, models.AuditAppError, @@ -1062,3 +1148,188 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) { } h.writeJSON(w, map[string]interface{}{"messages": msgs}) } + +// ---- Attachment download ---- + +// DownloadAttachment fetches and streams a message attachment from IMAP. +func (h *APIHandler) DownloadAttachment(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + + // Get attachment metadata from DB + attachmentID := pathInt64(r, "att_id") + att, err := h.db.GetAttachment(attachmentID, userID) + if err != nil || att == nil { + h.writeError(w, http.StatusNotFound, "attachment not found") + return + } + + _ = messageID // already verified via GetAttachment ownership check + + // Get IMAP info for the message + uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(att.MessageID, userID) + if iErr != nil || uid == 0 || account == nil { + h.writeError(w, http.StatusNotFound, "message IMAP info not found") + return + } + + c, cErr := email.Connect(context.Background(), account) + if cErr != nil { + h.writeError(w, http.StatusBadGateway, "IMAP connect failed: "+cErr.Error()) + return + } + defer c.Close() + + // att.ContentID stores the MIME part path (set during parse) + mimePartPath := att.ContentID + if mimePartPath == "" { + h.writeError(w, http.StatusNotFound, "attachment part path not stored") + return + } + + data, filename, ct, fetchErr := c.FetchAttachmentRaw(folderPath, uid, mimePartPath) + if fetchErr != nil { + h.writeError(w, http.StatusBadGateway, "fetch failed: "+fetchErr.Error()) + return + } + if filename == "" { + filename = att.Filename + } + if ct == "" { + ct = att.ContentType + } + if ct == "" { + ct = "application/octet-stream" + } + + safe := sanitizeFilename(filename) + // For browser-viewable types, use inline disposition so they open in a new tab. + // For everything else, force download. + disposition := "attachment" + ctLower := strings.ToLower(ct) + if strings.HasPrefix(ctLower, "image/") || + strings.HasPrefix(ctLower, "text/") || + strings.HasPrefix(ctLower, "video/") || + strings.HasPrefix(ctLower, "audio/") || + ctLower == "application/pdf" { + disposition = "inline" + } + w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe)) + w.Header().Set("Content-Type", ct) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.WriteHeader(http.StatusOK) + w.Write(data) +} + +// ListAttachments returns stored attachment metadata for a message. +func (h *APIHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + atts, err := h.db.GetAttachmentsByMessage(messageID, userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list attachments") + return + } + if atts == nil { + atts = []models.Attachment{} + } + // Strip raw data from response, keep metadata only + type attMeta struct { + ID int64 `json:"id"` + MessageID int64 `json:"message_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + } + result := make([]attMeta, len(atts)) + for i, a := range atts { + result[i] = attMeta{a.ID, a.MessageID, a.Filename, a.ContentType, a.Size} + } + h.writeJSON(w, result) +} + +// ---- Mark folder all read ---- + +func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + folderID := pathInt64(r, "id") + + ops, err := h.db.MarkFolderAllRead(folderID, userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Enqueue all flag_read ops and trigger sync + accountIDs := map[int64]bool{} + for _, op := range ops { + h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ + AccountID: op.AccountID, OpType: "flag_read", + RemoteUID: op.RemoteUID, FolderPath: op.FolderPath, Extra: "1", + }) + accountIDs[op.AccountID] = true + } + for accID := range accountIDs { + h.syncer.TriggerAccountSync(accID) + } + + h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)}) +} + +// ---- Save draft (IMAP APPEND to Drafts) ---- + +func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + + var req models.ComposeRequest + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + if err := r.ParseMultipartForm(32 << 20); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid form") + return + } + json.NewDecoder(strings.NewReader(r.FormValue("meta"))).Decode(&req) + } else { + json.NewDecoder(r.Body).Decode(&req) + } + + account, err := h.db.GetAccount(req.AccountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusBadRequest, "account not found") + return + } + + // Build the MIME message bytes + var buf strings.Builder + buf.WriteString("From: " + account.EmailAddress + "\r\n") + if len(req.To) > 0 { + buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n") + } + buf.WriteString("Subject: " + req.Subject + "\r\n") + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") + buf.WriteString(req.BodyHTML) + + raw := []byte(buf.String()) + + // Append to IMAP Drafts in background + go func() { + c, err := email.Connect(context.Background(), account) + if err != nil { + log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err) + return + } + defer c.Close() + draftsFolder, err := c.AppendToDrafts(raw) + if err != nil { + log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err) + return + } + if draftsFolder != "" { + // Trigger a sync of the drafts folder to pick up the saved draft + h.syncer.TriggerAccountSync(account.ID) + } + }() + + h.writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 7d67eb3..24baf3b 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -142,8 +142,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) { return } - qr := mfa.QRCodeURL("GoMail", user.Email, secret) - otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret) + qr := mfa.QRCodeURL("GoWebMail", user.Email, secret) + otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ diff --git a/internal/mfa/totp.go b/internal/mfa/totp.go index 784fa52..5a14718 100644 --- a/internal/mfa/totp.go +++ b/internal/mfa/totp.go @@ -34,7 +34,7 @@ func GenerateSecret() (string, error) { } // OTPAuthURL builds an otpauth:// URI for QR code generation. -// issuer is the application name (e.g. "GoMail"), accountName is the user's email. +// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email. func OTPAuthURL(issuer, accountName, secret string) string { v := url.Values{} v.Set("secret", secret) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index c89ff2c..82399f0 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -1,4 +1,4 @@ -// Package middleware provides HTTP middleware for GoMail. +// Package middleware provides HTTP middleware for GoWebMail. package middleware import ( diff --git a/internal/models/models.go b/internal/models/models.go index f6d0641..7f1fd2a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4,7 +4,7 @@ import "time" // ---- Users ---- -// UserRole controls access level within GoMail. +// UserRole controls access level within GoWebMail. type UserRole string const ( @@ -12,7 +12,7 @@ const ( RoleUser UserRole = "user" ) -// User represents a GoMail application user. +// User represents a GoWebMail application user. type User struct { ID int64 `json:"id"` Email string `json:"email"` @@ -213,6 +213,8 @@ type ComposeRequest struct { // For reply/forward InReplyToID int64 `json:"in_reply_to_id,omitempty"` ForwardFromID int64 `json:"forward_from_id,omitempty"` + // Attachments: populated from multipart/form-data or inline base64 + Attachments []Attachment `json:"attachments,omitempty"` } // ---- Search ---- diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 5961b5f..8a45556 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -446,6 +446,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db msg.FolderID = dbFolder.ID if err := s.db.UpsertMessage(msg); err == nil { newMessages++ + // Save attachment metadata if any (enables download) + if len(msg.Attachments) > 0 && msg.ID > 0 { + _ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments) + } } uid := uint32(0) fmt.Sscanf(msg.RemoteUID, "%d", &uid) diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 8a4e57b..2aaed33 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -369,10 +369,13 @@ body.admin-page{overflow:auto;background:var(--bg)} /* ---- Attachment chips ---- */ .attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px; - background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer} + background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer; + text-decoration:none;color:inherit} .attachment-chip:hover{background:var(--border2)} .attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px; padding:8px 14px;border-bottom:1px solid var(--border)} +/* Drag-and-drop compose overlay */ +.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;} /* ── Email tag input ─────────────────────────────────────────── */ .tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1; diff --git a/web/static/js/admin.js b/web/static/js/admin.js index c3170aa..d7249c9 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -1,4 +1,4 @@ -// GoMail Admin SPA +// GoWebMail Admin SPA const adminRoutes = { '/admin': renderUsers, @@ -26,7 +26,7 @@ async function renderUsers() { el.innerHTML = `

Users

-

Manage GoMail accounts and permissions.

+

Manage GoWebMail accounts and permissions.

@@ -67,16 +67,19 @@ async function loadUsersTable() { if (!r) { el.innerHTML = '

Failed to load users

'; return; } if (!r.length) { el.innerHTML = '

No users yet.

'; return; } el.innerHTML = ` - + ${r.map(u => ` + - `).join('')} @@ -139,6 +142,23 @@ async function deleteUser(userId) { else toast((r && r.error) || 'Delete failed', 'error'); } +async function disableMFA(userId, username) { + if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return; + const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true }); + if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); } + else toast((r && r.error) || 'Failed to disable MFA', 'error'); +} + +function openResetPassword(userId, username) { + const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`); + if (!pw) return; + if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; } + api('PUT', '/admin/users/' + userId, { password: pw }).then(r => { + if (r && r.ok) toast('Password reset for ' + username, 'success'); + else toast((r && r.error) || 'Failed to reset password', 'error'); + }); +} + // ============================================================ // Settings // ============================================================ diff --git a/web/static/js/app.js b/web/static/js/app.js index 04faef0..c1ebaa4 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,4 +1,4 @@ -// GoMail app.js — full client +// GoWebMail app.js — full client // ── State ────────────────────────────────────────────────────────────────── const S = { @@ -6,12 +6,8 @@ const S = { folders: [], messages: [], totalMessages: 0, currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox', currentMessage: null, selectedMessageId: null, - searchQuery: '', composeMode: 'new', composeReplyToId: null, - remoteWhitelist: new Set(), - draftTimer: null, draftDirty: false, - composeVisible: false, composeMinimised: false, - // Message list filters - filterUnread: false, + searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null, + filterUnread: false, filterAttachment: false, sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc' }; @@ -387,6 +383,7 @@ function showFolderMenu(e, folderId) {
↻ Sync this folder
${syncLabel}
${enableAllEntry} +
✓ Mark all as read
${moveEntry} ${emptyEntry} @@ -401,6 +398,15 @@ async function syncFolderNow(folderId) { else toast(r?.error||'Sync failed','error'); } +async function markFolderAllRead(folderId) { + const r=await api('POST','/folders/'+folderId+'/mark-all-read'); + if(r?.ok){ + toast(`Marked ${r.marked||0} message(s) as read`,'success'); + loadFolders(); + loadMessages(); + } else toast(r?.error||'Failed','error'); +} + async function toggleFolderSync(folderId) { const f = S.folders.find(f=>f.id===folderId); if (!f) return; @@ -533,18 +539,19 @@ async function loadMessages(append) { function setFilter(mode) { S.filterUnread = (mode === 'unread'); - S.sortOrder = (mode === 'unread' || mode === 'default') ? 'date-desc' : mode; + S.filterAttachment = (mode === 'attachment'); + S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode; // Update checkmarks - ['default','unread','date-desc','date-asc','size-desc'].forEach(k => { + ['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => { const el = document.getElementById('fopt-'+k); if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2); }); // Update button label const labels = { - 'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date', - 'date-asc':'↑ Date', 'size-desc':'↓ Size' + 'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment', + 'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size' }; const labelEl = document.getElementById('filter-label'); if (labelEl) { @@ -569,6 +576,7 @@ function renderMessageList() { // Filter if (S.filterUnread) msgs = msgs.filter(m => !m.is_read); + if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment); // Sort if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date)); @@ -576,7 +584,8 @@ function renderMessageList() { else msgs.sort((a,b) => new Date(b.date)-new Date(a.date)); if (!msgs.length){ - list.innerHTML=`

${S.filterUnread?'No unread messages':'No messages'}

`; + const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages'; + list.innerHTML=`

${emptyMsg}

`; return; } @@ -642,6 +651,7 @@ function handleMsgClick(e, id, idx) { function getFilteredSortedMsgs() { let msgs=[...S.messages]; if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read); + if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment); if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date)); else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0)); else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date)); @@ -717,21 +727,36 @@ function renderMessageDetail(msg, showRemoteContent) { const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email); // CSS injected into every iframe — forces white background so dark-themed emails - // don't inherit our app dark theme. allow-scripts is needed for some email onclick events. + // don't inherit our app dark theme. const cssReset = ``; + // Script injected into srcdoc to report content height via postMessage. + // Required because removing allow-same-origin means contentDocument is null from parent. + const heightScript = ` + {{end}} \ No newline at end of file diff --git a/web/templates/app.html b/web/templates/app.html index 950c2ae..f64ff6c 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -58,13 +58,14 @@ Filter @@ -308,5 +309,5 @@ {{end}} {{define "scripts"}} - + {{end}} diff --git a/web/templates/base.html b/web/templates/base.html index dbf9a37..f0b2586 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -5,12 +5,12 @@ {{block "title" .}}GoWebMail{{end}} - + {{block "head_extra" .}}{{end}} {{block "body" .}}{{end}} - + {{block "scripts" .}}{{end}}
UsernameEmailRoleStatusLast Login
UsernameEmailRoleStatusMFALast Login
${esc(u.username)} ${esc(u.email)} ${u.role} ${u.is_active?'Active':'Disabled'}${u.mfa_enabled?'On':'Off'} ${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} + + + ${u.mfa_enabled?``:''}