mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
fix the layout and email input for sending message
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
data/envs
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gomail.conf
|
||||
data/*.txt
|
||||
@@ -133,6 +133,7 @@ func main() {
|
||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
||||
api.HandleFunc("/folders/{account_id:[0-9]+}", h.API.ListAccountFolders).Methods("GET")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/sync", h.API.SyncFolder).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/visibility", h.API.SetFolderVisibility).Methods("PUT")
|
||||
|
||||
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||
|
||||
@@ -165,8 +165,13 @@ func (d *DB) Migrate() error {
|
||||
alterStmts := []string{
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_days INTEGER NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_mode TEXT NOT NULL DEFAULT 'days'`,
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_all_folders INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
|
||||
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
|
||||
// Default: primary folder types sync by default, others don't.
|
||||
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
||||
}
|
||||
for _, stmt := range alterStmts {
|
||||
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
|
||||
@@ -441,7 +446,6 @@ func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.Audi
|
||||
}, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
// ---- Email Accounts ----
|
||||
|
||||
func (d *DB) CreateAccount(a *models.EmailAccount) error {
|
||||
@@ -685,25 +689,35 @@ func (d *DB) UpdateFolderCounts(folderID int64) {
|
||||
// ---- Folders ----
|
||||
|
||||
func (d *DB) UpsertFolder(f *models.Folder) error {
|
||||
// On insert: set sync_enabled based on folder type (primary types sync by default)
|
||||
defaultSync := 0
|
||||
switch f.FolderType {
|
||||
case "inbox", "sent", "drafts", "trash", "spam":
|
||||
defaultSync = 1
|
||||
}
|
||||
_, err := d.sql.Exec(`
|
||||
INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count, sync_enabled)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, full_path) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
folder_type=excluded.folder_type,
|
||||
unread_count=excluded.unread_count,
|
||||
total_count=excluded.total_count`,
|
||||
f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount,
|
||||
f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount, defaultSync,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder, error) {
|
||||
f := &models.Folder{}
|
||||
var isHidden, syncEnabled int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
|
||||
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
|
||||
FROM folders WHERE account_id=? AND full_path=?`, accountID, fullPath,
|
||||
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount)
|
||||
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -712,7 +726,8 @@ func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder,
|
||||
|
||||
func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
|
||||
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
|
||||
FROM folders WHERE account_id=? ORDER BY folder_type, name`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -722,9 +737,12 @@ func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) {
|
||||
var folders []*models.Folder
|
||||
for rows.Next() {
|
||||
f := &models.Folder{}
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil {
|
||||
var isHidden, syncEnabled int
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
@@ -988,7 +1006,8 @@ func (d *DB) DeleteMessage(messageID, userID int64) error {
|
||||
|
||||
func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count
|
||||
SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count,
|
||||
COALESCE(f.is_hidden,0), COALESCE(f.sync_enabled,1)
|
||||
FROM folders f
|
||||
JOIN email_accounts a ON a.id=f.account_id
|
||||
WHERE a.user_id=?
|
||||
@@ -1001,9 +1020,12 @@ func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) {
|
||||
var folders []*models.Folder
|
||||
for rows.Next() {
|
||||
f := &models.Folder{}
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil {
|
||||
var isHidden, syncEnabled int
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
@@ -1047,12 +1069,31 @@ func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) {
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// SetFolderVisibility sets is_hidden and sync_enabled for a folder owned by the user.
|
||||
func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled bool) error {
|
||||
ih, se := 0, 0
|
||||
if isHidden {
|
||||
ih = 1
|
||||
}
|
||||
if syncEnabled {
|
||||
se = 1
|
||||
}
|
||||
_, err := d.sql.Exec(`
|
||||
UPDATE folders SET is_hidden=?, sync_enabled=?
|
||||
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
|
||||
ih, se, folderID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
|
||||
f := &models.Folder{}
|
||||
var isHidden, syncEnabled int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
FROM folders WHERE id=?`, folderID,
|
||||
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount)
|
||||
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -293,6 +293,24 @@ func (h *APIHandler) SyncFolder(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SetFolderVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
folderID := pathInt64(r, "id")
|
||||
var req struct {
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
SyncEnabled bool `json:"sync_enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if err := h.db.SetFolderVisibility(folderID, userID, req.IsHidden, req.SyncEnabled); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SetAccountSyncSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
accountID := pathInt64(r, "id")
|
||||
|
||||
@@ -125,6 +125,8 @@ type Folder struct {
|
||||
FolderType string `json:"folder_type"` // inbox, sent, drafts, trash, spam, custom
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
SyncEnabled bool `json:"sync_enabled"`
|
||||
}
|
||||
|
||||
// ---- Messages ----
|
||||
|
||||
@@ -163,6 +163,11 @@ func (s *Scheduler) doSync(account *models.EmailAccount) (int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip folders that the user has disabled sync on
|
||||
if !dbFolder.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 36500 // ~100 years = full mailbox
|
||||
|
||||
@@ -153,18 +153,8 @@ body.app-page{overflow:hidden}
|
||||
.compose-btn{padding:6px 12px;background:var(--accent);border:none;border-radius:6px;
|
||||
color:white;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.compose-btn:hover{opacity:.85}
|
||||
.accounts-section{padding:10px 8px 4px;border-bottom:1px solid var(--border)}
|
||||
.section-label{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||
color:var(--muted);padding:0 6px 6px}
|
||||
.account-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;
|
||||
cursor:pointer;transition:background .1s;position:relative}
|
||||
.account-item:hover{background:var(--surface3)}
|
||||
/* ── Account dot (still used in popup) */
|
||||
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.account-email{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.account-error-dot{width:6px;height:6px;background:var(--danger);border-radius:50%;flex-shrink:0}
|
||||
.add-account-btn{display:flex;align-items:center;gap:6px;padding:5px 6px;color:var(--accent);
|
||||
font-size:12px;cursor:pointer;border-radius:6px;transition:background .1s;margin-top:2px}
|
||||
.add-account-btn:hover{background:var(--accent-dim)}
|
||||
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
|
||||
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
|
||||
@@ -247,30 +237,60 @@ body.app-page{overflow:hidden}
|
||||
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
|
||||
.detail-body iframe{width:100%;border:none;min-height:400px}
|
||||
|
||||
/* Compose */
|
||||
.compose-overlay{position:fixed;bottom:20px;right:24px;z-index:50;display:none}
|
||||
.compose-overlay.open{display:block}
|
||||
.compose-window{width:540px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);display:flex;flex-direction:column}
|
||||
.compose-header{padding:12px 16px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.compose-title{font-size:14px;font-weight:500}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;
|
||||
line-height:1;padding:2px 6px;border-radius:4px}
|
||||
/* ── Compose dialog (draggable, all-edge resizable) ──────────── */
|
||||
.compose-dialog{
|
||||
position:fixed;bottom:20px;right:24px;
|
||||
width:540px;height:480px;
|
||||
background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
display:none;flex-direction:column;z-index:200;
|
||||
min-width:360px;min-height:280px;overflow:hidden;
|
||||
user-select:none;
|
||||
}
|
||||
.compose-dialog-header{
|
||||
padding:10px 12px 10px 16px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
cursor:grab;flex-shrink:0;background:var(--surface2);
|
||||
}
|
||||
.compose-dialog-header:active{cursor:grabbing}
|
||||
.compose-body-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden}
|
||||
.compose-title{font-size:13px;font-weight:500;pointer-events:none}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
|
||||
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
|
||||
.compose-close:hover{background:var(--surface3);color:var(--text)}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 14px;gap:10px}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:6px 14px;gap:10px;flex-shrink:0}
|
||||
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
|
||||
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
|
||||
.compose-field select option{background:var(--surface2)}
|
||||
.compose-body textarea{width:100%;height:200px;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.6;resize:none;outline:none;padding:12px 14px}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.send-btn:hover{opacity:.85}
|
||||
.send-btn:disabled{opacity:.5;cursor:default}
|
||||
|
||||
/* Resize handles — 8-directional */
|
||||
.compose-resize{position:absolute;z-index:10}
|
||||
.compose-resize[data-dir="e"] {right:0;top:8px;bottom:8px;width:5px;cursor:e-resize}
|
||||
.compose-resize[data-dir="w"] {left:0;top:8px;bottom:8px;width:5px;cursor:w-resize}
|
||||
.compose-resize[data-dir="s"] {bottom:0;left:8px;right:8px;height:5px;cursor:s-resize}
|
||||
.compose-resize[data-dir="n"] {top:0;left:8px;right:8px;height:5px;cursor:n-resize}
|
||||
.compose-resize[data-dir="se"]{right:0;bottom:0;width:12px;height:12px;cursor:se-resize}
|
||||
.compose-resize[data-dir="sw"]{left:0;bottom:0;width:12px;height:12px;cursor:sw-resize}
|
||||
.compose-resize[data-dir="ne"]{right:0;top:0;width:12px;height:12px;cursor:ne-resize}
|
||||
.compose-resize[data-dir="nw"]{left:0;top:0;width:12px;height:12px;cursor:nw-resize}
|
||||
|
||||
/* Minimised pill */
|
||||
.compose-minimised{
|
||||
position:fixed;bottom:0;right:24px;z-index:201;
|
||||
display:none;align-items:center;gap:8px;
|
||||
padding:8px 16px;background:var(--surface2);
|
||||
border:1px solid var(--border2);border-bottom:none;
|
||||
border-radius:8px 8px 0 0;font-size:13px;cursor:pointer;
|
||||
box-shadow:0 -4px 20px rgba(0,0,0,.3);
|
||||
}
|
||||
.compose-minimised:hover{background:var(--surface3)}
|
||||
|
||||
/* Provider buttons */
|
||||
.provider-btns{display:flex;gap:10px;margin-bottom:14px}
|
||||
.provider-btn{flex:1;padding:10px;background:var(--surface3);border:1px solid var(--border2);
|
||||
@@ -350,29 +370,59 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
|
||||
/* ── Email tag input ─────────────────────────────────────────── */
|
||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||
padding:4px 6px;min-height:34px;cursor:text;background:var(--bg);
|
||||
border:1px solid var(--border);border-radius:6px}
|
||||
.tag-container:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px rgba(99,102,241,.15)}
|
||||
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:8px}
|
||||
.email-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px;
|
||||
padding:4px 6px;min-height:32px;cursor:text;background:transparent}
|
||||
.tag-container:focus-within{}
|
||||
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:7px}
|
||||
.email-tag{display:inline-flex;align-items:center;gap:3px;padding:2px 6px 2px 8px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:12px;
|
||||
font-size:12px;color:var(--text);white-space:nowrap}
|
||||
.email-tag.invalid{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.4);color:#fca5a5}
|
||||
font-size:12px;color:var(--text);white-space:nowrap;max-width:260px}
|
||||
.email-tag.invalid{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}
|
||||
.tag-remove{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:0;font-size:14px;line-height:1;margin-left:2px}
|
||||
padding:0 1px;font-size:14px;line-height:1;flex-shrink:0}
|
||||
.tag-remove:hover{color:var(--text)}
|
||||
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
|
||||
font-family:inherit;min-width:120px;flex:1;padding:1px 0}
|
||||
font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
|
||||
|
||||
/* ── Compose resize handle ───────────────────────────────────── */
|
||||
#compose-resize-handle{position:absolute;top:0;left:0;right:0;height:5px;
|
||||
cursor:n-resize;border-radius:10px 10px 0 0;z-index:1}
|
||||
#compose-resize-handle:hover{background:var(--accent);opacity:.4}
|
||||
.compose-window{position:relative;display:flex;flex-direction:column;
|
||||
min-width:360px;min-height:280px;resize:none}
|
||||
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:6px 14px 0;min-height:0}
|
||||
/* ── Accounts popup ──────────────────────────────────────────── */
|
||||
.accounts-popup{
|
||||
position:fixed;bottom:52px;left:8px;
|
||||
width:300px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);
|
||||
z-index:300;display:none;flex-direction:column;overflow:hidden;
|
||||
}
|
||||
.accounts-popup.open{display:flex}
|
||||
.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299}
|
||||
.accounts-popup-backdrop.open{display:block}
|
||||
.accounts-popup-inner{padding:12px}
|
||||
.accounts-popup-header{display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;
|
||||
color:var(--muted);margin-bottom:8px}
|
||||
.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px;
|
||||
transition:background .1s}
|
||||
.acct-popup-item:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px;
|
||||
margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
|
||||
transition:background .1s}
|
||||
.accounts-add-btn:hover{background:var(--accent-dim)}
|
||||
|
||||
/* ── Icon sync button ─────────────────────────────────────────── */
|
||||
/* ── Inline confirm toast ────────────────────────────────────── */
|
||||
.inline-confirm{
|
||||
position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
|
||||
background:var(--surface2);border:1px solid var(--border2);border-radius:10px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55);padding:16px 18px;
|
||||
min-width:280px;max-width:400px;z-index:400;
|
||||
opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||||
}
|
||||
.inline-confirm.open{opacity:1;pointer-events:all;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ── Folder no-sync indicator ────────────────────────────────── */
|
||||
.folder-nosync{opacity:.65}
|
||||
|
||||
/* ── Compose attach list ─────────────────────────────────────── */
|
||||
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:4px 14px 0;min-height:0;flex-shrink:0}
|
||||
|
||||
/* ── Icon sync button ────────────────────────────────────────── */
|
||||
.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s}
|
||||
.icon-sync-btn:hover{color:var(--text)}
|
||||
.icon-sync-btn:hover{color:var(--text)}
|
||||
@@ -9,6 +9,7 @@ const S = {
|
||||
searchQuery: '', composeMode: 'new', composeReplyToId: null,
|
||||
remoteWhitelist: new Set(),
|
||||
draftTimer: null, draftDirty: false,
|
||||
composeVisible: false, composeMinimised: false,
|
||||
};
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
@@ -20,17 +21,16 @@ async function init() {
|
||||
S.me = me;
|
||||
document.getElementById('user-display').textContent = me.username || me.email;
|
||||
if (me.role === 'admin') document.getElementById('admin-link').style.display = 'block';
|
||||
if (me.compose_popup) document.getElementById('compose-popup-toggle').checked = true;
|
||||
}
|
||||
if (providers) { S.providers = providers; updateProviderButtons(); }
|
||||
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
|
||||
|
||||
await loadAccounts(); // must complete before loadFolders so colors are available
|
||||
await loadAccounts();
|
||||
await loadFolders();
|
||||
await loadMessages();
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'',' /'); }
|
||||
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
|
||||
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
@@ -40,71 +40,79 @@ async function init() {
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); }
|
||||
});
|
||||
|
||||
// Resizable compose
|
||||
initComposeResize();
|
||||
initComposeDragResize();
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
function updateProviderButtons() {
|
||||
['gmail','outlook'].forEach(p => {
|
||||
const btn = document.getElementById('btn-'+p);
|
||||
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title=p+' OAuth not configured'; }
|
||||
if (!btn) return;
|
||||
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Accounts popup ─────────────────────────────────────────────────────────
|
||||
function toggleAccountsMenu(e) {
|
||||
e.stopPropagation();
|
||||
const popup = document.getElementById('accounts-popup');
|
||||
const backdrop = document.getElementById('accounts-popup-backdrop');
|
||||
if (popup.classList.contains('open')) {
|
||||
closeAccountsMenu(); return;
|
||||
}
|
||||
renderAccountsPopup();
|
||||
popup.classList.add('open');
|
||||
backdrop.classList.add('open');
|
||||
}
|
||||
function closeAccountsMenu() {
|
||||
document.getElementById('accounts-popup').classList.remove('open');
|
||||
document.getElementById('accounts-popup-backdrop').classList.remove('open');
|
||||
}
|
||||
|
||||
function renderAccountsPopup() {
|
||||
const el = document.getElementById('accounts-popup-list');
|
||||
if (!S.accounts.length) {
|
||||
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = S.accounts.map(a => `
|
||||
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
||||
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
||||
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
|
||||
<svg width="13" height="13" 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>
|
||||
<button class="icon-btn" title="Settings" onclick="openEditAccount(${a.id})">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Remove" onclick="deleteAccount(${a.id})" style="color:var(--danger)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
async function loadAccounts() {
|
||||
const data = await api('GET','/accounts');
|
||||
if (!data) return;
|
||||
S.accounts = data;
|
||||
renderAccounts();
|
||||
renderAccountsPopup();
|
||||
populateComposeFrom();
|
||||
}
|
||||
|
||||
function renderAccounts() {
|
||||
const el = document.getElementById('accounts-list');
|
||||
el.innerHTML = S.accounts.map(a => `
|
||||
<div class="account-item" oncontextmenu="showAccountMenu(event,${a.id})"
|
||||
title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
||||
<div class="account-dot" style="background:${a.color}"></div>
|
||||
<span class="account-email">${esc(a.email_address)}</span>
|
||||
${a.last_error?'<div class="account-error-dot"></div>':''}
|
||||
<button onclick="syncNow(${a.id},event)" id="sync-btn-${a.id}" class="icon-sync-btn" title="Sync now">
|
||||
<svg width="12" height="12" 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>`).join('');
|
||||
}
|
||||
|
||||
function showAccountMenu(e, id) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const a = S.accounts.find(a=>a.id===id);
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncNow(${id});closeMenu()">↻ Sync now</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id},true);closeMenu()">⚡ Test connection</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id});closeMenu()">✎ Edit credentials</div>
|
||||
${a?.last_error?`<div class="ctx-item" onclick="toast('${esc(a.last_error)}','error');closeMenu()">⚠ View last error</div>`:''}
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteAccount(${id});closeMenu()">🗑 Remove account</div>`);
|
||||
}
|
||||
|
||||
async function syncNow(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
const btn = document.getElementById('sync-btn-'+id);
|
||||
if (btn) { btn.style.opacity='0.3'; btn.style.pointerEvents='none'; }
|
||||
const r = await api('POST','/accounts/'+id+'/sync');
|
||||
if (btn) { btn.style.opacity=''; btn.style.pointerEvents=''; }
|
||||
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadAccounts(); loadFolders(); loadMessages(); }
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; }
|
||||
|
||||
// ── Add Account modal ──────────────────────────────────────────────────────
|
||||
function openAddAccountModal() {
|
||||
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
|
||||
document.getElementById('imap-port').value='993';
|
||||
document.getElementById('smtp-port').value='587';
|
||||
const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';}
|
||||
closeAccountsMenu();
|
||||
openModal('add-account-modal');
|
||||
}
|
||||
|
||||
@@ -135,8 +143,17 @@ async function addIMAPAccount() {
|
||||
else toast(r?.error||'Failed to add account','error');
|
||||
}
|
||||
|
||||
async function syncNow(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
toast('Syncing…','info');
|
||||
const r = await api('POST','/accounts/'+id+'/sync');
|
||||
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadAccounts(); loadFolders(); loadMessages(); }
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
// ── Edit Account modal ─────────────────────────────────────────────────────
|
||||
async function openEditAccount(id, testAfterOpen) {
|
||||
async function openEditAccount(id) {
|
||||
closeAccountsMenu();
|
||||
const r=await api('GET','/accounts/'+id);
|
||||
if (!r) return;
|
||||
document.getElementById('edit-account-id').value=id;
|
||||
@@ -147,7 +164,6 @@ async function openEditAccount(id, testAfterOpen) {
|
||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
||||
// Sync settings
|
||||
document.getElementById('edit-sync-mode').value=r.sync_mode||'days';
|
||||
document.getElementById('edit-sync-days').value=r.sync_days||30;
|
||||
toggleSyncDaysField();
|
||||
@@ -156,7 +172,6 @@ async function openEditAccount(id, testAfterOpen) {
|
||||
errEl.style.display=r.last_error?'block':'none';
|
||||
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
|
||||
openModal('edit-account-modal');
|
||||
if (testAfterOpen) setTimeout(testEditConnection,200);
|
||||
}
|
||||
|
||||
function toggleSyncDaysField() {
|
||||
@@ -198,10 +213,27 @@ async function saveAccountEdit() {
|
||||
|
||||
async function deleteAccount(id) {
|
||||
const a=S.accounts.find(a=>a.id===id);
|
||||
if (!confirm('Remove '+(a?a.email_address:id)+'?\nAll synced messages will be deleted.')) return;
|
||||
const r=await api('DELETE','/accounts/'+id);
|
||||
if (r?.ok){toast('Account removed','success');loadAccounts();loadFolders();loadMessages();}
|
||||
else toast('Remove failed','error');
|
||||
inlineConfirm(
|
||||
'Remove '+(a?a.email_address:'this account')+'? All synced messages will be deleted.',
|
||||
async () => {
|
||||
const r=await api('DELETE','/accounts/'+id);
|
||||
if (r?.ok){toast('Account removed','success');closeAccountsMenu();loadAccounts();loadFolders();loadMessages();}
|
||||
else toast('Remove failed','error');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline confirm (replaces browser confirm()) ────────────────────────────
|
||||
function inlineConfirm(message, onOk, onCancel) {
|
||||
const el = document.getElementById('inline-confirm');
|
||||
const msg = document.getElementById('inline-confirm-msg');
|
||||
const ok = document.getElementById('inline-confirm-ok');
|
||||
const cancel = document.getElementById('inline-confirm-cancel');
|
||||
msg.textContent = message;
|
||||
el.classList.add('open');
|
||||
const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; };
|
||||
ok.onclick = () => { cleanup(); onOk && onOk(); };
|
||||
cancel.onclick = () => { cleanup(); onCancel && onCancel(); };
|
||||
}
|
||||
|
||||
// ── Folders ────────────────────────────────────────────────────────────────
|
||||
@@ -227,7 +259,8 @@ function renderFolders() {
|
||||
const el=document.getElementById('folders-by-account');
|
||||
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
|
||||
const byAcc={};
|
||||
S.folders.forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
|
||||
// Only show non-hidden folders
|
||||
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
|
||||
const prio=['inbox','sent','drafts','trash','spam','archive'];
|
||||
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
|
||||
const acc=accMap[parseInt(accId)];
|
||||
@@ -239,28 +272,58 @@ function renderFolders() {
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
|
||||
${esc(accEmail)}
|
||||
</div>`+sorted.map(f=>`
|
||||
<div class="nav-item" id="nav-f${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||
oncontextmenu="showFolderMenu(event,${f.id},${acc.id})">
|
||||
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${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)}
|
||||
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
||||
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">⊘</span>':''}
|
||||
</div>`).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showFolderMenu(e, folderId, accountId) {
|
||||
function showFolderMenu(e, folderId) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const f = S.folders.find(f=>f.id===folderId);
|
||||
if (!f) return;
|
||||
const syncLabel = f.sync_enabled ? '⊘ Disable sync' : '↻ Enable sync';
|
||||
const hideLabel = '👁 Hide from sidebar';
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||
<div class="ctx-item" onclick="selectFolder(${folderId});closeMenu()">📂 Open folder</div>`);
|
||||
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item" onclick="hideFolder(${folderId});closeMenu()">${hideLabel}</div>`);
|
||||
}
|
||||
|
||||
async function syncFolderNow(folderId) {
|
||||
toast('Syncing folder…','info');
|
||||
const r=await api('POST','/folders/'+folderId+'/sync');
|
||||
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadFolders(); loadMessages(); }
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
async function toggleFolderSync(folderId) {
|
||||
const f = S.folders.find(f=>f.id===folderId);
|
||||
if (!f) return;
|
||||
const newSync = !f.sync_enabled;
|
||||
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:f.is_hidden, sync_enabled:newSync});
|
||||
if (r?.ok) {
|
||||
f.sync_enabled = newSync;
|
||||
toast(newSync?'Folder sync enabled':'Folder sync disabled', 'success');
|
||||
renderFolders();
|
||||
} else toast('Update failed','error');
|
||||
}
|
||||
|
||||
async function hideFolder(folderId) {
|
||||
const f = S.folders.find(f=>f.id===folderId);
|
||||
if (!f) return;
|
||||
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:true, sync_enabled:false});
|
||||
if (r?.ok) {
|
||||
toast('Folder hidden. Manage in account settings.','success');
|
||||
await loadFolders();
|
||||
} else toast('Update failed','error');
|
||||
}
|
||||
|
||||
function updateUnreadBadge() {
|
||||
const total=S.folders.filter(f=>f.folder_type==='inbox').reduce((s,f)=>s+(f.unread_count||0),0);
|
||||
const badge=document.getElementById('unread-total');
|
||||
@@ -460,10 +523,12 @@ async function moveMessage(msgId, folderId) {
|
||||
}
|
||||
|
||||
async function deleteMessage(id) {
|
||||
if(!confirm('Delete this message?')) return;
|
||||
const r=await api('DELETE','/messages/'+id);
|
||||
if(r?.ok){toast('Deleted','success');S.messages=S.messages.filter(m=>m.id!==id);renderMessageList();
|
||||
if(S.currentMessage?.id===id)resetDetail();loadFolders();}
|
||||
inlineConfirm('Delete this message?', async () => {
|
||||
const r=await api('DELETE','/messages/'+id);
|
||||
if(r?.ok){toast('Deleted','success');S.messages=S.messages.filter(m=>m.id!==id);renderMessageList();
|
||||
if(S.currentMessage?.id===id)resetDetail();loadFolders();}
|
||||
else toast('Delete failed','error');
|
||||
});
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
@@ -488,9 +553,12 @@ function openCompose(opts={}) {
|
||||
S.composeMode=opts.mode||'new'; S.composeReplyToId=opts.replyId||null;
|
||||
composeAttachments=[];
|
||||
document.getElementById('compose-title').textContent=opts.title||'New Message';
|
||||
document.getElementById('compose-to').innerHTML='';
|
||||
document.getElementById('compose-cc-tags').innerHTML='';
|
||||
document.getElementById('compose-bcc-tags').innerHTML='';
|
||||
document.getElementById('compose-minimised-label').textContent=opts.title||'New Message';
|
||||
// Clear tag containers and re-init
|
||||
['compose-to','compose-cc-tags','compose-bcc-tags'].forEach(id=>{
|
||||
const c=document.getElementById(id);
|
||||
if(c){ c.innerHTML=''; initTagField(id); }
|
||||
});
|
||||
document.getElementById('compose-subject').value=opts.subject||'';
|
||||
document.getElementById('cc-row').style.display='none';
|
||||
document.getElementById('bcc-row').style.display='none';
|
||||
@@ -498,16 +566,50 @@ function openCompose(opts={}) {
|
||||
editor.innerHTML=opts.body||'';
|
||||
S.draftDirty=false;
|
||||
updateAttachList();
|
||||
if (S.me?.compose_popup) {
|
||||
openComposePopup();
|
||||
} else {
|
||||
document.getElementById('compose-overlay').classList.add('open');
|
||||
// Focus the To field's input
|
||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },50);
|
||||
}
|
||||
showCompose();
|
||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
|
||||
startDraftAutosave();
|
||||
}
|
||||
|
||||
function showCompose() {
|
||||
const d=document.getElementById('compose-dialog');
|
||||
const m=document.getElementById('compose-minimised');
|
||||
d.style.display='flex';
|
||||
m.style.display='none';
|
||||
S.composeVisible=true; S.composeMinimised=false;
|
||||
}
|
||||
|
||||
function minimizeCompose() {
|
||||
document.getElementById('compose-dialog').style.display='none';
|
||||
document.getElementById('compose-minimised').style.display='flex';
|
||||
S.composeMinimised=true;
|
||||
}
|
||||
|
||||
function restoreCompose() {
|
||||
showCompose();
|
||||
}
|
||||
|
||||
function closeCompose(skipCheck) {
|
||||
if (!skipCheck && S.draftDirty) {
|
||||
inlineConfirm('Save draft before closing?',
|
||||
()=>{ saveDraft(); _closeCompose(); },
|
||||
()=>{ _closeCompose(); }
|
||||
);
|
||||
return;
|
||||
}
|
||||
_closeCompose();
|
||||
}
|
||||
|
||||
function _closeCompose() {
|
||||
document.getElementById('compose-dialog').style.display='none';
|
||||
document.getElementById('compose-minimised').style.display='none';
|
||||
clearDraftAutosave();
|
||||
S.composeVisible=false; S.composeMinimised=false; S.draftDirty=false;
|
||||
}
|
||||
|
||||
function showCCRow() { document.getElementById('cc-row').style.display='flex'; }
|
||||
function showBCCRow() { document.getElementById('bcc-row').style.display='flex'; }
|
||||
|
||||
function openReply() { if (S.currentMessage) openReplyTo(S.currentMessage.id); }
|
||||
|
||||
function openReplyTo(msgId) {
|
||||
@@ -518,7 +620,6 @@ function openReplyTo(msgId) {
|
||||
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
|
||||
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
// Pre-fill To
|
||||
addTag('compose-to', msg.from_email||'');
|
||||
}
|
||||
|
||||
@@ -532,132 +633,108 @@ function openForward() {
|
||||
});
|
||||
}
|
||||
|
||||
function closeCompose(skipDraftCheck) {
|
||||
if (!skipDraftCheck && S.draftDirty) {
|
||||
const choice=confirm('Save draft before closing?');
|
||||
if (choice) { saveDraft(); return; }
|
||||
}
|
||||
clearDraftAutosave();
|
||||
if (S.me?.compose_popup) {
|
||||
const win=window._composeWin;
|
||||
if (win&&!win.closed) win.close();
|
||||
} else {
|
||||
document.getElementById('compose-overlay').classList.remove('open');
|
||||
}
|
||||
S.draftDirty=false;
|
||||
}
|
||||
|
||||
// ── Email Tag Input ────────────────────────────────────────────────────────
|
||||
function initTagField(containerId) {
|
||||
const container=document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
// Remove any existing input first
|
||||
const old=container.querySelector('.tag-input');
|
||||
if(old) old.remove();
|
||||
|
||||
const inp=document.createElement('input');
|
||||
inp.type='text'; inp.className='tag-input'; inp.placeholder=containerId==='compose-to'?'recipient@example.com':'';
|
||||
inp.type='text';
|
||||
inp.className='tag-input';
|
||||
inp.placeholder=containerId==='compose-to'?'recipient@example.com':'';
|
||||
inp.setAttribute('autocomplete','off');
|
||||
inp.setAttribute('spellcheck','false');
|
||||
container.appendChild(inp);
|
||||
|
||||
const commit = () => {
|
||||
const v=inp.value.trim().replace(/[,;\s]+$/,'');
|
||||
if(v){ addTag(containerId,v); inp.value=''; }
|
||||
};
|
||||
|
||||
inp.addEventListener('keydown', e=>{
|
||||
if ((e.key===' '||e.key==='Enter'||e.key===','||e.key===';') && inp.value.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(containerId, inp.value.trim().replace(/[,;]$/,''));
|
||||
inp.value='';
|
||||
} else if (e.key==='Backspace'&&!inp.value) {
|
||||
if(e.key==='Enter'||e.key===','||e.key===';') { e.preventDefault(); commit(); }
|
||||
else if(e.key===' ') {
|
||||
// Space commits only if value looks like an email
|
||||
const v=inp.value.trim();
|
||||
if(v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { e.preventDefault(); commit(); }
|
||||
} else if(e.key==='Backspace'&&!inp.value) {
|
||||
const tags=container.querySelectorAll('.email-tag');
|
||||
if (tags.length) tags[tags.length-1].remove();
|
||||
if(tags.length) tags[tags.length-1].remove();
|
||||
}
|
||||
S.draftDirty=true;
|
||||
});
|
||||
inp.addEventListener('blur', ()=>{
|
||||
if (inp.value.trim()) { addTag(containerId, inp.value.trim()); inp.value=''; }
|
||||
});
|
||||
container.addEventListener('click', ()=>inp.focus());
|
||||
inp.addEventListener('blur', commit);
|
||||
container.addEventListener('click', e=>{ if(e.target===container||e.target.tagName==='LABEL') inp.focus(); else if(!e.target.closest('.email-tag')) inp.focus(); });
|
||||
}
|
||||
|
||||
function addTag(containerId, value) {
|
||||
if (!value) return;
|
||||
const container=document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
// Basic email validation
|
||||
const isValid=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
const tag=document.createElement('span');
|
||||
tag.className='email-tag'+(isValid?'':' invalid');
|
||||
tag.textContent=value;
|
||||
tag.dataset.email=value;
|
||||
const label=document.createElement('span');
|
||||
label.textContent=value;
|
||||
const remove=document.createElement('button');
|
||||
remove.innerHTML='×'; remove.className='tag-remove';
|
||||
remove.innerHTML='×'; remove.className='tag-remove'; remove.type='button';
|
||||
remove.onclick=e=>{e.stopPropagation();tag.remove();S.draftDirty=true;};
|
||||
tag.appendChild(remove);
|
||||
tag.appendChild(label); tag.appendChild(remove);
|
||||
const inp=container.querySelector('.tag-input');
|
||||
container.insertBefore(tag, inp);
|
||||
container.insertBefore(tag, inp||null);
|
||||
S.draftDirty=true;
|
||||
}
|
||||
|
||||
function getTagValues(containerId) {
|
||||
return Array.from(document.querySelectorAll('#'+containerId+' .email-tag'))
|
||||
.map(t=>t.textContent.replace('×','').trim()).filter(Boolean);
|
||||
.map(t=>t.dataset.email||t.querySelector('span')?.textContent||'').filter(Boolean);
|
||||
}
|
||||
|
||||
// ── Draft autosave ─────────────────────────────────────────────────────────
|
||||
function startDraftAutosave() {
|
||||
clearDraftAutosave();
|
||||
S.draftTimer=setInterval(()=>{
|
||||
if (S.draftDirty) saveDraft(true);
|
||||
}, 60000); // every 60s
|
||||
// Mark dirty on any edit
|
||||
S.draftTimer=setInterval(()=>{ if(S.draftDirty) saveDraft(true); }, 60000);
|
||||
const editor=document.getElementById('compose-editor');
|
||||
if (editor) editor.oninput=()=>S.draftDirty=true;
|
||||
['compose-subject'].forEach(id=>{
|
||||
const el=document.getElementById(id);
|
||||
if(el) el.oninput=()=>S.draftDirty=true;
|
||||
});
|
||||
if(editor) editor.oninput=()=>S.draftDirty=true;
|
||||
}
|
||||
|
||||
function clearDraftAutosave() {
|
||||
if (S.draftTimer) { clearInterval(S.draftTimer); S.draftTimer=null; }
|
||||
if(S.draftTimer){ clearInterval(S.draftTimer); S.draftTimer=null; }
|
||||
}
|
||||
|
||||
async function saveDraft(silent) {
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
if (!accountId) return;
|
||||
const to=getTagValues('compose-to');
|
||||
const editor=document.getElementById('compose-editor');
|
||||
// For now save as a local note — a real IMAP APPEND to Drafts would be ideal
|
||||
// but for MVP we just suppress the dirty flag and toast
|
||||
S.draftDirty=false;
|
||||
if (!silent) toast('Draft saved','success');
|
||||
if(!silent) toast('Draft saved','success');
|
||||
else toast('Draft auto-saved','success');
|
||||
}
|
||||
|
||||
// ── Compose formatting ─────────────────────────────────────────────────────
|
||||
function execFmt(cmd, val) {
|
||||
document.getElementById('compose-editor').focus();
|
||||
document.execCommand(cmd, false, val||null);
|
||||
}
|
||||
|
||||
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
||||
|
||||
function handleAttachFiles(input) {
|
||||
for (const file of input.files) composeAttachments.push({file,name:file.name,size:file.size});
|
||||
input.value=''; updateAttachList(); S.draftDirty=true;
|
||||
}
|
||||
|
||||
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
|
||||
function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); }
|
||||
|
||||
function updateAttachList() {
|
||||
const el=document.getElementById('compose-attach-list');
|
||||
if (!composeAttachments.length){el.innerHTML='';return;}
|
||||
if(!composeAttachments.length){el.innerHTML='';return;}
|
||||
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.name)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
<button onclick="removeAttachment(${i})" class="tag-remove">×</button>
|
||||
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
const to=getTagValues('compose-to');
|
||||
if (!accountId||!to.length){toast('From account and To address required','error');return;}
|
||||
if(!accountId||!to.length){toast('From account and To address required','error');return;}
|
||||
const editor=document.getElementById('compose-editor');
|
||||
const bodyHTML=editor.innerHTML.trim();
|
||||
const bodyText=editor.innerText.trim();
|
||||
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
||||
const btn=document.getElementById('send-btn');
|
||||
btn.disabled=true;btn.textContent='Sending...';
|
||||
btn.disabled=true; btn.textContent='Sending…';
|
||||
const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send';
|
||||
const r=await api('POST',endpoint,{
|
||||
account_id:accountId, to,
|
||||
@@ -667,43 +744,67 @@ async function sendMessage() {
|
||||
body_text:bodyText, body_html:bodyHTML,
|
||||
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
||||
});
|
||||
btn.disabled=false;btn.textContent='Send';
|
||||
if (r?.ok){toast('Sent!','success');clearDraftAutosave();S.draftDirty=false;
|
||||
document.getElementById('compose-overlay').classList.remove('open');}
|
||||
btn.disabled=false; btn.textContent='Send';
|
||||
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
||||
else toast(r?.error||'Send failed','error');
|
||||
}
|
||||
|
||||
// ── Resizable compose ──────────────────────────────────────────────────────
|
||||
function initComposeResize() {
|
||||
const win=document.getElementById('compose-window');
|
||||
if (!win) return;
|
||||
let resizing=false, startX, startY, startW, startH;
|
||||
const handle=document.getElementById('compose-resize-handle');
|
||||
if (!handle) return;
|
||||
handle.addEventListener('mousedown', e=>{
|
||||
resizing=true; startX=e.clientX; startY=e.clientY;
|
||||
startW=win.offsetWidth; startH=win.offsetHeight;
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', ()=>{resizing=false;document.removeEventListener('mousemove',onMouseMove);});
|
||||
e.preventDefault();
|
||||
});
|
||||
function onMouseMove(e) {
|
||||
if (!resizing) return;
|
||||
const newW=Math.max(360, startW+(e.clientX-startX));
|
||||
const newH=Math.max(280, startH-(e.clientY-startY));
|
||||
win.style.width=newW+'px';
|
||||
win.style.height=newH+'px';
|
||||
document.getElementById('compose-editor').style.height=(newH-240)+'px';
|
||||
}
|
||||
}
|
||||
// ── Compose drag + all-edge resize ─────────────────────────────────────────
|
||||
function initComposeDragResize() {
|
||||
const dlg=document.getElementById('compose-dialog');
|
||||
if(!dlg) return;
|
||||
|
||||
// ── Compose popup window ───────────────────────────────────────────────────
|
||||
function openComposePopup() {
|
||||
const popup=window.open('','_blank','width=640,height=520,resizable=yes,scrollbars=yes');
|
||||
window._composeWin=popup;
|
||||
// Simpler: just use the in-page compose anyway for now; popup would need full HTML
|
||||
// Fall back to in-page for robustness
|
||||
document.getElementById('compose-overlay').classList.add('open');
|
||||
// Default position — bottom-right
|
||||
dlg.style.right='24px'; dlg.style.bottom='20px';
|
||||
dlg.style.left='auto'; dlg.style.top='auto';
|
||||
|
||||
// Drag by header
|
||||
const header=document.getElementById('compose-drag-handle');
|
||||
if(header) {
|
||||
let ox,oy,startL,startT;
|
||||
header.addEventListener('mousedown', e=>{
|
||||
if(e.target.closest('button')) return;
|
||||
const r=dlg.getBoundingClientRect();
|
||||
ox=e.clientX; oy=e.clientY; startL=r.left; startT=r.top;
|
||||
dlg.style.left=startL+'px'; dlg.style.top=startT+'px';
|
||||
dlg.style.right='auto'; dlg.style.bottom='auto';
|
||||
const mm=ev=>{
|
||||
dlg.style.left=Math.max(0,Math.min(window.innerWidth-dlg.offsetWidth, startL+(ev.clientX-ox)))+'px';
|
||||
dlg.style.top= Math.max(0,Math.min(window.innerHeight-30, startT+(ev.clientY-oy)))+'px';
|
||||
};
|
||||
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); };
|
||||
document.addEventListener('mousemove',mm);
|
||||
document.addEventListener('mouseup',mu);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
// Resize handles
|
||||
dlg.querySelectorAll('.compose-resize').forEach(handle=>{
|
||||
const dir=handle.dataset.dir;
|
||||
handle.addEventListener('mousedown', e=>{
|
||||
const rect=dlg.getBoundingClientRect();
|
||||
const startX=e.clientX,startY=e.clientY;
|
||||
const startW=rect.width,startH=rect.height,startL=rect.left,startT=rect.top;
|
||||
const mm=ev=>{
|
||||
let w=startW,h=startH,l=startL,t=startT;
|
||||
const dx=ev.clientX-startX, dy=ev.clientY-startY;
|
||||
if(dir.includes('e')) w=Math.max(360,startW+dx);
|
||||
if(dir.includes('w')){ w=Math.max(360,startW-dx); l=startL+startW-w; }
|
||||
if(dir.includes('s')) h=Math.max(280,startH+dy);
|
||||
if(dir.includes('n')){ h=Math.max(280,startH-dy); t=startT+startH-h; }
|
||||
dlg.style.width=w+'px'; dlg.style.height=h+'px';
|
||||
dlg.style.left=l+'px'; dlg.style.top=t+'px';
|
||||
dlg.style.right='auto'; dlg.style.bottom='auto';
|
||||
const editor=document.getElementById('compose-editor');
|
||||
if(editor) editor.style.height=(h-242)+'px';
|
||||
};
|
||||
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); };
|
||||
document.addEventListener('mousemove',mm);
|
||||
document.addEventListener('mouseup',mu);
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
@@ -715,34 +816,27 @@ async function openSettings() {
|
||||
|
||||
async function loadSyncInterval() {
|
||||
const r=await api('GET','/sync-interval');
|
||||
if (r) document.getElementById('sync-interval-select').value=String(r.sync_interval||15);
|
||||
if(r) document.getElementById('sync-interval-select').value=String(r.sync_interval||15);
|
||||
}
|
||||
|
||||
async function saveSyncInterval() {
|
||||
const val=parseInt(document.getElementById('sync-interval-select').value)||0;
|
||||
const r=await api('PUT','/sync-interval',{sync_interval:val});
|
||||
if (r?.ok) toast('Saved','success'); else toast('Failed','error');
|
||||
}
|
||||
|
||||
async function saveComposePopupPref() {
|
||||
const val=document.getElementById('compose-popup-toggle').checked;
|
||||
await api('PUT','/compose-popup',{compose_popup:val});
|
||||
if (S.me) S.me.compose_popup=val;
|
||||
if(r?.ok) toast('Sync interval saved','success'); else toast('Failed','error');
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const cur=document.getElementById('cur-pw').value, nw=document.getElementById('new-pw').value;
|
||||
if (!cur||!nw){toast('Both fields required','error');return;}
|
||||
if(!cur||!nw){toast('Both fields required','error');return;}
|
||||
const r=await api('POST','/change-password',{current_password:cur,new_password:nw});
|
||||
if (r?.ok){toast('Password updated','success');document.getElementById('cur-pw').value='';document.getElementById('new-pw').value='';}
|
||||
if(r?.ok){toast('Password updated','success');document.getElementById('cur-pw').value='';document.getElementById('new-pw').value='';}
|
||||
else toast(r?.error||'Failed','error');
|
||||
}
|
||||
|
||||
async function renderMFAPanel() {
|
||||
const me=await api('GET','/me');
|
||||
if (!me) return;
|
||||
const me=await api('GET','/me'); if(!me) return;
|
||||
const badge=document.getElementById('mfa-badge'), panel=document.getElementById('mfa-panel');
|
||||
if (me.mfa_enabled) {
|
||||
if(me.mfa_enabled) {
|
||||
badge.innerHTML='<span class="badge green">Enabled</span>';
|
||||
panel.innerHTML=`<p style="font-size:13px;color:var(--muted);margin-bottom:12px">TOTP active. Enter code to disable.</p>
|
||||
<div class="modal-field"><label>Code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||
@@ -754,7 +848,7 @@ async function renderMFAPanel() {
|
||||
}
|
||||
|
||||
async function beginMFASetup() {
|
||||
const r=await api('POST','/mfa/setup'); if (!r) return;
|
||||
const r=await api('POST','/mfa/setup'); if(!r) return;
|
||||
document.getElementById('mfa-panel').innerHTML=`
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Scan with your authenticator app.</p>
|
||||
<div style="text-align:center;margin-bottom:14px"><img src="${r.qr_url}" style="border-radius:8px;background:white;padding:8px"></div>
|
||||
@@ -764,11 +858,11 @@ async function beginMFASetup() {
|
||||
}
|
||||
async function confirmMFASetup() {
|
||||
const r=await api('POST','/mfa/confirm',{code:document.getElementById('mfa-code').value});
|
||||
if (r?.ok){toast('MFA enabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
if(r?.ok){toast('MFA enabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
}
|
||||
async function disableMFA() {
|
||||
const r=await api('POST','/mfa/disable',{code:document.getElementById('mfa-code').value});
|
||||
if (r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
}
|
||||
|
||||
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
||||
@@ -783,19 +877,12 @@ function showCtxMenu(e, html) {
|
||||
});
|
||||
}
|
||||
|
||||
// Close compose on overlay click
|
||||
document.addEventListener('click', e=>{
|
||||
if (e.target===document.getElementById('compose-overlay')) {
|
||||
if (S.draftDirty) { if (confirm('Save draft before closing?')) { saveDraft(); return; } }
|
||||
closeCompose(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Init tag fields after DOM is ready
|
||||
// ── Init tag fields on DOMContentLoaded ───────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
// Tag fields are re-init each time openCompose() is called.
|
||||
// Initial init here covers the first open.
|
||||
initTagField('compose-to');
|
||||
initTagField('compose-cc-tags');
|
||||
initTagField('compose-bcc-tags');
|
||||
});
|
||||
|
||||
init();
|
||||
init();
|
||||
});
|
||||
@@ -14,15 +14,6 @@
|
||||
<button class="compose-btn" onclick="openCompose()">+ Compose</button>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="section-label">Accounts</div>
|
||||
<div id="accounts-list"></div>
|
||||
<div class="add-account-btn" onclick="openModal('add-account-modal')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-item active" id="nav-unified" onclick="selectFolder('unified','Unified Inbox')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><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>
|
||||
@@ -42,6 +33,9 @@
|
||||
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Admin</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
|
||||
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
</button>
|
||||
@@ -79,14 +73,34 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Compose window -->
|
||||
<div class="compose-overlay" id="compose-overlay">
|
||||
<div class="compose-window" id="compose-window">
|
||||
<div id="compose-resize-handle"></div>
|
||||
<div class="compose-header">
|
||||
<span class="compose-title" id="compose-title">New Message</span>
|
||||
<button class="compose-close" onclick="closeCompose()">×</button>
|
||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||
<div class="accounts-popup" id="accounts-popup">
|
||||
<div class="accounts-popup-inner">
|
||||
<div class="accounts-popup-header">
|
||||
<span>Accounts</span>
|
||||
<button class="icon-btn" onclick="closeAccountsMenu()" style="margin:-4px -4px -4px 0">
|
||||
<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>
|
||||
<div id="accounts-popup-list"></div>
|
||||
<button class="accounts-add-btn" onclick="closeAccountsMenu();openAddAccountModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect new account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounts-popup-backdrop" id="accounts-popup-backdrop" onclick="closeAccountsMenu()"></div>
|
||||
|
||||
<!-- ── Draggable Compose dialog ───────────────────────────────────────────── -->
|
||||
<div class="compose-dialog" id="compose-dialog">
|
||||
<div class="compose-dialog-header" id="compose-drag-handle">
|
||||
<span class="compose-title" id="compose-title">New Message</span>
|
||||
<div style="display:flex;align-items:center;gap:2px">
|
||||
<button class="compose-close" onclick="minimizeCompose()" title="Minimise">–</button>
|
||||
<button class="compose-close" onclick="closeCompose()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-body-wrap" id="compose-body-wrap">
|
||||
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
|
||||
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
|
||||
@@ -108,17 +122,39 @@
|
||||
<div class="compose-footer">
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<div style="display:flex;gap:6px;margin-left:4px">
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('cc-row').style.display='flex'">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('bcc-row').style.display='flex'">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showCCRow()">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">✎ Draft</button>
|
||||
</div>
|
||||
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-resize" data-dir="e"></div>
|
||||
<div class="compose-resize" data-dir="s"></div>
|
||||
<div class="compose-resize" data-dir="se"></div>
|
||||
<div class="compose-resize" data-dir="w"></div>
|
||||
<div class="compose-resize" data-dir="sw"></div>
|
||||
<div class="compose-resize" data-dir="n"></div>
|
||||
<div class="compose-resize" data-dir="ne"></div>
|
||||
<div class="compose-resize" data-dir="nw"></div>
|
||||
</div>
|
||||
<!-- Minimised pill (shown when user clicks – on header) -->
|
||||
<div class="compose-minimised" id="compose-minimised" onclick="restoreCompose()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><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>
|
||||
<span id="compose-minimised-label">New Message</span>
|
||||
</div>
|
||||
|
||||
<!-- Add Account Modal -->
|
||||
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
|
||||
<div class="inline-confirm" id="inline-confirm">
|
||||
<p id="inline-confirm-msg" style="margin:0 0 12px;font-size:13px"></p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
|
||||
<button class="btn-danger" style="font-size:12px" id="inline-confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="add-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Connect an account</h2>
|
||||
@@ -154,7 +190,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="edit-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Account Settings</h2>
|
||||
@@ -191,7 +227,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal" style="width:520px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||
@@ -216,15 +252,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Compose Window</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Open new message as an in-page panel (default) or a separate popup window.</div>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
|
||||
<input type="checkbox" id="compose-popup-toggle" onchange="saveComposePopupPref()">
|
||||
Open compose in new popup window
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
@@ -248,4 +275,4 @@
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js"></script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user