mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
updated user management, add mesage select options
This commit is contained in:
@@ -106,4 +106,4 @@ CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server
|
||||
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
||||
|
||||
## License
|
||||
GNU 3
|
||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||
@@ -119,7 +119,9 @@ func main() {
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
||||
|
||||
// Remote content whitelist
|
||||
api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET")
|
||||
|
||||
@@ -276,13 +276,15 @@ func (d *DB) ListUsers() ([]*models.User, error) {
|
||||
u := &models.User{}
|
||||
var mfaSecretEnc, mfaPendingEnc string
|
||||
var lastLogin sql.NullTime
|
||||
var composePopup int
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive,
|
||||
&u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.SyncInterval, &composePopup,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ComposePopup = composePopup == 1
|
||||
u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc)
|
||||
u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc)
|
||||
if lastLogin.Valid {
|
||||
@@ -1129,3 +1131,83 @@ func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
|
||||
// GetMessageIMAPInfo returns the remote_uid, folder full_path, account info needed for IMAP ops.
|
||||
func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, folderPath string, account *models.EmailAccount, err error) {
|
||||
var uidStr string
|
||||
var accountID int64
|
||||
var folderID int64
|
||||
err = d.sql.QueryRow(`
|
||||
SELECT m.remote_uid, m.account_id, m.folder_id
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id = m.account_id
|
||||
WHERE m.id=? AND a.user_id=?`, messageID, userID,
|
||||
).Scan(&uidStr, &accountID, &folderID)
|
||||
if err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
// Parse uid
|
||||
var uid uint64
|
||||
fmt.Sscanf(uidStr, "%d", &uid)
|
||||
remoteUID = uint32(uid)
|
||||
|
||||
folder, err := d.GetFolderByID(folderID)
|
||||
if err != nil || folder == nil {
|
||||
return remoteUID, "", nil, fmt.Errorf("folder not found")
|
||||
}
|
||||
account, err = d.GetAccount(accountID)
|
||||
return remoteUID, folder.FullPath, account, err
|
||||
}
|
||||
|
||||
// ListStarredMessages returns all starred messages for a user, newest first.
|
||||
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
var total int
|
||||
d.sql.QueryRow(`SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE a.user_id=? AND m.is_starred=1`, userID).Scan(&total)
|
||||
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name,
|
||||
m.subject, m.from_name, m.from_email, m.body_text,
|
||||
m.date, m.is_read, m.is_starred, m.has_attachment
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id = m.account_id
|
||||
JOIN folders f ON f.id = m.folder_id
|
||||
WHERE a.user_id=? AND m.is_starred=1
|
||||
ORDER BY m.date DESC
|
||||
LIMIT ? OFFSET ?`, userID, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var summaries []models.MessageSummary
|
||||
for rows.Next() {
|
||||
s := models.MessageSummary{}
|
||||
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
|
||||
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
|
||||
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Subject, _ = d.enc.Decrypt(subjectEnc)
|
||||
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
|
||||
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
|
||||
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
|
||||
if len(bodyText) > 120 {
|
||||
bodyText = bodyText[:120] + "…"
|
||||
}
|
||||
s.Preview = bodyText
|
||||
summaries = append(summaries, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PagedMessages{
|
||||
Messages: summaries,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: offset+len(summaries) < total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +138,93 @@ func (c *Client) DeleteMailbox(name string) error {
|
||||
return c.imap.Delete(name)
|
||||
}
|
||||
|
||||
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
|
||||
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
|
||||
if _, err := c.imap.Select(srcMailbox, false); err != nil {
|
||||
return fmt.Errorf("select %s: %w", srcMailbox, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
// COPY to destination
|
||||
if err := c.imap.UidCopy(seqSet, destMailbox); err != nil {
|
||||
return fmt.Errorf("uid copy: %w", err)
|
||||
}
|
||||
// Mark deleted in source
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
flags := []interface{}{imap.DeletedFlag}
|
||||
if err := c.imap.UidStore(seqSet, item, flags, nil); err != nil {
|
||||
return fmt.Errorf("uid store deleted: %w", err)
|
||||
}
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
|
||||
// DeleteByUID moves message to Trash, or hard-deletes if already in Trash.
|
||||
func (c *Client) DeleteByUID(mailboxName string, uid uint32, trashName string) error {
|
||||
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
||||
return fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
isTrash := strings.EqualFold(mailboxName, trashName) || trashName == ""
|
||||
if !isTrash && trashName != "" {
|
||||
// Move to trash
|
||||
if err := c.imap.UidCopy(seqSet, trashName); err == nil {
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
_ = c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil)
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
}
|
||||
// Hard delete (already in trash or no trash folder)
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
if err := c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil); err != nil {
|
||||
return fmt.Errorf("uid store deleted: %w", err)
|
||||
}
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
|
||||
// SetFlagByUID sets or clears an IMAP flag (e.g. \Seen, \Flagged) for a message.
|
||||
func (c *Client) SetFlagByUID(mailboxName string, uid uint32, flag string, set bool) error {
|
||||
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
||||
return err
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
var op imap.FlagsOp
|
||||
if set {
|
||||
op = imap.AddFlags
|
||||
} else {
|
||||
op = imap.RemoveFlags
|
||||
}
|
||||
item := imap.FormatFlagsOp(op, true)
|
||||
return c.imap.UidStore(seqSet, item, []interface{}{flag}, nil)
|
||||
}
|
||||
|
||||
// FetchRawByUID returns the raw RFC 822 message bytes for the given UID.
|
||||
func (c *Client) FetchRawByUID(mailboxName string, uid uint32) ([]byte, error) {
|
||||
if _, err := c.imap.Select(mailboxName, true); err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{section.FetchItem()}
|
||||
ch := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
||||
msg := <-ch
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
body := msg.GetBody(section)
|
||||
if body == nil {
|
||||
return nil, fmt.Errorf("no body")
|
||||
}
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
|
||||
func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
|
||||
ch := make(chan *imap.MailboxInfo, 64)
|
||||
done := make(chan error, 1)
|
||||
|
||||
@@ -541,6 +541,18 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Read bool `json:"read"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||
go func() {
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetFlagByUID(folderPath, uid, `\Seen`, req.Read)
|
||||
}()
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -552,6 +564,18 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetFlagByUID(folderPath, uid, `\Flagged`, starred)
|
||||
}()
|
||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
||||
}
|
||||
|
||||
@@ -563,6 +587,25 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusBadRequest, "folder_id required")
|
||||
return
|
||||
}
|
||||
|
||||
// IMAP move (best-effort, non-blocking)
|
||||
go func() {
|
||||
uid, srcPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
return
|
||||
}
|
||||
destFolder, err := h.db.GetFolderByID(req.FolderID)
|
||||
if err != nil || destFolder == nil {
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.MoveByUID(srcPath, destFolder.FullPath, uid)
|
||||
}()
|
||||
|
||||
if err := h.db.MoveMessage(messageID, userID, req.FolderID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "move failed")
|
||||
return
|
||||
@@ -573,6 +616,30 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
|
||||
// IMAP delete (best-effort, non-blocking)
|
||||
go func() {
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
// Find trash folder name
|
||||
mailboxes, _ := c.ListMailboxes()
|
||||
var trashName string
|
||||
for _, mb := range mailboxes {
|
||||
if email.InferFolderType(mb.Name, mb.Attributes) == "trash" {
|
||||
trashName = mb.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = c.DeleteByUID(folderPath, uid, trashName)
|
||||
}()
|
||||
|
||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
@@ -738,7 +805,6 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
// Return a simplified set of headers we store
|
||||
headers := map[string]string{
|
||||
"Message-ID": msg.MessageID,
|
||||
"From": fmt.Sprintf("%s <%s>", msg.FromName, msg.FromEmail),
|
||||
@@ -749,7 +815,111 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
"Subject": msg.Subject,
|
||||
"Date": msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers})
|
||||
// Build a pseudo-raw header block for display
|
||||
var raw strings.Builder
|
||||
order := []string{"Date", "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Message-ID"}
|
||||
for _, k := range order {
|
||||
if v := headers[k]; v != "" {
|
||||
fmt.Fprintf(&raw, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers, "raw": raw.String()})
|
||||
}
|
||||
|
||||
func (h *APIHandler) StarredMessages(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 50
|
||||
}
|
||||
result, err := h.db.ListStarredMessages(userID, page, pageSize)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list starred")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (h *APIHandler) DownloadEML(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
msg, err := h.db.GetMessage(messageID, userID)
|
||||
if err != nil || msg == nil {
|
||||
h.writeError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw from IMAP first
|
||||
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if iErr == nil && uid != 0 && account != nil {
|
||||
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
|
||||
defer c.Close()
|
||||
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||
safe := sanitizeFilename(msg.Subject) + ".eml"
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
w.Write(raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reconstruct from stored fields
|
||||
var buf strings.Builder
|
||||
buf.WriteString("Date: " + msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
||||
buf.WriteString(fmt.Sprintf("From: %s <%s>\r\n", msg.FromName, msg.FromEmail))
|
||||
if msg.ToList != "" {
|
||||
buf.WriteString("To: " + msg.ToList + "\r\n")
|
||||
}
|
||||
if msg.CCList != "" {
|
||||
buf.WriteString("Cc: " + msg.CCList + "\r\n")
|
||||
}
|
||||
buf.WriteString("Subject: " + msg.Subject + "\r\n")
|
||||
if msg.MessageID != "" {
|
||||
buf.WriteString("Message-ID: " + msg.MessageID + "\r\n")
|
||||
}
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
if msg.BodyHTML != "" {
|
||||
boundary := "GoMailBoundary"
|
||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyText + "\r\n")
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyHTML + "\r\n")
|
||||
buf.WriteString("--" + boundary + "--\r\n")
|
||||
} else {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyText)
|
||||
}
|
||||
safe := sanitizeFilename(msg.Subject) + ".eml"
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
w.Write([]byte(buf.String()))
|
||||
}
|
||||
|
||||
func sanitizeFilename(s string) string {
|
||||
var out strings.Builder
|
||||
for _, r := range s {
|
||||
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
|
||||
out.WriteRune('_')
|
||||
} else {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
result := strings.TrimSpace(out.String())
|
||||
if result == "" {
|
||||
return "message"
|
||||
}
|
||||
if len(result) > 80 {
|
||||
result = result[:80]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---- Remote content whitelist ----
|
||||
|
||||
@@ -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 'self' data: https://api.qrserver.com;")
|
||||
"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:;")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +432,12 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
}
|
||||
.ctx-has-sub:hover>.ctx-submenu{display:block}
|
||||
.ctx-sub-item{white-space:nowrap}
|
||||
|
||||
/* ── Multi-select & drag-drop ────────────────────────────────── */
|
||||
.message-item.selected{background:rgba(74,144,226,.18)!important;outline:1px solid var(--accent)}
|
||||
.message-item.selected:hover{background:rgba(74,144,226,.26)!important}
|
||||
.nav-folder.drag-over,.nav-item.drag-over{background:rgba(74,144,226,.22)!important;border-radius:6px}
|
||||
#bulk-action-bar{display:none}
|
||||
.folder-nosync{opacity:.65}
|
||||
|
||||
/* ── Compose attach list ─────────────────────────────────────── */
|
||||
|
||||
@@ -334,7 +334,7 @@ function renderFolders() {
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
</button>
|
||||
</div>`+sorted.map(f=>`
|
||||
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||
oncontextmenu="showFolderMenu(event,${f.id})">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
||||
${esc(f.name)}
|
||||
@@ -464,6 +464,7 @@ async function loadMessages(append) {
|
||||
let result;
|
||||
if (S.searchQuery) result=await api('GET',`/search?q=${encodeURIComponent(S.searchQuery)}&page=${S.currentPage}&page_size=50`);
|
||||
else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`);
|
||||
else if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?page=${S.currentPage}&page_size=50`);
|
||||
else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`);
|
||||
if (!result){list.innerHTML='<div class="empty-state"><p>Failed to load</p></div>';return;}
|
||||
S.totalMessages=result.total||(result.messages||[]).length;
|
||||
@@ -502,6 +503,9 @@ function setFilter(mode) {
|
||||
function toggleFilterUnread() { setFilter(S.filterUnread ? 'default' : 'unread'); }
|
||||
function setSortOrder(order) { setFilter(order); }
|
||||
|
||||
// ── Multi-select state ────────────────────────────────────────
|
||||
if (!window.SEL) window.SEL = { ids: new Set(), lastIdx: -1 };
|
||||
|
||||
function renderMessageList() {
|
||||
const list=document.getElementById('message-list');
|
||||
let msgs = [...S.messages];
|
||||
@@ -512,15 +516,23 @@ function renderMessageList() {
|
||||
// Sort
|
||||
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)); // date-desc default
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=msgs.map(m=>`
|
||||
<div class="message-item ${m.id===S.selectedMessageId?'active':''} ${!m.is_read?'unread':''}"
|
||||
onclick="openMessage(${m.id})" oncontextmenu="showMessageMenu(event,${m.id})">
|
||||
|
||||
// Update bulk action bar
|
||||
updateBulkBar();
|
||||
|
||||
list.innerHTML=msgs.map((m,i)=>`
|
||||
<div class="message-item ${m.id===S.selectedMessageId&&!SEL.ids.size?'active':''} ${!m.is_read?'unread':''} ${SEL.ids.has(m.id)?'selected':''}"
|
||||
data-id="${m.id}" data-idx="${i}"
|
||||
draggable="true"
|
||||
onclick="handleMsgClick(event,${m.id},${i})"
|
||||
oncontextmenu="showMessageMenu(event,${m.id})"
|
||||
ondragstart="handleMsgDragStart(event,${m.id})">
|
||||
<div class="msg-top">
|
||||
<span class="msg-from">${esc(m.from_name||m.from_email)}</span>
|
||||
<span class="msg-date">${formatDate(m.date)}</span>
|
||||
@@ -536,6 +548,86 @@ function renderMessageList() {
|
||||
</div>
|
||||
</div>`).join('')+(S.messages.length<S.totalMessages
|
||||
?`<div class="load-more"><button class="load-more-btn" onclick="loadMoreMessages()">Load more</button></div>`:'');
|
||||
|
||||
// Enable drag-drop onto folder nav items
|
||||
document.querySelectorAll('.nav-item[data-fid]').forEach(el=>{
|
||||
el.ondragover=e=>{e.preventDefault();el.classList.add('drag-over');};
|
||||
el.ondragleave=()=>el.classList.remove('drag-over');
|
||||
el.ondrop=e=>{
|
||||
e.preventDefault(); el.classList.remove('drag-over');
|
||||
const fid=parseInt(el.dataset.fid);
|
||||
if (!fid) return;
|
||||
const ids = SEL.ids.size ? [...SEL.ids] : [parseInt(e.dataTransfer.getData('text/plain'))];
|
||||
ids.forEach(id=>moveMessage(id, fid, true));
|
||||
SEL.ids.clear(); updateBulkBar(); renderMessageList();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleMsgClick(e, id, idx) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Toggle selection
|
||||
SEL.ids.has(id) ? SEL.ids.delete(id) : SEL.ids.add(id);
|
||||
SEL.lastIdx = idx;
|
||||
renderMessageList(); return;
|
||||
}
|
||||
if (e.shiftKey && SEL.lastIdx >= 0) {
|
||||
// Range select
|
||||
const msgs = getFilteredSortedMsgs();
|
||||
const lo=Math.min(SEL.lastIdx,idx), hi=Math.max(SEL.lastIdx,idx);
|
||||
for (let i=lo;i<=hi;i++) SEL.ids.add(msgs[i].id);
|
||||
renderMessageList(); return;
|
||||
}
|
||||
SEL.ids.clear(); SEL.lastIdx=idx;
|
||||
openMessage(id);
|
||||
}
|
||||
|
||||
function getFilteredSortedMsgs() {
|
||||
let msgs=[...S.messages];
|
||||
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
|
||||
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));
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function handleMsgDragStart(e, id) {
|
||||
if (!SEL.ids.has(id)) { SEL.ids.clear(); SEL.ids.add(id); }
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
e.dataTransfer.effectAllowed='move';
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
let bar = document.getElementById('bulk-action-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id='bulk-action-bar';
|
||||
bar.style.cssText='display:none;position:sticky;top:0;z-index:10;background:var(--accent);color:#fff;padding:6px 12px;font-size:12px;display:flex;align-items:center;gap:8px';
|
||||
bar.innerHTML=`<span id="bulk-count"></span>
|
||||
<button onclick="bulkMarkRead(true)" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Mark read</button>
|
||||
<button onclick="bulkMarkRead(false)" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Mark unread</button>
|
||||
<button onclick="bulkDelete()" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Delete</button>
|
||||
<button onclick="SEL.ids.clear();renderMessageList()" style="margin-left:auto;font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">✕ Clear</button>`;
|
||||
document.getElementById('message-list').before(bar);
|
||||
}
|
||||
if (SEL.ids.size) {
|
||||
bar.style.display='flex';
|
||||
document.getElementById('bulk-count').textContent=SEL.ids.size+' selected';
|
||||
} else {
|
||||
bar.style.display='none';
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkMarkRead(read) {
|
||||
await Promise.all([...SEL.ids].map(id=>api('PUT','/messages/'+id+'/read',{read})));
|
||||
SEL.ids.forEach(id=>{const m=S.messages.find(m=>m.id===id);if(m)m.is_read=read;});
|
||||
SEL.ids.clear(); renderMessageList(); loadFolders();
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
await Promise.all([...SEL.ids].map(id=>api('DELETE','/messages/'+id)));
|
||||
SEL.ids.forEach(id=>{S.messages=S.messages.filter(m=>m.id!==id);});
|
||||
SEL.ids.clear(); renderMessageList();
|
||||
}
|
||||
|
||||
function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
||||
@@ -557,22 +649,21 @@ 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's dark theme and become unreadable
|
||||
// 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}</style>`;
|
||||
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
|
||||
`img{max-width:100%;height:auto}</style>`;
|
||||
|
||||
let bodyHtml='';
|
||||
if (msg.body_html) {
|
||||
if (allowed) {
|
||||
const srcdoc = cssReset + msg.body_html;
|
||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups"
|
||||
style="width:100%;border:none;min-height:300px;display:block"
|
||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
||||
style="width:100%;border:none;min-height:400px;display:block"
|
||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||
} else {
|
||||
// Strip only remote resources (img src, background-image urls, external link/script)
|
||||
// Keep full HTML structure so text remains readable
|
||||
const stripped = msg.body_html
|
||||
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])[^'"]*\2/gi, '<img$1src=""data-blocked="1"')
|
||||
.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, '');
|
||||
@@ -583,8 +674,8 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
|
||||
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||
</div>
|
||||
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups"
|
||||
style="width:100%;border:none;min-height:300px;display:block"
|
||||
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
||||
style="width:100%;border:none;min-height:400px;display:block"
|
||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||
}
|
||||
} else {
|
||||
@@ -621,14 +712,31 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
<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>
|
||||
<button class="action-btn" onclick="downloadEML(${msg.id})">⬇ Download</button>
|
||||
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
|
||||
</div>
|
||||
${attachHtml}
|
||||
<div class="detail-body">${bodyHtml}</div>`;
|
||||
|
||||
if (msg.body_html && allowed) {
|
||||
// Auto-size iframe to content height using ResizeObserver
|
||||
if (msg.body_html) {
|
||||
const frame=document.getElementById('msg-frame');
|
||||
if (frame) frame.onload=()=>{try{const h=frame.contentDocument.documentElement.scrollHeight;frame.style.height=(h+30)+'px';}catch(e){}};
|
||||
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) {}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,27 +750,50 @@ async function showMessageHeaders(id) {
|
||||
if (!r?.headers) return;
|
||||
const rows=Object.entries(r.headers).filter(([,v])=>v)
|
||||
.map(([k,v])=>`<tr><td style="color:var(--muted);padding:4px 12px 4px 0;font-size:12px;white-space:nowrap;vertical-align:top">${esc(k)}</td><td style="font-size:12px;word-break:break-all">${esc(v)}</td></tr>`).join('');
|
||||
const rawText = r.raw||'';
|
||||
const overlay=document.createElement('div');
|
||||
overlay.className='modal-overlay open';
|
||||
overlay.innerHTML=`<div class="modal" style="width:600px;max-height:80vh;overflow-y:auto">
|
||||
overlay.innerHTML=`<div class="modal" style="width:660px;max-height:85vh;display:flex;flex-direction:column">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">Message Headers</h2>
|
||||
<button class="icon-btn" onclick="this.closest('.modal-overlay').remove()"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
<table style="width:100%"><tbody>${rows}</tbody></table>
|
||||
<div style="overflow-y:auto;flex:1">
|
||||
<table style="width:100%;margin-bottom:16px"><tbody>${rows}</tbody></table>
|
||||
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin-bottom:6px">Raw Headers</div>
|
||||
<div style="position:relative">
|
||||
<textarea id="raw-headers-ta" readonly style="width:100%;box-sizing:border-box;height:180px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text2);font-family:monospace;font-size:11px;padding:10px;resize:vertical;outline:none">${esc(rawText)}</textarea>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('raw-headers-ta').value).then(()=>toast('Copied','success'))"
|
||||
style="position:absolute;top:6px;right:8px;font-size:11px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text2);cursor:pointer">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click',e=>{if(e.target===overlay)overlay.remove();});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function downloadEML(id) {
|
||||
window.open('/api/messages/'+id+'/download.eml','_blank');
|
||||
}
|
||||
|
||||
function showMessageMenu(e, id) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const moveFolders=S.folders.slice(0,8).map(f=>`<div class="ctx-item" onclick="moveMessage(${id},${f.id});closeMenu()">${esc(f.name)}</div>`).join('');
|
||||
const msg = S.messages.find(m=>m.id===id);
|
||||
const otherFolders = S.folders.filter(f=>!f.is_hidden&&f.id!==S.currentFolder).slice(0,16);
|
||||
const moveItems = otherFolders.map(f=>`<div class="ctx-item ctx-sub-item" onclick="moveMessage(${id},${f.id});closeMenu()">${esc(f.name)}</div>`).join('');
|
||||
const moveSub = otherFolders.length ? `
|
||||
<div class="ctx-item ctx-has-sub">📂 Move to
|
||||
<span class="ctx-sub-arrow">›</span>
|
||||
<div class="ctx-submenu">${moveItems}</div>
|
||||
</div>` : '';
|
||||
showCtxMenu(e,`
|
||||
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">★ Toggle star</div>
|
||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
|
||||
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
|
||||
<div class="ctx-sep"></div>
|
||||
${moveSub}
|
||||
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()">⋮ View headers</div>
|
||||
${moveFolders?`<div class="ctx-sep"></div><div style="font-size:10px;color:var(--muted);padding:4px 12px;text-transform:uppercase;letter-spacing:.8px">Move to</div>${moveFolders}`:''}
|
||||
<div class="ctx-item" onclick="downloadEML(${id});closeMenu()">⬇ Download .eml</div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteMessage(${id});closeMenu()">🗑 Delete</div>`);
|
||||
}
|
||||
@@ -680,14 +811,16 @@ async function markRead(id, read) {
|
||||
loadFolders();
|
||||
}
|
||||
|
||||
async function moveMessage(msgId, folderId) {
|
||||
async function moveMessage(msgId, folderId, silent=false) {
|
||||
const folder = S.folders.find(f=>f.id===folderId);
|
||||
inlineConfirm(`Move this message to "${folder?.name||'selected folder'}"?`, async () => {
|
||||
const doMove = async () => {
|
||||
const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId});
|
||||
if(r?.ok){toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);renderMessageList();
|
||||
if(r?.ok){if(!silent)toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);
|
||||
if(S.currentMessage?.id===msgId)resetDetail();loadFolders();}
|
||||
else toast('Move failed','error');
|
||||
});
|
||||
else if(!silent) toast('Move failed','error');
|
||||
};
|
||||
if (silent) { doMove(); return; }
|
||||
inlineConfirm(`Move this message to "${folder?.name||'selected folder'}"?`, doMove);
|
||||
}
|
||||
|
||||
async function deleteMessage(id) {
|
||||
|
||||
@@ -302,5 +302,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=9"></script>
|
||||
<script src="/static/js/app.js?v=10"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoMail{{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/gomail.css?v=9">
|
||||
<link rel="stylesheet" href="/static/css/gomail.css?v=10">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gomail.js?v=9"></script>
|
||||
<script src="/static/js/gomail.js?v=10"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user