Compare commits

...

3 Commits

Author SHA1 Message Date
ghostersk
948e111cc6 fix image and link rendering 2026-03-08 12:14:58 +00:00
ghostersk
ac43075d62 fix attachments 2026-03-08 11:48:27 +00:00
ghostersk
964a345657 fix embeding issue and sqlite db new database string 2026-03-08 06:51:04 +00:00
22 changed files with 1079 additions and 129 deletions

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@ data/*.db
data/*.db-shm
data/*db-wal
data/gowebmail.conf
data/*.txt
data/*.txt
gowebmail-devplan.md
testrun/

View File

@@ -31,6 +31,8 @@ A self-hosted, encrypted web email client written entirely in Go. Supports Gmail
# 1. Clone / copy the project
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
go build -o gowebmail ./cmd/server
# if you want smaller exe ( strip down debuginformation):
go build -ldflags="-s -w" -o gowebmail ./cmd/server
./gowebmail
```

View File

@@ -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

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
// Package db provides encrypted SQLite storage for GoMail.
// Package db provides encrypted SQLite storage for GoWebMail.
package db
import (
@@ -27,7 +27,8 @@ func New(path string, encKey []byte) (*DB, error) {
}
// Enable WAL mode and foreign keys for performance and integrity
dsn := fmt.Sprintf("%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", path)
// sqlite file path must start with `file:` for package mattn/go-sqlite3
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", path)
sqlDB, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
@@ -867,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
}
@@ -909,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
}
@@ -1563,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
}

View File

@@ -392,8 +392,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
return m, nil
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
@@ -405,12 +411,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
// cidMap: Content-ID → base64 data URI for inline images
cidMap := make(map[string]string)
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
// Rewrite cid: references in HTML to data: URIs
if html != "" && len(cidMap) > 0 {
html = rewriteCIDReferences(html, cidMap)
}
return
}
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
func rewriteCIDReferences(html string, cidMap map[string]string) string {
for cid, dataURI := range cidMap {
// Match both with and without angle brackets
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
// Some clients wrap CID in angle brackets in the src attribute
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
}
return html
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
return parsePartIndexed(contentType, transferEncoding, body, []int{})
}
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
}
mediaType = strings.ToLower(mediaType)
decoded := decodeTransfer(transferEncoding, body)
switch {
case mediaType == "text/plain":
text = decodeCharset(params["charset"], decoded)
case mediaType == "text/html":
html = decodeCharset(params["charset"], decoded)
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
disposition := part.Header.Get("Content-Disposition")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
if filename != "" {
wd := mime.WordDecoder{}
if dec, e := wd.DecodeHeader(filename); e == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
partMediaLower := strings.ToLower(partMedia)
// Inline image with Content-ID → embed as data URI for cid: resolution
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
decodedPart := decodeTransfer(partTE, partBody)
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
cidMap[contentID] = dataURI
// Don't add as attachment chip — it's inline
continue
}
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
!strings.HasPrefix(partMediaLower, "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath,
})
continue
}
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
@@ -430,11 +568,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
@@ -444,24 +586,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
disposition := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
if strings.EqualFold(dispType, "attachment") {
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
// Filename from Content-Disposition or Content-Type params
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
// Decode RFC 2047 encoded filename
if filename != "" {
wd := mime.WordDecoder{}
if dec, err := wd.DecodeHeader(filename); err == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
partMedia, _, _ := mime.ParseMediaType(partCT)
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePart(partCT, partTE, partBody)
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
@@ -471,13 +630,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
attachments = append(attachments, atts...)
}
default:
// Any other type treat as attachment if it has a filename
mt, _, _ := mime.ParseMediaType(contentType)
_ = mt
// Any other non-text type with a filename → treat as attachment
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// mimePathString converts an int path like [1,2] to "1.2".
func mimePathString(path []int) string {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
return strings.Join(parts, ".")
}
func decodeTransfer(encoding string, data []byte) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
@@ -763,7 +944,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// Use the sender's actual domain for Message-ID so it passes spam filters
domain := account.EmailAddress
if at := strings.Index(domain, "@"); at >= 0 {
@@ -781,24 +963,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
buf.WriteString("\r\n")
// Plain text part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
hasAttachments := len(req.Attachments) > 0
if hasAttachments {
// Outer multipart/mixed wraps body + attachments
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
buf.WriteString("--" + mixedBoundary + "\r\n")
}
// Inner multipart/alternative: text/plain + text/html
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
// HTML part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw2 := quotedprintable.NewWriter(buf)
@@ -809,8 +999,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
}
qpw2.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "--\r\n")
if hasAttachments {
for _, att := range req.Attachments {
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
buf.WriteString(encoded[i:end] + "\r\n")
}
}
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
}
buf.WriteString("--" + boundary + "--\r\n")
return msgID
}
@@ -875,6 +1088,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
// Returns the folder name that was used (for sync purposes).
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
var draftsName string
for _, mb := range mailboxes {
ft := InferFolderType(mb.Name, mb.Attributes)
if ft == "drafts" {
draftsName = mb.Name
break
}
}
if draftsName == "" {
return "", nil // no Drafts folder, skip silently
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
}
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
// raw message and parsing the requested MIME part path.
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
raw, err := c.FetchRawByUID(mailboxName, uid)
if err != nil {
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
}
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, "", "", fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
if err != nil {
return nil, "", "", err
}
return data, filename, contentType, nil
}
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
}
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
}
decoded := decodeTransfer(transferEncoding, body)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, "", "", fmt.Errorf("no boundary")
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, currentPath...), partIdx)
childPathStr := mimePathString(childPath)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
if childPathStr == targetPath {
// Found it
disposition := part.Header.Get("Content-Disposition")
_, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
wd2 := mime.WordDecoder{}
if dec, e := wd2.DecodeHeader(filename); e == nil {
filename = dec
}
partMedia, _, _ := mime.ParseMediaType(partCT)
return decodeTransfer(partTE, partBody), filename, partMedia, nil
}
// Recurse into multipart children
partMedia, _, _ := mime.ParseMediaType(partCT)
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
return data, fn, ct2, nil
}
}
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
// Leaf node — only matches if path is root (empty)
if targetPath == "" || targetPath == "1" {
return decoded, "", strings.ToLower(mediaType), nil
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")

View File

@@ -108,9 +108,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
DisableMFA bool `json:"disable_mfa"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -133,6 +134,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.DisableMFA {
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -532,6 +533,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
h.db.MarkMessageRead(messageID, userID, true)
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
if msg.HasAttachment && len(msg.Attachments) == 0 {
if uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID); iErr == nil && uid != 0 && account != nil {
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
_, _, atts := email.ParseMIMEFull(raw)
if len(atts) > 0 {
h.db.SaveAttachmentMeta(messageID, atts)
if fresh, fErr := h.db.GetAttachmentsByMessage(messageID, userID); fErr == nil {
msg.Attachments = fresh
}
}
}
c.Close()
}
}
}
h.writeJSON(w, msg)
}
@@ -652,13 +673,54 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward")
}
func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward-attachment")
}
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Parse multipart form (attachments present)
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
metaStr := r.FormValue("meta")
if err := json.NewDecoder(strings.NewReader(metaStr)).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid meta JSON")
return
}
if r.MultipartForm != nil {
for _, fheaders := range r.MultipartForm.File {
for _, fh := range fheaders {
f, err := fh.Open()
if err != nil {
continue
}
data, _ := io.ReadAll(f)
f.Close()
fileCT := fh.Header.Get("Content-Type")
if fileCT == "" {
fileCT = "application/octet-stream"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: fh.Filename,
ContentType: fileCT,
Size: int64(len(data)),
Data: data,
})
}
}
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
}
account, err := h.db.GetAccount(req.AccountID)
@@ -667,6 +729,30 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
return
}
// Forward-as-attachment: fetch original message as EML and attach it
if mode == "forward-attachment" && req.ForwardFromID > 0 {
origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID)
if origMsg != nil {
uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID)
if iErr == nil && uid != 0 && origAccount != nil {
if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
safe := sanitizeFilename(origMsg.Subject)
if safe == "" {
safe = "message"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: safe + ".eml",
ContentType: "message/rfc822",
Data: raw,
})
}
c.Close()
}
}
}
}
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
h.db.WriteAudit(&userID, models.AuditAppError,
@@ -1062,3 +1148,188 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
}
h.writeJSON(w, map[string]interface{}{"messages": msgs})
}
// ---- Attachment download ----
// DownloadAttachment fetches and streams a message attachment from IMAP.
func (h *APIHandler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
// Get attachment metadata from DB
attachmentID := pathInt64(r, "att_id")
att, err := h.db.GetAttachment(attachmentID, userID)
if err != nil || att == nil {
h.writeError(w, http.StatusNotFound, "attachment not found")
return
}
_ = messageID // already verified via GetAttachment ownership check
// Get IMAP info for the message
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(att.MessageID, userID)
if iErr != nil || uid == 0 || account == nil {
h.writeError(w, http.StatusNotFound, "message IMAP info not found")
return
}
c, cErr := email.Connect(context.Background(), account)
if cErr != nil {
h.writeError(w, http.StatusBadGateway, "IMAP connect failed: "+cErr.Error())
return
}
defer c.Close()
// att.ContentID stores the MIME part path (set during parse)
mimePartPath := att.ContentID
if mimePartPath == "" {
h.writeError(w, http.StatusNotFound, "attachment part path not stored")
return
}
data, filename, ct, fetchErr := c.FetchAttachmentRaw(folderPath, uid, mimePartPath)
if fetchErr != nil {
h.writeError(w, http.StatusBadGateway, "fetch failed: "+fetchErr.Error())
return
}
if filename == "" {
filename = att.Filename
}
if ct == "" {
ct = att.ContentType
}
if ct == "" {
ct = "application/octet-stream"
}
safe := sanitizeFilename(filename)
// For browser-viewable types, use inline disposition so they open in a new tab.
// For everything else, force download.
disposition := "attachment"
ctLower := strings.ToLower(ct)
if strings.HasPrefix(ctLower, "image/") ||
strings.HasPrefix(ctLower, "text/") ||
strings.HasPrefix(ctLower, "video/") ||
strings.HasPrefix(ctLower, "audio/") ||
ctLower == "application/pdf" {
disposition = "inline"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe))
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// ListAttachments returns stored attachment metadata for a message.
func (h *APIHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
atts, err := h.db.GetAttachmentsByMessage(messageID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list attachments")
return
}
if atts == nil {
atts = []models.Attachment{}
}
// Strip raw data from response, keep metadata only
type attMeta struct {
ID int64 `json:"id"`
MessageID int64 `json:"message_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
result := make([]attMeta, len(atts))
for i, a := range atts {
result[i] = attMeta{a.ID, a.MessageID, a.Filename, a.ContentType, a.Size}
}
h.writeJSON(w, result)
}
// ---- Mark folder all read ----
func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
ops, err := h.db.MarkFolderAllRead(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Enqueue all flag_read ops and trigger sync
accountIDs := map[int64]bool{}
for _, op := range ops {
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: op.AccountID, OpType: "flag_read",
RemoteUID: op.RemoteUID, FolderPath: op.FolderPath, Extra: "1",
})
accountIDs[op.AccountID] = true
}
for accID := range accountIDs {
h.syncer.TriggerAccountSync(accID)
}
h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)})
}
// ---- Save draft (IMAP APPEND to Drafts) ----
func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
json.NewDecoder(strings.NewReader(r.FormValue("meta"))).Decode(&req)
} else {
json.NewDecoder(r.Body).Decode(&req)
}
account, err := h.db.GetAccount(req.AccountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusBadRequest, "account not found")
return
}
// Build the MIME message bytes
var buf strings.Builder
buf.WriteString("From: " + account.EmailAddress + "\r\n")
if len(req.To) > 0 {
buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n")
}
buf.WriteString("Subject: " + req.Subject + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(req.BodyHTML)
raw := []byte(buf.String())
// Append to IMAP Drafts in background
go func() {
c, err := email.Connect(context.Background(), account)
if err != nil {
log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err)
return
}
defer c.Close()
draftsFolder, err := c.AppendToDrafts(raw)
if err != nil {
log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err)
return
}
if draftsFolder != "" {
// Trigger a sync of the drafts folder to pick up the saved draft
h.syncer.TriggerAccountSync(account.ID)
}
}()
h.writeJSON(w, map[string]bool{"ok": true})
}

View File

@@ -142,8 +142,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{

View File

@@ -4,9 +4,11 @@ import (
"bytes"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"path/filepath"
"github.com/ghostersk/gowebmail"
)
// Renderer holds one compiled *template.Template per page name.
@@ -16,11 +18,6 @@ type Renderer struct {
templates map[string]*template.Template
}
const (
tmplBase = "web/templates/base.html"
tmplDir = "web/templates"
)
// NewRenderer parses every page template paired with the base layout.
// Call once at startup; fails fast if any template has a syntax error.
func NewRenderer() (*Renderer, error) {
@@ -30,19 +27,23 @@ func NewRenderer() (*Renderer, error) {
"mfa.html",
"admin.html",
}
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
if err != nil {
log.Fatalf("embed templates fs: %v", err)
}
r := &Renderer{templates: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
pagePath := filepath.Join(tmplDir, page)
// New instance per page — base FIRST, then the page file.
// This means the page's {{define}} blocks override the base's {{block}} defaults
// without any other page's definitions being present in the same pool.
t, err := template.New("base").ParseFiles(tmplBase, pagePath)
t, err := template.ParseFS(templateFS, "base.html", page)
if err != nil {
return nil, fmt.Errorf("renderer: parse %s: %w", page, err)
}
name := page[:len(page)-5] // strip ".html"
name := page[:len(page)-5]
r.templates[name] = t
log.Printf("renderer: loaded template %q", name)
}

View File

@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
}
// OTPAuthURL builds an otpauth:// URI for QR code generation.
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
func OTPAuthURL(issuer, accountName, secret string) string {
v := url.Values{}
v.Set("secret", secret)

View File

@@ -1,4 +1,4 @@
// Package middleware provides HTTP middleware for GoMail.
// Package middleware provides HTTP middleware for GoWebMail.
package middleware
import (
@@ -47,7 +47,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
next.ServeHTTP(w, r)
})
}

View File

@@ -4,7 +4,7 @@ import "time"
// ---- Users ----
// UserRole controls access level within GoMail.
// UserRole controls access level within GoWebMail.
type UserRole string
const (
@@ -12,7 +12,7 @@ const (
RoleUser UserRole = "user"
)
// User represents a GoMail application user.
// User represents a GoWebMail application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
@@ -213,6 +213,8 @@ type ComposeRequest struct {
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
}
// ---- Search ----

View File

@@ -446,6 +446,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
msg.FolderID = dbFolder.ID
if err := s.db.UpsertMessage(msg); err == nil {
newMessages++
// Save attachment metadata if any (enables download)
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
// GoMail Admin SPA
// GoWebMail Admin SPA
const adminRoutes = {
'/admin': renderUsers,
@@ -26,7 +26,7 @@ async function renderUsers() {
el.innerHTML = `
<div class="admin-page-header">
<h1>Users</h1>
<p>Manage GoMail accounts and permissions.</p>
<p>Manage GoWebMail accounts and permissions.</p>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
@@ -67,16 +67,19 @@ async function loadUsersTable() {
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
el.innerHTML = `<table class="data-table">
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
<tbody>${r.map(u => `
<tr>
<td style="font-weight:500">${esc(u.username)}</td>
<td style="color:var(--muted)">${esc(u.email)}</td>
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
<td style="display:flex;gap:6px;justify-content:flex-end">
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
</td>
</tr>`).join('')}
@@ -139,6 +142,23 @@ async function deleteUser(userId) {
else toast((r && r.error) || 'Delete failed', 'error');
}
async function disableMFA(userId, username) {
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
else toast((r && r.error) || 'Failed to disable MFA', 'error');
}
function openResetPassword(userId, username) {
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
if (!pw) return;
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
if (r && r.ok) toast('Password reset for ' + username, 'success');
else toast((r && r.error) || 'Failed to reset password', 'error');
});
}
// ============================================================
// Settings
// ============================================================

View File

@@ -1,4 +1,4 @@
// GoMail app.js — full client
// GoWebMail app.js — full client
// ── State ──────────────────────────────────────────────────────────────────
const S = {
@@ -6,12 +6,8 @@ const S = {
folders: [], messages: [], totalMessages: 0,
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
currentMessage: null, selectedMessageId: null,
searchQuery: '', composeMode: 'new', composeReplyToId: null,
remoteWhitelist: new Set(),
draftTimer: null, draftDirty: false,
composeVisible: false, composeMinimised: false,
// Message list filters
filterUnread: false,
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
};
@@ -387,6 +383,7 @@ function showFolderMenu(e, folderId) {
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
${enableAllEntry}
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()">✓ Mark all as read</div>
<div class="ctx-sep"></div>
${moveEntry}
${emptyEntry}
@@ -401,6 +398,15 @@ async function syncFolderNow(folderId) {
else toast(r?.error||'Sync failed','error');
}
async function markFolderAllRead(folderId) {
const r=await api('POST','/folders/'+folderId+'/mark-all-read');
if(r?.ok){
toast(`Marked ${r.marked||0} message(s) as read`,'success');
loadFolders();
loadMessages();
} else toast(r?.error||'Failed','error');
}
async function toggleFolderSync(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
@@ -533,18 +539,19 @@ async function loadMessages(append) {
function setFilter(mode) {
S.filterUnread = (mode === 'unread');
S.sortOrder = (mode === 'unread' || mode === 'default') ? 'date-desc' : mode;
S.filterAttachment = (mode === 'attachment');
S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode;
// Update checkmarks
['default','unread','date-desc','date-asc','size-desc'].forEach(k => {
['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => {
const el = document.getElementById('fopt-'+k);
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
});
// Update button label
const labels = {
'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date',
'date-asc':'↑ Date', 'size-desc':'↓ Size'
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
};
const labelEl = document.getElementById('filter-label');
if (labelEl) {
@@ -569,6 +576,7 @@ function renderMessageList() {
// Filter
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
// Sort
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
@@ -576,7 +584,8 @@ function renderMessageList() {
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
if (!msgs.length){
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${S.filterUnread?'No unread messages':'No messages'}</p></div>`;
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${emptyMsg}</p></div>`;
return;
}
@@ -642,6 +651,7 @@ function handleMsgClick(e, id, idx) {
function getFilteredSortedMsgs() {
let msgs=[...S.messages];
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment);
if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date));
else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0));
else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date));
@@ -712,39 +722,86 @@ async function openMessage(id) {
}
}
// ── External link navigation whitelist ───────────────────────────────────────
// Persisted in sessionStorage so it resets on tab close (safety default).
const _extNavOk = new Set(JSON.parse(sessionStorage.getItem('extNavOk')||'[]'));
function _saveExtNavOk(){ sessionStorage.setItem('extNavOk', JSON.stringify([..._extNavOk])); }
function confirmExternalNav(url) {
const origin = (() => { try { return new URL(url).origin; } catch(e){ return url; } })();
if (_extNavOk.has(origin)) { window.open(url,'_blank','noopener,noreferrer'); return; }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay open';
overlay.innerHTML = `<div class="modal" style="max-width:480px">
<h2 style="margin:0 0 12px">Open external link?</h2>
<div style="word-break:break-all;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;font-family:monospace;margin-bottom:16px;color:var(--text2)">${esc(url)}</div>
<p style="margin:0 0 20px;font-size:13px;color:var(--text2)">This link was in a received email. Opening it will take you to an external website.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-primary" id="enav-once">Open once</button>
<button class="btn-primary" id="enav-always" style="background:var(--accent2,#2a7)">Always allow ${esc(origin)}</button>
<button class="action-btn" id="enav-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#enav-once').onclick = () => { overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
overlay.querySelector('#enav-always').onclick = () => { _extNavOk.add(origin); _saveExtNavOk(); overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
overlay.querySelector('#enav-cancel').onclick = () => overlay.remove();
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
}
function renderMessageDetail(msg, showRemoteContent) {
const detail=document.getElementById('message-detail');
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.
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}</style>`;
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
// Injected into srcdoc: reports height + intercepts all link clicks → postMessage to 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});
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
let bodyHtml='';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (allowed) {
const srcdoc = cssReset + msg.body_html;
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
style="width:100%;border:none;min-height:400px;display:block"
const srcdoc = cssReset + heightScript + html;
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${srcdoc.replace(/"/g,'&quot;')}"></iframe>`;
} else {
const stripped = msg.body_html
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])[^'"]*\2/gi, '<img$1src="" data-blocked="1"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi, 'url()')
.replace(/<link[^>]*>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const srcdoc = cssReset + stripped;
const stripped = stripRemoteImages(html);
bodyHtml=`<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
</div>
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
style="width:100%;border:none;min-height:400px;display:block"
srcdoc="${srcdoc.replace(/"/g,'&quot;')}"></iframe>`;
<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'&quot;')}"></iframe>`;
}
} else {
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
@@ -752,15 +809,21 @@ function renderMessageDetail(msg, showRemoteContent) {
let attachHtml='';
if (msg.attachments?.length) {
attachHtml=`<div class="attachments-bar">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
${msg.attachments.map(a=>`<div class="attachment-chip">
📎 <span>${esc(a.filename)}</span>
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
</div>`).join('')}
</div>`;
const chips = msg.attachments.map(a=>{
const url=`/api/messages/${msg.id}/attachments/${a.id}`;
const ct=a.content_type||'';
const viewable=/^(image\/|text\/|application\/pdf$|video\/|audio\/)/.test(ct);
const icon=ct.startsWith('image/')?'🖼':ct==='application/pdf'?'📄':ct.startsWith('video/')?'🎬':ct.startsWith('audio/')?'🎵':'📎';
if(viewable){
return `<a class="attachment-chip" href="${url}" target="_blank" rel="noopener" title="Open ${esc(a.filename)}">${icon} <span>${esc(a.filename)}</span><span style="color:var(--muted);font-size:10px"> ${formatSize(a.size)}</span></a>`;
}
return `<a class="attachment-chip" href="${url}" download="${esc(a.filename)}" title="Download ${esc(a.filename)}">${icon} <span>${esc(a.filename)}</span><span style="color:var(--muted);font-size:10px"> ${formatSize(a.size)}</span></a>`;
}).join('');
const dlAll=`<button class="attachment-chip" onclick="downloadAllAttachments(${msg.id})" style="cursor:pointer;border:1px solid var(--border)">⬇ <span>Download all</span></button>`;
attachHtml=`<div class="attachments-bar">${dlAll}${chips}</div>`;
}
detail.innerHTML=`
<div class="detail-header">
<div class="detail-subject">${esc(msg.subject||'(no subject)')}</div>
@@ -777,6 +840,7 @@ function renderMessageDetail(msg, showRemoteContent) {
<div class="detail-actions">
<button class="action-btn" onclick="openReply()">↩ Reply</button>
<button class="action-btn" onclick="openForward()">↪ Forward</button>
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment">↪ Fwd as Attachment</button>
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
@@ -786,28 +850,50 @@ function renderMessageDetail(msg, showRemoteContent) {
${attachHtml}
<div class="detail-body">${bodyHtml}</div>`;
// Auto-size iframe to content height using ResizeObserver
// Auto-size iframe via postMessage from injected height-reporting script.
// We cannot use contentDocument (null without allow-same-origin in sandbox).
if (msg.body_html) {
const frame=document.getElementById('msg-frame');
const frame = document.getElementById('msg-frame');
if (frame) {
const sizeFrame = () => {
try {
const h = frame.contentDocument?.documentElement?.scrollHeight;
if (h && h > 50) frame.style.height = (h + 20) + 'px';
} catch(e) {}
};
frame.onload = () => {
sizeFrame();
// Also observe content changes (images loading)
try {
const ro = new ResizeObserver(sizeFrame);
ro.observe(frame.contentDocument.documentElement);
} catch(e) {}
// Clean up any previous listener
if (window._frameMsgHandler) window.removeEventListener('message', window._frameMsgHandler);
let lastH = 0;
window._frameMsgHandler = (e) => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const h = e.data.h + 24;
if (Math.abs(h - lastH) > 4) {
lastH = h;
frame.style.height = h + 'px';
}
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
confirmExternalNav(e.data.url);
}
};
window.addEventListener('message', window._frameMsgHandler);
}
}
}
// Download all attachments for a message sequentially
async function downloadAllAttachments(msgId) {
const msg = S.currentMessage;
if (!msg?.attachments?.length) return;
for (const a of msg.attachments) {
const url = `/api/messages/${msgId}/attachments/${a.id}`;
try {
const resp = await fetch(url);
const blob = await resp.blob();
const tmp = document.createElement('a');
tmp.href = URL.createObjectURL(blob);
tmp.download = a.filename || 'attachment';
tmp.click();
URL.revokeObjectURL(tmp.href);
// Small delay to avoid browser throttling sequential downloads
await new Promise(r => setTimeout(r, 400));
} catch(e) { toast('Failed to download '+esc(a.filename),'error'); }
}
}
async function whitelistSender(sender) {
const r=await api('POST','/remote-content-whitelist',{sender});
if (r?.ok){S.remoteWhitelist.add(sender);toast('Always allowing content from '+sender,'success');if(S.currentMessage)renderMessageDetail(S.currentMessage,false);}
@@ -946,6 +1032,7 @@ function showCompose() {
d.style.display='flex';
m.style.display='none';
S.composeVisible=true; S.composeMinimised=false;
initComposeDragDrop();
}
function minimizeCompose() {
@@ -995,13 +1082,30 @@ function openReplyTo(msgId) {
function openForward() {
if (!S.currentMessage) return;
const msg=S.currentMessage;
S.composeForwardFromId=msg.id;
openCompose({
mode:'forward', title:'Forward',
mode:'forward', forwardId:msg.id, title:'Forward',
subject:'Fwd: '+(msg.subject||''),
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
});
}
function openForwardAsAttachment() {
if (!S.currentMessage) return;
const msg=S.currentMessage;
S.composeForwardFromId=msg.id;
openCompose({
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
subject:'Fwd: '+(msg.subject||''),
body:'',
});
// Add a visual placeholder chip (the actual EML is fetched server-side)
composeAttachments=[{name: sanitizeSubject(msg.subject||'message')+'.eml', size:0, isForward:true}];
updateAttachList();
}
function sanitizeSubject(s){return s.replace(/[/\\:*?"<>|]/g,'_').slice(0,60)||'message';}
// ── Email Tag Input ────────────────────────────────────────────────────────
function initTagField(containerId) {
const container=document.getElementById(containerId);
@@ -1077,25 +1181,64 @@ function clearDraftAutosave() {
async function saveDraft(silent) {
S.draftDirty=false;
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
if(!accountId){ if(!silent) toast('Draft saved locally','success'); return; }
const editor=document.getElementById('compose-editor');
const meta={
account_id:accountId,
to:getTagValues('compose-to'),
subject:document.getElementById('compose-subject').value,
body_html:editor.innerHTML.trim(),
body_text:editor.innerText.trim(),
};
const r=await api('POST','/draft',meta);
if(!silent) toast('Draft saved','success');
else toast('Draft auto-saved','success');
else if(r?.ok) toast('Draft auto-saved to server','success');
}
// ── Compose formatting ─────────────────────────────────────────────────────
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); }
function removeAttachment(i) {
// Don't remove EML forward placeholder (isForward) from UI; it's handled server-side
if(composeAttachments[i]?.isForward && S.composeMode==='forward-attachment'){
toast('The original message will be attached when sent','info'); return;
}
composeAttachments.splice(i,1); updateAttachList();
}
function updateAttachList() {
const el=document.getElementById('compose-attach-list');
if(!composeAttachments.length){el.innerHTML='';return;}
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
📎 <span>${esc(a.name)}</span>
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
<span style="color:var(--muted);font-size:10px">${a.size?formatSize(a.size):''}</span>
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
</div>`).join('');
}
// ── Compose drag-and-drop attachments ──────────────────────────────────────
function initComposeDragDrop() {
const dialog=document.getElementById('compose-dialog');
if(!dialog) return;
dialog.addEventListener('dragover', e=>{
e.preventDefault(); e.stopPropagation();
dialog.classList.add('drag-over');
});
dialog.addEventListener('dragleave', e=>{
if(!dialog.contains(e.relatedTarget)) dialog.classList.remove('drag-over');
});
dialog.addEventListener('drop', e=>{
e.preventDefault(); e.stopPropagation();
dialog.classList.remove('drag-over');
if(e.dataTransfer?.files?.length){
for(const file of e.dataTransfer.files) composeAttachments.push({file,name:file.name,size:file.size});
updateAttachList(); S.draftDirty=true;
toast(`${e.dataTransfer.files.length} file(s) attached`,'success');
}
});
}
async function sendMessage() {
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
const to=getTagValues('compose-to');
@@ -1104,15 +1247,44 @@ async function sendMessage() {
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
const btn=document.getElementById('send-btn');
btn.disabled=true; btn.textContent='Sending…';
const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send';
const r=await api('POST',endpoint,{
const endpoint=S.composeMode==='reply'?'/reply'
:S.composeMode==='forward'?'/forward'
:S.composeMode==='forward-attachment'?'/forward-attachment'
:'/send';
const meta={
account_id:accountId, to,
cc:getTagValues('compose-cc-tags'),
bcc:getTagValues('compose-bcc-tags'),
subject:document.getElementById('compose-subject').value,
body_text:bodyText, body_html:bodyHTML,
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
});
forward_from_id:(S.composeMode==='forward'||S.composeMode==='forward-attachment')?S.composeForwardFromId:0,
};
let r;
// Use FormData when there are real file attachments, OR when forwarding as attachment
// (server needs multipart so it can read forward_from_id from meta and fetch the EML itself)
const hasRealFiles = composeAttachments.some(a => a.file instanceof Blob);
const needsFormData = hasRealFiles || S.composeMode === 'forward-attachment';
if(needsFormData){
const fd=new FormData();
fd.append('meta', JSON.stringify(meta));
for(const a of composeAttachments){
if(a.file instanceof Blob){ // only append real File/Blob objects
fd.append('file', a.file, a.name);
}
// isForward placeholders are intentionally skipped — the EML is fetched server-side
}
try{
const resp=await fetch('/api'+endpoint,{method:'POST',body:fd});
r=await resp.json();
}catch(e){ r={error:String(e)}; }
} else {
r=await api('POST',endpoint,meta);
}
btn.disabled=false; btn.textContent='Send';
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
else toast(r?.error||'Send failed','error');
@@ -1386,7 +1558,7 @@ function updateUnreadBadgeFromPoll(inboxUnread) {
badge.style.display = 'none';
}
// Update browser tab title
const base = 'GoMail';
const base = 'GoWebMail';
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
}
@@ -1444,7 +1616,7 @@ function sendOSNotification(msgs) {
const first = msgs[0];
const title = count === 1
? (first.from_name || first.from_email || 'New message')
: `${count} new messages in GoMail`;
: `${count} new messages in GoWebMail`;
const body = count === 1
? (first.subject || '(no subject)')
: `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`;

View File

@@ -1,4 +1,4 @@
// GoMail shared utilities - loaded on every page
// GoWebMail shared utilities - loaded on every page
// ---- API helper ----
async function api(method, path, body) {

View File

@@ -35,5 +35,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js"></script>
<script src="/static/js/admin.js?v=16"></script>
{{end}}

View File

@@ -58,13 +58,14 @@
<span id="filter-label">Filter</span>
</button>
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
</div>
</div>
</div>
@@ -308,5 +309,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=12"></script>
<script src="/static/js/app.js?v=16"></script>
{{end}}

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=16">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=12"></script>
<script src="/static/js/gowebmail.js?v=16"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

View File

@@ -4,5 +4,5 @@ import "embed"
// Global access to the web assets
//
//go:embed web/static/** web/templates/**
//go:embed web/static/css/* web/static/js/* web/static/img/* web/templates/**
var WebFS embed.FS