update
This commit is contained in:
+755
-19
@@ -9,11 +9,14 @@ package webmail
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -26,6 +29,7 @@ import (
|
||||
"gomail/internal/mailstore"
|
||||
"gomail/internal/oauth2"
|
||||
"gomail/internal/totp"
|
||||
"gomail/internal/webauthn"
|
||||
"gomail/internal/webtoken"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -38,11 +42,15 @@ type Handler struct {
|
||||
store *mailstore.Store
|
||||
mk *crypto.MasterKey
|
||||
jwtSecret string
|
||||
hostname string // WebAuthn RP ID; origin is "https://"+hostname
|
||||
|
||||
oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured
|
||||
|
||||
oauthStateMu sync.Mutex
|
||||
oauthState map[string]oauthStateEntry // CSRF state -> pending link request
|
||||
|
||||
webauthnMu sync.Mutex
|
||||
webauthnState map[string]webauthnChallengeEntry // challenge -> pending registration/assertion, same shape as oauthState
|
||||
}
|
||||
|
||||
type oauthStateEntry struct {
|
||||
@@ -51,11 +59,21 @@ type oauthStateEntry struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
||||
// webauthnChallengeEntry tracks one issued challenge. UserID is set for
|
||||
// both registration (the already-authenticated user registering a new
|
||||
// passkey) and login (the user identified by the mfa_pending token before
|
||||
// the passkey assertion completes it).
|
||||
type webauthnChallengeEntry struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret, hostname string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
||||
return &Handler{
|
||||
database: database, store: store, mk: mk, jwtSecret: jwtSecret,
|
||||
oauthConfigs: oauthConfigs,
|
||||
oauthState: make(map[string]oauthStateEntry),
|
||||
database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname,
|
||||
oauthConfigs: oauthConfigs,
|
||||
oauthState: make(map[string]oauthStateEntry),
|
||||
webauthnState: make(map[string]webauthnChallengeEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +86,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup))
|
||||
mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm))
|
||||
mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable))
|
||||
mux.HandleFunc("/api/me/passkeys/register/start", h.withAuth(h.passkeyRegisterStart))
|
||||
mux.HandleFunc("/api/me/passkeys/register/finish", h.withAuth(h.passkeyRegisterFinish))
|
||||
mux.HandleFunc("/api/me/passkeys", h.withAuth(h.passkeys))
|
||||
mux.HandleFunc("/api/me/passkeys/", h.withAuth(h.passkeyByID))
|
||||
mux.HandleFunc("/api/auth/passkey/start", h.passkeyLoginStart)
|
||||
mux.HandleFunc("/api/auth/passkey/finish", h.passkeyLoginFinish)
|
||||
mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail))
|
||||
mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords))
|
||||
mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID))
|
||||
@@ -75,11 +99,16 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages))
|
||||
mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages))
|
||||
mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID))
|
||||
mux.HandleFunc("/api/inbox/unified", h.withAuth(h.unifiedInbox))
|
||||
mux.HandleFunc("/api/search", h.withAuth(h.search))
|
||||
mux.HandleFunc("/api/calendar/events", h.withAuth(h.calendarEvents))
|
||||
mux.HandleFunc("/api/contacts", h.withAuth(h.contacts))
|
||||
mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine))
|
||||
mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine))
|
||||
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
|
||||
mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts))
|
||||
mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect)
|
||||
mux.HandleFunc("/api/accounts/imap", h.withAuth(h.linkIMAPAccount))
|
||||
mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount))
|
||||
}
|
||||
|
||||
@@ -255,19 +284,46 @@ func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.Use
|
||||
}
|
||||
|
||||
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
// withAuth's own row fetch doesn't select mfa_enabled/recovery_email
|
||||
// (most callers don't need them) — GetUser is the canonical full-row
|
||||
// fetch that does.
|
||||
fresh, err := h.database.GetUser(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID, "email": user.Email, "display_name": user.DisplayName, "role": user.Role,
|
||||
"id": fresh.ID, "email": fresh.Email, "display_name": fresh.DisplayName, "role": fresh.Role,
|
||||
"mfa_enabled": fresh.MFAEnabled, "recovery_email": fresh.RecoveryEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Folders & messages ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
|
||||
return accounts.NewGoMailProvider(h.database, h.store, user)
|
||||
// provider resolves which mailbox a request operates on. With no ?account=
|
||||
// query param it's the user's own local mailbox (today's only behavior,
|
||||
// unchanged). With ?account=<linked-account-id>, it's that account's
|
||||
// provider — after confirming the account actually belongs to this user,
|
||||
// since the ID otherwise comes straight from client input.
|
||||
func (h *Handler) provider(r *http.Request, user *db.User) (accounts.MailProvider, error) {
|
||||
id := r.URL.Query().Get("account")
|
||||
if id == "" {
|
||||
return accounts.NewGoMailProvider(h.database, h.store, user), nil
|
||||
}
|
||||
acct, err := h.database.GetLinkedAccount(id)
|
||||
if err != nil || acct.UserID != user.ID {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||
}
|
||||
|
||||
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
folders, err := h.provider(user).ListFolders(r.Context())
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
folders, err := p.ListFolders(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -293,7 +349,12 @@ func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.
|
||||
opts.Offset, _ = strconv.Atoi(o)
|
||||
}
|
||||
|
||||
headers, err := h.provider(user).ListMessages(r.Context(), folderID, opts)
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
headers, err := p.ListMessages(r.Context(), folderID, opts)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -321,8 +382,13 @@ func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, use
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
|
||||
if err := h.provider(user).SendMessage(r.Context(), msg); err != nil {
|
||||
if err := p.SendMessage(r.Context(), msg); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -342,7 +408,11 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
||||
if len(parts) >= 3 {
|
||||
action = parts[2]
|
||||
}
|
||||
p := h.provider(user)
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodGet && action == "":
|
||||
@@ -389,6 +459,306 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
||||
}
|
||||
}
|
||||
|
||||
type unifiedMessage struct {
|
||||
accounts.MessageHeader
|
||||
AccountID string `json:"account_id"` // "" means the local account — matches provider()'s ?account= convention
|
||||
AccountLabel string `json:"account_label"`
|
||||
}
|
||||
|
||||
// unifiedInbox handles GET /api/inbox/unified — merges the Inbox folder
|
||||
// across the user's local mailbox and every linked account into one
|
||||
// newest-first list. A linked account that fails (unreachable server,
|
||||
// expired token) is skipped and reported in "warnings", not allowed to
|
||||
// fail the whole request — the same negative-path standard this project
|
||||
// applies elsewhere (see GOMAIL_HANDOVER.md).
|
||||
func (h *Handler) unifiedInbox(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
const perAccountLimit = 30
|
||||
const overallLimit = 100
|
||||
|
||||
type source struct {
|
||||
id, label string
|
||||
p accounts.MailProvider
|
||||
}
|
||||
sources := []source{{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)}}
|
||||
|
||||
linked, err := h.database.ListLinkedAccounts(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for i := range linked {
|
||||
acct := &linked[i]
|
||||
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||
if perr != nil {
|
||||
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
||||
}
|
||||
label := acct.DisplayName
|
||||
if label == "" {
|
||||
label = acct.EmailAddress
|
||||
}
|
||||
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
||||
}
|
||||
|
||||
var merged []unifiedMessage
|
||||
var warnings []string
|
||||
for _, src := range sources {
|
||||
folders, ferr := src.p.ListFolders(r.Context())
|
||||
if ferr != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
||||
continue
|
||||
}
|
||||
var inboxID string
|
||||
for _, f := range folders {
|
||||
if f.Type == "inbox" {
|
||||
inboxID = f.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if inboxID == "" {
|
||||
continue
|
||||
}
|
||||
headers, merr := src.p.ListMessages(r.Context(), inboxID, accounts.ListOpts{Limit: perAccountLimit})
|
||||
if merr != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, merr))
|
||||
continue
|
||||
}
|
||||
for _, hdr := range headers {
|
||||
merged = append(merged, unifiedMessage{MessageHeader: hdr, AccountID: src.id, AccountLabel: src.label})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
ti, _ := mail.ParseDate(merged[i].Date)
|
||||
tj, _ := mail.ParseDate(merged[j].Date)
|
||||
return ti.After(tj)
|
||||
})
|
||||
if len(merged) > overallLimit {
|
||||
merged = merged[:overallLimit]
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": merged, "warnings": warnings})
|
||||
}
|
||||
|
||||
// searchResult tags a match with the folder it lives in — a search spans
|
||||
// every folder in the account, so (unlike a single-folder listing) the
|
||||
// client needs to know which folder to open the message from. AccountID/
|
||||
// AccountLabel follow unifiedMessage's convention ("" means the local
|
||||
// account) — populated when a search fans out across every linked account
|
||||
// (see search's doc comment), left blank for a single-account search.
|
||||
type searchResult struct {
|
||||
accounts.MessageHeader
|
||||
FolderName string `json:"folder_name"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
AccountLabel string `json:"account_label,omitempty"`
|
||||
}
|
||||
|
||||
// search handles GET /api/search?q=...&body=1&folder=...&account=... —
|
||||
// reuses ListFolders/ListMessages/GetMessage exactly like every other
|
||||
// endpoint (same account-scoping via provider(), same header-cache-or-
|
||||
// fallback decrypt ListMessages already does) rather than a separate
|
||||
// index. Header matching (from/to/subject) is effectively free — it reuses
|
||||
// the same decrypt ListMessages already pays for a folder view. Body
|
||||
// matching is opt-in and live-decrypts on demand, capped at
|
||||
// maxBodySearchScans messages total (shared across every account searched,
|
||||
// not per-account) so one search can't force-decrypt an entire large
|
||||
// mailbox — see this project's "everything encrypted at rest" guarantee,
|
||||
// which an index over message content would weaken.
|
||||
//
|
||||
// With no ?account= and no ?folder=, the search fans out across the local
|
||||
// mailbox and every linked account — same source list, same "skip and warn
|
||||
// on a broken account" behavior as unifiedInbox — since a specific account
|
||||
// or folder ID otherwise pins the search to one provider's namespace.
|
||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
const maxResults = 100
|
||||
const maxBodySearchScans = 500
|
||||
|
||||
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
if query == "" {
|
||||
writeErr(w, http.StatusBadRequest, "q is required")
|
||||
return
|
||||
}
|
||||
searchBody := r.URL.Query().Get("body") == "1" || r.URL.Query().Get("body") == "true"
|
||||
onlyFolder := r.URL.Query().Get("folder")
|
||||
accountID := r.URL.Query().Get("account")
|
||||
|
||||
type source struct {
|
||||
id, label string
|
||||
p accounts.MailProvider
|
||||
}
|
||||
var sources []source
|
||||
singleAccount := accountID != "" || onlyFolder != ""
|
||||
if singleAccount {
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
sources = []source{{id: accountID, p: p}}
|
||||
} else {
|
||||
sources = append(sources, source{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)})
|
||||
linked, err := h.database.ListLinkedAccounts(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for i := range linked {
|
||||
acct := &linked[i]
|
||||
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||
if perr != nil {
|
||||
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
||||
}
|
||||
label := acct.DisplayName
|
||||
if label == "" {
|
||||
label = acct.EmailAddress
|
||||
}
|
||||
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
||||
}
|
||||
}
|
||||
|
||||
var results []searchResult
|
||||
var warnings []string
|
||||
bodyScans := 0
|
||||
truncated := false
|
||||
for _, src := range sources {
|
||||
var folders []accounts.Folder
|
||||
if onlyFolder != "" {
|
||||
folders = []accounts.Folder{{ID: onlyFolder}}
|
||||
} else {
|
||||
var ferr error
|
||||
folders, ferr = src.p.ListFolders(r.Context())
|
||||
if ferr != nil {
|
||||
if singleAccount {
|
||||
writeErr(w, http.StatusInternalServerError, ferr.Error())
|
||||
return
|
||||
}
|
||||
if src.label != "" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, f := range folders {
|
||||
headers, err := src.p.ListMessages(r.Context(), f.ID, accounts.ListOpts{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, hdr := range headers {
|
||||
matched := strings.Contains(strings.ToLower(hdr.From), query) ||
|
||||
strings.Contains(strings.ToLower(hdr.To), query) ||
|
||||
strings.Contains(strings.ToLower(hdr.Subject), query)
|
||||
|
||||
if !matched && searchBody {
|
||||
if bodyScans >= maxBodySearchScans {
|
||||
truncated = true
|
||||
} else {
|
||||
bodyScans++
|
||||
if full, ferr := src.p.GetMessage(r.Context(), f.ID, hdr.ID); ferr == nil {
|
||||
matched = strings.Contains(strings.ToLower(string(full.Raw)), query)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
results = append(results, searchResult{MessageHeader: hdr, FolderName: f.DisplayName, AccountID: src.id, AccountLabel: src.label})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
ti, _ := mail.ParseDate(results[i].Date)
|
||||
tj, _ := mail.ParseDate(results[j].Date)
|
||||
return ti.After(tj)
|
||||
})
|
||||
if len(results) > maxResults {
|
||||
results = results[:maxResults]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": results, "truncated": truncated, "warnings": warnings})
|
||||
}
|
||||
|
||||
// ── Calendar/contacts (linked accounts only — local calendar/contacts are
|
||||
// served by internal/dav's CalDAV/CardDAV server, a separate protocol) ──────
|
||||
|
||||
// linkedProviderFor looks up id, checks it belongs to user (same ownership
|
||||
// check provider()/unifiedInbox already use), and builds its MailProvider.
|
||||
// Unlike provider(), there is no "local account" fallback here — local
|
||||
// calendar/contacts have no REST path, only CalDAV/CardDAV.
|
||||
func (h *Handler) linkedProviderFor(user *db.User, id string) (accounts.MailProvider, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("account is required")
|
||||
}
|
||||
acct, err := h.database.GetLinkedAccount(id)
|
||||
if err != nil || acct.UserID != user.ID {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||
}
|
||||
|
||||
func (h *Handler) calendarEvents(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
cp, ok := p.(accounts.CalendarProvider)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "this account type does not support calendar access")
|
||||
return
|
||||
}
|
||||
|
||||
from := time.Now().UTC().AddDate(0, 0, -30)
|
||||
to := time.Now().UTC().AddDate(0, 0, 30)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
|
||||
events, err := cp.ListEvents(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func (h *Handler) contacts(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
cp, ok := p.(accounts.ContactProvider)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "this account type does not support contacts access")
|
||||
return
|
||||
}
|
||||
list, err := cp.ListContacts(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// ── Quarantine ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
@@ -530,6 +900,44 @@ func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"})
|
||||
}
|
||||
|
||||
// linkIMAPAccount handles POST /api/accounts/imap — the password-based
|
||||
// counterpart to the OAuth linking flow, for a generic IMAP/SMTP provider.
|
||||
// Wraps accounts.LinkIMAPAccount, which already existed and was already
|
||||
// fully wired for encrypted credential storage; this was the only missing
|
||||
// piece, an HTTP entry point for it.
|
||||
func (h *Handler) linkIMAPAccount(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string
|
||||
Email string
|
||||
Password string
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPTLS string
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPTLS string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
|
||||
req.Email == "" || req.Password == "" || req.IMAPHost == "" || req.SMTPHost == "" {
|
||||
writeErr(w, http.StatusBadRequest, "email, password, imap_host, and smtp_host are required")
|
||||
return
|
||||
}
|
||||
if req.DisplayName == "" {
|
||||
req.DisplayName = req.Email
|
||||
}
|
||||
account, err := accounts.LinkIMAPAccount(h.database, h.mk, user.ID, req.DisplayName, req.Email, req.Password,
|
||||
req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": account.ID})
|
||||
}
|
||||
|
||||
// oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback.
|
||||
// start requires an authenticated session (checked inline, not via withAuth,
|
||||
// since callback intentionally does NOT require one — it's a plain browser
|
||||
@@ -622,14 +1030,11 @@ func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider
|
||||
dbProvider = db.ProviderM365
|
||||
}
|
||||
|
||||
// Note: a real implementation would call the provider's userinfo/profile
|
||||
// endpoint here to learn the account's actual email address rather than
|
||||
// require it as a query param — deferred; for now the display name is
|
||||
// generic and the operator/user can rename it, matching the minimum
|
||||
// needed to prove the OAuth2 flow itself is correct end-to-end.
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
email = provider + "-account"
|
||||
email, err := accounts.FetchOAuth2Email(r.Context(), provider, token.AccessToken)
|
||||
if err != nil {
|
||||
slog.Error("failed to look up account email from provider", "provider", provider, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "failed to look up account email")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token)
|
||||
@@ -764,9 +1169,340 @@ func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.Us
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.database.RecomputeMFAEnabled(user.ID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
|
||||
}
|
||||
|
||||
// ── Passkeys (WebAuthn) ──────────────────────────────────────────────────────
|
||||
// See internal/webauthn's package doc comment for the two deliberate scope
|
||||
// decisions (no attestation verification, ES256/P-256 only). Passkeys are
|
||||
// an alternative second factor alongside TOTP/backup codes — they redeem
|
||||
// the same mfa_pending token mfaVerify does, not a separate login flow.
|
||||
|
||||
func (h *Handler) origin() string { return "https://" + h.hostname }
|
||||
|
||||
func loadPasskeys(user *db.User) []webauthn.StoredCredential {
|
||||
var creds []webauthn.StoredCredential
|
||||
if user.PasskeyCredentialsJSON != "" {
|
||||
json.Unmarshal([]byte(user.PasskeyCredentialsJSON), &creds)
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
func savePasskeys(database *db.DB, userID string, creds []webauthn.StoredCredential) error {
|
||||
if creds == nil {
|
||||
creds = []webauthn.StoredCredential{}
|
||||
}
|
||||
b, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := database.SetPasskeyCredentials(userID, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
return database.RecomputeMFAEnabled(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyRegisterStart(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
challenge, err := webauthn.NewChallenge()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
||||
return
|
||||
}
|
||||
|
||||
h.webauthnMu.Lock()
|
||||
h.pruneExpiredWebauthnState()
|
||||
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
||||
h.webauthnMu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rp": map[string]string{"id": h.hostname, "name": "GoMail"},
|
||||
"user": map[string]string{"id": base64.RawURLEncoding.EncodeToString([]byte(user.ID)), "name": user.Email, "displayName": user.DisplayName},
|
||||
"challenge": challenge,
|
||||
"pubKeyCredParams": []map[string]any{
|
||||
{"alg": -7, "type": "public-key"}, // ES256 — see internal/webauthn's scope doc comment
|
||||
},
|
||||
"timeout": 60000,
|
||||
"attestation": "none",
|
||||
"authenticatorSelection": map[string]string{"userVerification": "preferred"},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Challenge string
|
||||
Name string
|
||||
ClientDataJSON string
|
||||
AttestationObject string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
h.webauthnMu.Lock()
|
||||
entry, ok := h.webauthnState[req.Challenge]
|
||||
if ok {
|
||||
delete(h.webauthnState, req.Challenge) // one-time use
|
||||
}
|
||||
h.webauthnMu.Unlock()
|
||||
if !ok || entry.UserID != user.ID || time.Now().UTC().After(entry.ExpiresAt) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired registration challenge")
|
||||
return
|
||||
}
|
||||
|
||||
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
||||
attestationObject, err2 := base64.RawURLEncoding.DecodeString(req.AttestationObject)
|
||||
if err1 != nil || err2 != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
||||
return
|
||||
}
|
||||
|
||||
authData, err := webauthn.VerifyRegistration(clientDataJSON, attestationObject, req.Challenge, h.hostname, h.origin())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "passkey registration failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
cred := webauthn.StoredCredential{
|
||||
ID: base64.RawURLEncoding.EncodeToString(authData.CredentialID),
|
||||
PublicKey: webauthn.EncodePublicKey(authData.PublicKey),
|
||||
SignCount: authData.SignCount,
|
||||
Name: name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
// withAuth's own row fetch doesn't select passkey_credentials_json
|
||||
// (most callers don't need it) — GetUser is the canonical full-row
|
||||
// fetch that does; using the withAuth-provided user here would silently
|
||||
// discard every previously registered passkey on each new one.
|
||||
fresh, err := h.database.GetUser(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
creds := append(loadPasskeys(fresh), cred)
|
||||
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey added"})
|
||||
}
|
||||
|
||||
func (h *Handler) passkeys(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
type safeCred struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
fresh, err := h.database.GetUser(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
creds := loadPasskeys(fresh)
|
||||
out := make([]safeCred, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
out = append(out, safeCred{ID: c.ID, Name: c.Name, CreatedAt: c.CreatedAt})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/me/passkeys/")
|
||||
fresh, err := h.database.GetUser(user.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
creds := loadPasskeys(fresh)
|
||||
kept := make([]webauthn.StoredCredential, 0, len(creds))
|
||||
found := false
|
||||
for _, c := range creds {
|
||||
if c.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
if !found {
|
||||
writeErr(w, http.StatusNotFound, "passkey not found")
|
||||
return
|
||||
}
|
||||
if err := savePasskeys(h.database, user.ID, kept); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey removed"})
|
||||
}
|
||||
|
||||
func (h *Handler) pruneExpiredWebauthnState() {
|
||||
now := time.Now().UTC()
|
||||
for k, v := range h.webauthnState {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(h.webauthnState, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// passkeyLoginStart handles POST /api/auth/passkey/start — takes the same
|
||||
// mfa_pending token login() issues, returns a WebAuthn assertion challenge
|
||||
// listing the user's registered credentials.
|
||||
func (h *Handler) passkeyLoginStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct{ MFAToken string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
||||
if err != nil || claims.Purpose != "mfa_pending" {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
||||
return
|
||||
}
|
||||
user, err := h.database.GetUser(claims.Subject)
|
||||
if err != nil || !user.Active {
|
||||
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
||||
return
|
||||
}
|
||||
creds := loadPasskeys(user)
|
||||
if len(creds) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "no passkeys registered for this account")
|
||||
return
|
||||
}
|
||||
|
||||
challenge, err := webauthn.NewChallenge()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
||||
return
|
||||
}
|
||||
h.webauthnMu.Lock()
|
||||
h.pruneExpiredWebauthnState()
|
||||
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
||||
h.webauthnMu.Unlock()
|
||||
|
||||
allow := make([]map[string]string, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
allow = append(allow, map[string]string{"id": c.ID, "type": "public-key"})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rpId": h.hostname, "challenge": challenge, "timeout": 60000,
|
||||
"userVerification": "preferred", "allowCredentials": allow,
|
||||
})
|
||||
}
|
||||
|
||||
// passkeyLoginFinish handles POST /api/auth/passkey/finish — verifies the
|
||||
// assertion and, on success, redeems the mfa_pending token for a real
|
||||
// session exactly like mfaVerify does.
|
||||
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MFAToken string
|
||||
Challenge string
|
||||
CredentialID string
|
||||
ClientDataJSON string
|
||||
AuthenticatorData string
|
||||
Signature string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
||||
if err != nil || claims.Purpose != "mfa_pending" {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
||||
return
|
||||
}
|
||||
|
||||
h.webauthnMu.Lock()
|
||||
entry, ok := h.webauthnState[req.Challenge]
|
||||
if ok {
|
||||
delete(h.webauthnState, req.Challenge) // one-time use
|
||||
}
|
||||
h.webauthnMu.Unlock()
|
||||
if !ok || entry.UserID != claims.Subject || time.Now().UTC().After(entry.ExpiresAt) {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid or expired passkey challenge")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(claims.Subject)
|
||||
if err != nil || !user.Active {
|
||||
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
||||
return
|
||||
}
|
||||
creds := loadPasskeys(user)
|
||||
idx := -1
|
||||
for i, c := range creds {
|
||||
if c.ID == req.CredentialID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
writeErr(w, http.StatusUnauthorized, "unknown credential")
|
||||
return
|
||||
}
|
||||
|
||||
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
||||
authenticatorData, err2 := base64.RawURLEncoding.DecodeString(req.AuthenticatorData)
|
||||
signature, err3 := base64.RawURLEncoding.DecodeString(req.Signature)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
||||
return
|
||||
}
|
||||
|
||||
newSignCount, err := webauthn.VerifyAssertion(creds[idx], clientDataJSON, authenticatorData, signature, req.Challenge, h.hostname, h.origin())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "passkey verification failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
creds[idx].SignCount = newSignCount
|
||||
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
||||
return
|
||||
}
|
||||
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"token": token,
|
||||
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
|
||||
})
|
||||
}
|
||||
|
||||
// ── App passwords ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
|
||||
@@ -2,5 +2,5 @@ package webmail
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/index.html
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
const API = '/api';
|
||||
let token = localStorage.getItem('gomail_token') || '';
|
||||
let me = null;
|
||||
let accounts = []; // [{id:'', label, provider:'local'}, ...linked]
|
||||
let currentAccountId = 'UNIFIED';
|
||||
let currentFolder = 'INBOX';
|
||||
let folders = [];
|
||||
let loadedMessages = []; // last-fetched folder/unified-inbox contents
|
||||
let searchQuery = '';
|
||||
let searchResults = null; // null = not searching; array = server search results
|
||||
let searchTruncated = false;
|
||||
let searchDebounceTimer = null;
|
||||
let selectedKey = '';
|
||||
let pendingMFAToken = '';
|
||||
|
||||
// ── fetch helper ──────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(API + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, ...(opts.headers || {}) } });
|
||||
if (r.status === 401) { showLogin(); return null; }
|
||||
return r.ok ? r.json() : Promise.reject(await r.json());
|
||||
}
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
function bodyOf(raw) {
|
||||
if (!raw) return '';
|
||||
const decoded = atob(raw);
|
||||
const idx = decoded.indexOf('\r\n\r\n');
|
||||
return idx >= 0 ? decoded.slice(idx + 4) : decoded;
|
||||
}
|
||||
|
||||
function formatDate(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
return isNaN(d) ? s : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function initial(label) { return (label || '?').trim().charAt(0).toUpperCase() || '?'; }
|
||||
|
||||
// account-scoping: '' (local) omits the query param, matching provider()'s
|
||||
// own default-to-local convention server-side.
|
||||
function acctQuery(id) { return id ? '?account=' + encodeURIComponent(id) : ''; }
|
||||
|
||||
// ── auth ──────────────────────────────────────────────────────────────────
|
||||
async function login() {
|
||||
const email = document.getElementById('le').value, pwd = document.getElementById('lp').value;
|
||||
try {
|
||||
const d = await fetch(API + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Email: email, Password: pwd }) }).then(r => r.json());
|
||||
if (d.error) throw new Error(d.error);
|
||||
if (d.mfa_required) { pendingMFAToken = d.mfa_token; showMFALogin(); return; }
|
||||
token = d.token; localStorage.setItem('gomail_token', token);
|
||||
showApp();
|
||||
} catch (e) { const el = document.getElementById('lerr'); el.textContent = e.message || 'Login failed'; el.style.display = ''; }
|
||||
}
|
||||
|
||||
async function mfaVerifyLogin() {
|
||||
const code = document.getElementById('mfa-code').value;
|
||||
try {
|
||||
const d = await fetch(API + '/auth/mfa-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ MFAToken: pendingMFAToken, Code: code }) }).then(r => r.json());
|
||||
if (d.error) throw new Error(d.error);
|
||||
token = d.token; localStorage.setItem('gomail_token', token);
|
||||
showApp();
|
||||
} catch (e) { const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Invalid code'; el.style.display = ''; }
|
||||
}
|
||||
|
||||
function logout() { localStorage.removeItem('gomail_token'); token = ''; showLogin(); }
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login').style.display = 'flex';
|
||||
document.getElementById('mfa-login').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'none';
|
||||
}
|
||||
function showMFALogin() {
|
||||
document.getElementById('login').style.display = 'none';
|
||||
document.getElementById('mfa-login').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function showApp() {
|
||||
document.getElementById('login').style.display = 'none';
|
||||
document.getElementById('mfa-login').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
me = await api('/me'); if (!me) return;
|
||||
document.getElementById('me-email').textContent = me.email;
|
||||
await loadAccounts();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!token) { showLogin(); return; }
|
||||
try { const m = await api('/me'); if (m) { me = m; document.getElementById('app').style.display = 'flex'; document.getElementById('me-email').textContent = me.email; await loadAccounts(); } else showLogin(); }
|
||||
catch { showLogin(); }
|
||||
}
|
||||
|
||||
// ── accounts ──────────────────────────────────────────────────────────────
|
||||
async function loadAccounts() {
|
||||
const linked = await api('/accounts') || [];
|
||||
accounts = [{ id: '', label: me.email, provider: 'local' }, ...linked.map(a => ({ id: a.id, label: a.display_name || a.email_address, provider: a.provider }))];
|
||||
renderAccountSwitcher();
|
||||
await selectAccount('UNIFIED');
|
||||
}
|
||||
|
||||
function renderAccountSwitcher() {
|
||||
const rows = [{ id: 'UNIFIED', label: 'Unified Inbox', icon: '✦' }, ...accounts];
|
||||
document.getElementById('account-switcher').innerHTML = rows.map(a => `
|
||||
<div class="nav-row ${a.id === currentAccountId ? 'active' : ''}" onclick="selectAccount('${esc(a.id)}')">
|
||||
<div class="seal">${a.icon || esc(initial(a.label))}</div>
|
||||
<div class="nav-row-label">${esc(a.label)}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = ''; searchResults = null; searchTruncated = false;
|
||||
const box = document.getElementById('search-box');
|
||||
if (box) box.value = '';
|
||||
const toggle = document.getElementById('search-body-toggle');
|
||||
if (toggle) toggle.style.display = 'none';
|
||||
}
|
||||
|
||||
async function selectAccount(id) {
|
||||
clearSearch();
|
||||
currentAccountId = id;
|
||||
renderAccountSwitcher();
|
||||
document.getElementById('view-mail').style.display = 'flex';
|
||||
document.getElementById('view-quarantine').style.display = 'none';
|
||||
document.getElementById('view-settings').style.display = 'none';
|
||||
const existingWarning = document.getElementById('unified-warning');
|
||||
if (existingWarning) existingWarning.remove();
|
||||
if (id === 'UNIFIED') {
|
||||
document.getElementById('folder-section').style.display = 'none';
|
||||
document.getElementById('list-title').textContent = 'Unified Inbox';
|
||||
await loadUnifiedInbox();
|
||||
} else {
|
||||
document.getElementById('folder-section').style.display = '';
|
||||
await loadFolders();
|
||||
}
|
||||
}
|
||||
|
||||
// ── folders (per-account view) ───────────────────────────────────────────
|
||||
async function loadFolders() {
|
||||
folders = await api('/folders' + acctQuery(currentAccountId)) || [];
|
||||
if (!folders.find(f => f.id === currentFolder)) {
|
||||
const inbox = folders.find(f => f.type === 'inbox');
|
||||
currentFolder = inbox ? inbox.id : (folders[0] ? folders[0].id : 'INBOX');
|
||||
}
|
||||
renderFolderList();
|
||||
await loadMessages(currentFolder);
|
||||
}
|
||||
|
||||
function renderFolderList() {
|
||||
document.getElementById('folder-list').innerHTML = folders.map(f => `
|
||||
<div class="nav-row ${f.id === currentFolder ? 'active' : ''}" onclick="selectFolder('${esc(f.id)}')">
|
||||
<div class="seal">${f.unread_count > 0 ? '<span class="dot"></span>' : ''}</div>
|
||||
<div class="nav-row-label">${esc(f.display_name)}</div>
|
||||
<div class="count-badge">${f.unread_count > 0 ? f.unread_count : ''}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function selectFolder(id) {
|
||||
clearSearch();
|
||||
currentFolder = id;
|
||||
renderFolderList();
|
||||
const f = folders.find(x => x.id === id);
|
||||
document.getElementById('list-title').textContent = f ? f.display_name : id;
|
||||
await loadMessages(id);
|
||||
}
|
||||
|
||||
// ── messages ──────────────────────────────────────────────────────────────
|
||||
async function loadMessages(folderID) {
|
||||
loadedMessages = await api('/folders/' + folderID + '/messages' + acctQuery(currentAccountId)) || [];
|
||||
renderMessageList();
|
||||
}
|
||||
|
||||
async function loadUnifiedInbox() {
|
||||
const res = await api('/inbox/unified'); if (!res) return;
|
||||
loadedMessages = res.messages || [];
|
||||
renderMessageList();
|
||||
const existing = document.getElementById('unified-warning');
|
||||
if (existing) existing.remove();
|
||||
if (res.warnings && res.warnings.length) {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'unified-warning';
|
||||
notice.className = 'notice';
|
||||
notice.style.margin = '0 12px 8px';
|
||||
notice.textContent = 'Some accounts could not be reached: ' + res.warnings.join('; ');
|
||||
document.getElementById('list-title').insertAdjacentElement('afterend', notice);
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput(v) {
|
||||
searchQuery = v;
|
||||
clearTimeout(searchDebounceTimer);
|
||||
document.getElementById('search-body-toggle').style.display = v ? '' : 'none';
|
||||
if (!v) { searchResults = null; renderMessageList(); return; }
|
||||
searchDebounceTimer = setTimeout(() => runSearch(false), 300);
|
||||
}
|
||||
|
||||
// runSearch calls the real server-side search (internal/webmail/api.go's
|
||||
// search handler). Unified view sends no ?account=, so the server fans the
|
||||
// search out across the local mailbox and every linked account (each
|
||||
// result tagged with account_id/account_label, like the unified inbox);
|
||||
// otherwise it's scoped to the one selected account.
|
||||
async function runSearch(withBody) {
|
||||
const q = searchQuery;
|
||||
if (!q) return;
|
||||
const realAccountId = currentAccountId === 'UNIFIED' ? '' : currentAccountId;
|
||||
let url = '/search?q=' + encodeURIComponent(q);
|
||||
if (withBody) url += '&body=1';
|
||||
if (realAccountId) url += '&account=' + encodeURIComponent(realAccountId);
|
||||
try {
|
||||
const res = await api(url);
|
||||
if (!res) return;
|
||||
searchResults = res.messages || [];
|
||||
searchTruncated = !!res.truncated;
|
||||
renderMessageList();
|
||||
} catch (e) { /* transient — leave prior results/state as-is */ }
|
||||
}
|
||||
|
||||
function renderMessageList() {
|
||||
const list = document.getElementById('msg-list');
|
||||
|
||||
if (searchResults !== null) {
|
||||
const notice = searchTruncated ? '<div class="notice" style="margin:0 12px 8px">Showing partial results — narrow your search for a complete list.</div>' : '';
|
||||
if (!searchResults.length) { list.innerHTML = notice + '<div style="padding:20px;color:var(--text-faint);text-align:center">No matches</div>'; return; }
|
||||
list.innerHTML = notice + searchResults.map(m => {
|
||||
const unread = !(m.flags || []).includes('\\Seen');
|
||||
const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId;
|
||||
const key = acct + '|' + m.folder_id + '|' + m.id;
|
||||
return `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(m.folder_id)}','${esc(m.id)}')">
|
||||
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||||
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||||
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span class="chip">${esc(m.folder_name || '')}</span><span>${esc(formatDate(m.date))}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loadedMessages.length) { list.innerHTML = '<div style="padding:20px;color:var(--text-faint);text-align:center">No messages</div>'; return; }
|
||||
list.innerHTML = loadedMessages.map(m => {
|
||||
const unread = !(m.flags || []).includes('\\Seen');
|
||||
const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId;
|
||||
const folderId = currentAccountId === 'UNIFIED' ? m.folder_id : currentFolder;
|
||||
const key = acct + '|' + folderId + '|' + m.id;
|
||||
return `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(folderId)}','${esc(m.id)}')">
|
||||
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||||
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||||
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span>${esc(formatDate(m.date))}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function viewMessage(acct, folderId, id) {
|
||||
selectedKey = acct + '|' + folderId + '|' + id;
|
||||
renderMessageList();
|
||||
const msg = await api('/messages/' + folderId + '/' + id + acctQuery(acct)); if (!msg) return;
|
||||
document.getElementById('msg-view').innerHTML = `
|
||||
<div class="reading-subject">${esc(msg.subject || '(no subject)')}</div>
|
||||
<div class="reading-meta">
|
||||
<div>From: ${esc(msg.from)}</div>
|
||||
<div>To: ${esc(msg.to)}</div>
|
||||
<div>${esc(formatDate(msg.date))}</div>
|
||||
</div>
|
||||
<div class="reading-body">${esc(bodyOf(msg.raw))}</div>
|
||||
<div style="margin-top:20px;display:flex;gap:8px">
|
||||
<button onclick="deleteMessage('${esc(acct)}','${esc(folderId)}','${esc(id)}')" class="btn btn-ghost">Delete</button>
|
||||
</div>`;
|
||||
api('/messages/' + folderId + '/' + id + '/flags' + acctQuery(acct), { method: 'PUT', body: JSON.stringify({ Flags: ['\\Seen'] }) });
|
||||
}
|
||||
|
||||
async function deleteMessage(acct, folderId, id) {
|
||||
await api('/messages/' + folderId + '/' + id + acctQuery(acct), { method: 'DELETE' });
|
||||
document.getElementById('msg-view').innerHTML = '<div class="reading-empty">Select a message</div>';
|
||||
if (currentAccountId === 'UNIFIED') await loadUnifiedInbox(); else await loadMessages(currentFolder);
|
||||
}
|
||||
|
||||
// ── compose ───────────────────────────────────────────────────────────────
|
||||
function openCompose() {
|
||||
const sel = document.getElementById('c-from');
|
||||
sel.innerHTML = accounts.map(a => `<option value="${esc(a.id)}">${esc(a.label)}</option>`).join('');
|
||||
sel.value = currentAccountId === 'UNIFIED' ? '' : currentAccountId;
|
||||
document.getElementById('compose-modal').style.display = 'flex';
|
||||
}
|
||||
function closeCompose() { document.getElementById('compose-modal').style.display = 'none'; }
|
||||
|
||||
async function sendMessage() {
|
||||
const from = document.getElementById('c-from').value;
|
||||
const to = document.getElementById('c-to').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const subject = document.getElementById('c-subject').value;
|
||||
const body = document.getElementById('c-body').value;
|
||||
try {
|
||||
await api('/messages' + acctQuery(from), { method: 'POST', body: JSON.stringify({ to, subject, body }) });
|
||||
closeCompose();
|
||||
document.getElementById('c-to').value = ''; document.getElementById('c-subject').value = ''; document.getElementById('c-body').value = '';
|
||||
} catch (e) { alert('Send failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
// ── quarantine ────────────────────────────────────────────────────────────
|
||||
async function showQuarantine() {
|
||||
document.getElementById('view-mail').style.display = 'none';
|
||||
document.getElementById('view-settings').style.display = 'none';
|
||||
document.getElementById('view-quarantine').style.display = 'block';
|
||||
const entries = await api('/quarantine'); if (!entries) return;
|
||||
document.getElementById('quarantine-list').innerHTML = entries.length ? entries.map(e => `
|
||||
<div class="card" style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div><div style="font-size:13px">Reason: ${esc(e.Reason || '—')}</div>
|
||||
<div style="color:var(--text-faint);font-size:12px">Held: ${esc(e.CreatedAt)}</div></div>
|
||||
<button onclick="releaseQ('${esc(e.ID)}')" class="btn btn-primary">Release</button>
|
||||
</div>`).join('') : '<div style="color:var(--text-faint);text-align:center;padding:40px">Nothing held</div>';
|
||||
}
|
||||
async function releaseQ(id) {
|
||||
try { await api('/quarantine/' + id + '/release', { method: 'POST' }); showQuarantine(); }
|
||||
catch (e) { alert('Release failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
// ── settings ──────────────────────────────────────────────────────────────
|
||||
async function showSettings() {
|
||||
document.getElementById('view-mail').style.display = 'none';
|
||||
document.getElementById('view-quarantine').style.display = 'none';
|
||||
document.getElementById('view-settings').style.display = 'block';
|
||||
await renderSettings();
|
||||
}
|
||||
|
||||
async function renderSettings() {
|
||||
me = await api('/me') || me;
|
||||
const body = document.getElementById('settings-body');
|
||||
body.innerHTML = `
|
||||
<div class="settings-section card">
|
||||
<h3>Two-factor authentication</h3>
|
||||
<p class="hint">${me.mfa_enabled ? 'Enabled — a code or passkey is required at every sign-in.' : 'Not enabled. Add a code from an authenticator app or a passkey for a second sign-in step.'}</p>
|
||||
<div id="mfa-area"></div>
|
||||
</div>
|
||||
<div class="settings-section card">
|
||||
<h3>Passkeys</h3>
|
||||
<p class="hint">A device, security key, or platform authenticator (Touch ID, Windows Hello) you can sign in with instead of typing a code.</p>
|
||||
<div id="passkeys-area"></div>
|
||||
<button onclick="addPasskey()" class="btn btn-primary" style="margin-top:10px">Add a passkey</button>
|
||||
</div>
|
||||
<div class="settings-section card">
|
||||
<h3>Recovery email</h3>
|
||||
<p class="hint">Used for password reset — not your own mailbox, so you can't get locked out of it.</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input id="recovery-email-input" class="inp" placeholder="you@elsewhere.example" value="${esc(me.recovery_email || '')}">
|
||||
<button onclick="saveRecoveryEmail()" class="btn btn-primary" style="flex:none">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section card">
|
||||
<h3>App passwords</h3>
|
||||
<p class="hint">For mail clients that need a password instead of your real one — IMAP/SMTP/POP3 login.</p>
|
||||
<div id="app-passwords-area"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px">
|
||||
<input id="app-pw-label" class="inp" placeholder="Label, e.g. \"Phone Mail app\"">
|
||||
<button onclick="createAppPassword()" class="btn btn-primary" style="flex:none">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section card">
|
||||
<h3>Linked accounts</h3>
|
||||
<p class="hint">Other mailboxes shown in Unified Inbox and the account switcher.</p>
|
||||
<div id="linked-accounts-area"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:14px">
|
||||
<button onclick="startOAuth('google')" class="btn btn-ghost">Link Google account</button>
|
||||
<button onclick="startOAuth('microsoft')" class="btn btn-ghost">Link Microsoft account</button>
|
||||
<button onclick="toggleImapForm()" class="btn btn-ghost">Add IMAP account</button>
|
||||
</div>
|
||||
<div id="imap-form" style="display:none;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">
|
||||
<div class="field"><input id="imap-email" class="inp" placeholder="Email address"></div>
|
||||
<div class="field"><input id="imap-password" type="password" class="inp" placeholder="Password"></div>
|
||||
<div class="field" style="display:flex;gap:8px">
|
||||
<input id="imap-host" class="inp" placeholder="IMAP host">
|
||||
<input id="imap-port" class="inp" placeholder="993" style="width:90px">
|
||||
</div>
|
||||
<div class="field" style="display:flex;gap:8px">
|
||||
<input id="smtp-host" class="inp" placeholder="SMTP host">
|
||||
<input id="smtp-port" class="inp" placeholder="465" style="width:90px">
|
||||
</div>
|
||||
<button onclick="submitImapAccount()" class="btn btn-primary">Add account</button>
|
||||
</div>
|
||||
</div>`;
|
||||
renderMFAArea();
|
||||
renderPasskeys();
|
||||
renderAppPasswords();
|
||||
renderLinkedAccountsSettings();
|
||||
}
|
||||
|
||||
function renderMFAArea() {
|
||||
const area = document.getElementById('mfa-area');
|
||||
if (me.mfa_enabled) {
|
||||
area.innerHTML = `
|
||||
<div class="field"><input id="mfa-disable-pw" type="password" class="inp" placeholder="Current password" style="max-width:260px"></div>
|
||||
<button onclick="mfaDisableSubmit()" class="btn btn-danger">Disable two-factor</button>`;
|
||||
return;
|
||||
}
|
||||
area.innerHTML = `<button onclick="mfaSetupStart()" class="btn btn-primary">Set up two-factor</button>`;
|
||||
}
|
||||
|
||||
async function mfaSetupStart() {
|
||||
try {
|
||||
const d = await api('/me/mfa/setup', { method: 'POST' });
|
||||
document.getElementById('mfa-area').innerHTML = `
|
||||
<p class="hint">Scan isn't available here — enter this manually in your authenticator app (Google Authenticator, 1Password, etc.):</p>
|
||||
<div class="card" style="background:var(--bg);word-break:break-all;font-size:12px;margin-bottom:10px">${esc(d.provisioning_uri)}</div>
|
||||
<div class="field"><input id="mfa-confirm-code" class="inp" placeholder="Enter the 6-digit code" style="max-width:200px"></div>
|
||||
<button onclick="mfaConfirmSubmit()" class="btn btn-primary">Confirm</button>`;
|
||||
} catch (e) { alert('Setup failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function mfaConfirmSubmit() {
|
||||
const code = document.getElementById('mfa-confirm-code').value;
|
||||
try {
|
||||
const d = await api('/me/mfa/confirm', { method: 'POST', body: JSON.stringify({ Code: code }) });
|
||||
document.getElementById('mfa-area').innerHTML = `
|
||||
<div class="notice">Two-factor enabled. Save these backup codes somewhere safe — each works once if you lose access to your authenticator app.</div>
|
||||
<div class="card" style="background:var(--bg);font-family:monospace;font-size:13px;line-height:1.8">${d.backup_codes.map(esc).join('<br>')}</div>`;
|
||||
me.mfa_enabled = true;
|
||||
} catch (e) { alert('Invalid code: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function mfaDisableSubmit() {
|
||||
const pw = document.getElementById('mfa-disable-pw').value;
|
||||
try {
|
||||
await api('/me/mfa/disable', { method: 'POST', body: JSON.stringify({ Password: pw }) });
|
||||
me.mfa_enabled = false;
|
||||
renderMFAArea();
|
||||
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
// ── passkeys (WebAuthn) ─────────────────────────────────────────────────
|
||||
function b64urlToBuf(b64url) {
|
||||
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
|
||||
const raw = atob(b64 + pad);
|
||||
const buf = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let str = '';
|
||||
for (const b of bytes) str += String.fromCharCode(b);
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function renderPasskeys() {
|
||||
const area = document.getElementById('passkeys-area');
|
||||
const list = await api('/me/passkeys') || [];
|
||||
area.innerHTML = list.length ? list.map(p => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<div><div style="font-size:13px">${esc(p.name)}</div><div style="color:var(--text-faint);font-size:11px">Added ${esc(formatDate(p.created_at))}</div></div>
|
||||
<button onclick="deletePasskey('${esc(p.id)}')" class="btn btn-ghost">Remove</button>
|
||||
</div>`).join('') : '<p class="hint">No passkeys registered yet.</p>';
|
||||
}
|
||||
|
||||
async function addPasskey() {
|
||||
if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; }
|
||||
const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey');
|
||||
if (name === null) return;
|
||||
try {
|
||||
const options = await api('/me/passkeys/register/start', { method: 'POST' });
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
rp: options.rp,
|
||||
user: { id: b64urlToBuf(options.user.id), name: options.user.name, displayName: options.user.displayName },
|
||||
challenge: b64urlToBuf(options.challenge),
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
},
|
||||
});
|
||||
await api('/me/passkeys/register/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
Challenge: options.challenge,
|
||||
Name: name || 'Passkey',
|
||||
ClientDataJSON: bufToB64url(credential.response.clientDataJSON),
|
||||
AttestationObject: bufToB64url(credential.response.attestationObject),
|
||||
}),
|
||||
});
|
||||
renderPasskeys();
|
||||
} catch (e) { alert('Failed to add passkey: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function deletePasskey(id) {
|
||||
try { await api('/me/passkeys/' + encodeURIComponent(id), { method: 'DELETE' }); renderPasskeys(); }
|
||||
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function usePasskeyLogin() {
|
||||
if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; }
|
||||
try {
|
||||
const options = await fetch(API + '/auth/passkey/start', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ MFAToken: pendingMFAToken }),
|
||||
}).then(r => r.json());
|
||||
if (options.error) throw new Error(options.error);
|
||||
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
rpId: options.rpId,
|
||||
challenge: b64urlToBuf(options.challenge),
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
allowCredentials: options.allowCredentials.map(c => ({ id: b64urlToBuf(c.id), type: c.type })),
|
||||
},
|
||||
});
|
||||
|
||||
const d = await fetch(API + '/auth/passkey/finish', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
MFAToken: pendingMFAToken,
|
||||
Challenge: options.challenge,
|
||||
CredentialID: bufToB64url(assertion.rawId),
|
||||
ClientDataJSON: bufToB64url(assertion.response.clientDataJSON),
|
||||
AuthenticatorData: bufToB64url(assertion.response.authenticatorData),
|
||||
Signature: bufToB64url(assertion.response.signature),
|
||||
}),
|
||||
}).then(r => r.json());
|
||||
if (d.error) throw new Error(d.error);
|
||||
token = d.token; localStorage.setItem('gomail_token', token);
|
||||
showApp();
|
||||
} catch (e) {
|
||||
const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Passkey login failed'; el.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecoveryEmail() {
|
||||
const v = document.getElementById('recovery-email-input').value;
|
||||
try { await api('/me/recovery-email', { method: 'POST', body: JSON.stringify({ RecoveryEmail: v }) }); }
|
||||
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function renderAppPasswords() {
|
||||
const area = document.getElementById('app-passwords-area');
|
||||
const list = await api('/me/app-passwords') || [];
|
||||
area.innerHTML = list.length ? list.map(e => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<div><div style="font-size:13px">${esc(e.Label)}</div><div style="color:var(--text-faint);font-size:11px">Scopes: ${esc(e.Scopes)}</div></div>
|
||||
<button onclick="deleteAppPassword('${esc(e.ID)}')" class="btn btn-ghost">Revoke</button>
|
||||
</div>`).join('') : '<p class="hint">No app passwords yet.</p>';
|
||||
}
|
||||
|
||||
async function createAppPassword() {
|
||||
const label = document.getElementById('app-pw-label').value;
|
||||
if (!label) return;
|
||||
try {
|
||||
const d = await api('/me/app-passwords', { method: 'POST', body: JSON.stringify({ Label: label }) });
|
||||
document.getElementById('app-passwords-area').insertAdjacentHTML('afterbegin',
|
||||
`<div class="notice">Copy this now, it won't be shown again: <strong>${esc(d.token)}</strong></div>`);
|
||||
document.getElementById('app-pw-label').value = '';
|
||||
renderAppPasswords();
|
||||
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function deleteAppPassword(id) {
|
||||
try { await api('/me/app-passwords/' + id, { method: 'DELETE' }); renderAppPasswords(); }
|
||||
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
function renderLinkedAccountsSettings() {
|
||||
const linked = accounts.filter(a => a.id);
|
||||
document.getElementById('linked-accounts-area').innerHTML = linked.length ? linked.map(a => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<div><div style="font-size:13px">${esc(a.label)}</div><div style="color:var(--text-faint);font-size:11px">${esc(a.provider)}</div></div>
|
||||
<button onclick="unlinkAccount('${esc(a.id)}')" class="btn btn-ghost">Unlink</button>
|
||||
</div>`).join('') : '<p class="hint">No linked accounts yet.</p>';
|
||||
}
|
||||
|
||||
async function unlinkAccount(id) {
|
||||
try { await api('/accounts/' + id, { method: 'DELETE' }); await loadAccounts(); renderLinkedAccountsSettings(); }
|
||||
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
async function startOAuth(provider) {
|
||||
try {
|
||||
const d = await api('/accounts/oauth/' + provider + '/start');
|
||||
window.location.href = d.auth_url;
|
||||
} catch (e) { alert(e.error || ('Failed to start ' + provider + ' linking')); }
|
||||
}
|
||||
|
||||
function toggleImapForm() {
|
||||
const el = document.getElementById('imap-form');
|
||||
el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function submitImapAccount() {
|
||||
const req = {
|
||||
Email: document.getElementById('imap-email').value,
|
||||
Password: document.getElementById('imap-password').value,
|
||||
IMAPHost: document.getElementById('imap-host').value,
|
||||
IMAPPort: parseInt(document.getElementById('imap-port').value, 10) || 993,
|
||||
IMAPTLS: 'implicit',
|
||||
SMTPHost: document.getElementById('smtp-host').value,
|
||||
SMTPPort: parseInt(document.getElementById('smtp-port').value, 10) || 465,
|
||||
SMTPTLS: 'implicit',
|
||||
};
|
||||
try {
|
||||
await api('/accounts/imap', { method: 'POST', body: JSON.stringify(req) });
|
||||
toggleImapForm();
|
||||
await loadAccounts();
|
||||
renderLinkedAccountsSettings();
|
||||
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||
}
|
||||
|
||||
boot();
|
||||
+158
-164
@@ -4,73 +4,187 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoMail</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
|
||||
.sidebar{width:220px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0;bottom:0}
|
||||
.main{margin-left:220px;display:flex;min-height:100vh}
|
||||
.msg-list{width:340px;border-right:1px solid #334155;overflow-y:auto}
|
||||
.msg-view{flex:1;padding:24px;overflow-y:auto}
|
||||
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
|
||||
.nav-item:hover{background:#334155}
|
||||
.nav-item.active{background:#7c3aed22;color:#a78bfa}
|
||||
.msg-row{padding:12px 16px;border-bottom:1px solid #1e293b;cursor:pointer;font-size:13px}
|
||||
.msg-row:hover{background:#1e293b80}
|
||||
.msg-row.unread{font-weight:600}
|
||||
.btn{padding:7px 14px;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;border:none}
|
||||
.btn-primary{background:#7c3aed;color:#fff}
|
||||
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
|
||||
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%}
|
||||
:root{
|
||||
--bg:#12141c;
|
||||
--surface:#1a1d29;
|
||||
--surface-2:#20232f;
|
||||
--border:#2a2e3d;
|
||||
--text:#e8e6e1;
|
||||
--text-muted:#8b8d98;
|
||||
--text-faint:#5b5e6b;
|
||||
--accent:#d9a441;
|
||||
--accent-dim:#d9a44122;
|
||||
--accent-text:#f0c878;
|
||||
--danger:#e2685a;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;margin:0;font-size:14px}
|
||||
.serif{font-family:'Source Serif 4',Georgia,serif}
|
||||
::selection{background:var(--accent-dim)}
|
||||
::-webkit-scrollbar{width:10px;height:10px}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
a{color:inherit}
|
||||
|
||||
/* ── layout shell ────────────────────────────────────────────── */
|
||||
.app-shell{display:flex;height:100vh;overflow:hidden}
|
||||
.rail{width:250px;flex:none;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column}
|
||||
.rail-header{padding:18px 16px 10px;display:flex;align-items:center;gap:8px}
|
||||
.rail-brand{font-weight:700;font-size:16px;letter-spacing:.01em}
|
||||
.rail-body{flex:1;overflow-y:auto;padding:4px 8px}
|
||||
.rail-footer{padding:10px 12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.workspace{flex:1;display:flex;min-width:0}
|
||||
|
||||
/* ── nav rows (accounts + folders) ──────────────────────────────── */
|
||||
.section-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);padding:14px 10px 6px}
|
||||
.nav-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;color:var(--text-muted);font-size:13px}
|
||||
.nav-row:hover{background:var(--surface-2)}
|
||||
.nav-row.active{background:var(--accent-dim);color:var(--accent-text)}
|
||||
.seal{width:22px;height:22px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;background:var(--surface-2);color:var(--text-muted);border:1px solid var(--border)}
|
||||
.nav-row.active .seal{background:var(--accent);color:#1a1206;border-color:var(--accent)}
|
||||
.nav-row-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.count-badge{font-size:11px;color:var(--text-faint);min-width:16px;text-align:right}
|
||||
.nav-row.active .count-badge{color:var(--accent-text)}
|
||||
|
||||
/* ── message list pane ──────────────────────────────────────────── */
|
||||
.list-pane{width:360px;flex:none;border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0}
|
||||
.list-toolbar{padding:12px;border-bottom:1px solid var(--border)}
|
||||
.list-title{font-weight:700;font-size:15px;margin-bottom:8px}
|
||||
.msg-list{flex:1;overflow-y:auto}
|
||||
.msg-row{padding:12px 14px;border-bottom:1px solid var(--border);cursor:pointer}
|
||||
.msg-row:hover{background:var(--surface)}
|
||||
.msg-row.selected{background:var(--accent-dim)}
|
||||
.msg-row .msg-from{display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px}
|
||||
.msg-row.unread .msg-from{font-weight:700}
|
||||
.msg-row .msg-subject{color:var(--text-muted);font-size:13px;margin-top:2px;font-family:'Source Serif 4',Georgia,serif}
|
||||
.msg-row .msg-meta{color:var(--text-faint);font-size:11px;margin-top:4px;display:flex;gap:6px;align-items:center}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);flex:none}
|
||||
.chip{font-size:10px;padding:1px 7px;border-radius:9px;background:var(--surface-2);color:var(--text-muted);border:1px solid var(--border)}
|
||||
|
||||
/* ── reading pane ────────────────────────────────────────────────── */
|
||||
.reading-pane{flex:1;overflow-y:auto;padding:28px 32px;min-width:0}
|
||||
.reading-empty{color:var(--text-faint);text-align:center;margin-top:80px}
|
||||
.reading-subject{font-family:'Source Serif 4',Georgia,serif;font-weight:700;font-size:21px;color:#fff}
|
||||
.reading-meta{color:var(--text-muted);font-size:13px;margin-top:6px;line-height:1.6}
|
||||
.reading-body{white-space:pre-wrap;font-family:inherit;color:#d4d2ce;font-size:13.5px;line-height:1.6;margin-top:20px}
|
||||
|
||||
/* ── generic controls ────────────────────────────────────────────── */
|
||||
.btn{padding:8px 15px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:filter .12s}
|
||||
.btn:hover{filter:brightness(1.1)}
|
||||
.btn-primary{background:var(--accent);color:#1a1206}
|
||||
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)}
|
||||
.btn-danger{background:transparent;color:var(--danger);border:1px solid #e2685a44}
|
||||
.btn:focus-visible,.inp:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.inp{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:13px;width:100%}
|
||||
.inp::placeholder{color:var(--text-faint)}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px}
|
||||
.view-single{flex:1;overflow-y:auto;padding:28px 32px}
|
||||
.modal-bg{position:fixed;inset:0;background:#00000088;z-index:50;display:flex;align-items:center;justify-content:center}
|
||||
.modal{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px;width:560px;max-width:95vw}
|
||||
.badge{padding:2px 8px;border-radius:10px;font-size:11px}
|
||||
.modal{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px;width:560px;max-width:95vw;max-height:88vh;overflow-y:auto}
|
||||
.label{font-size:12px;color:var(--text-muted);margin-bottom:4px;display:block}
|
||||
.field{margin-bottom:12px}
|
||||
.notice{background:var(--accent-dim);border:1px solid var(--accent);color:var(--accent-text);border-radius:8px;padding:10px 12px;font-size:12px;margin-bottom:12px}
|
||||
.settings-section{margin-bottom:22px}
|
||||
.settings-section h3{font-family:'Source Serif 4',Georgia,serif;font-size:17px;margin:0 0 4px}
|
||||
.settings-section p.hint{color:var(--text-muted);font-size:12px;margin:0 0 12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
||||
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
|
||||
<div style="text-align:center;margin-bottom:20px"><div style="font-size:2.5rem">📧</div>
|
||||
<h1 style="font-weight:700;color:#fff">GoMail</h1></div>
|
||||
<input id="le" class="inp" placeholder="you@example.com" style="margin-bottom:10px">
|
||||
<input id="lp" type="password" class="inp" placeholder="Password" style="margin-bottom:10px" onkeydown="if(event.key==='Enter')login()">
|
||||
<div class="card" style="width:320px">
|
||||
<div style="text-align:center;margin-bottom:20px">
|
||||
<div class="seal" style="width:44px;height:44px;font-size:18px;margin:0 auto 10px;background:var(--accent);color:#1a1206;border:none">G</div>
|
||||
<h1 class="serif" style="font-weight:700;color:#fff;font-size:22px;margin:0">GoMail</h1>
|
||||
</div>
|
||||
<div class="field"><input id="le" class="inp" placeholder="you@example.com"></div>
|
||||
<div class="field"><input id="lp" type="password" class="inp" placeholder="Password" onkeydown="if(event.key==='Enter')login()"></div>
|
||||
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
|
||||
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
|
||||
<p id="lerr" style="display:none;color:var(--danger);font-size:12px;text-align:center;margin-top:10px"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" style="display:none">
|
||||
<aside class="sidebar">
|
||||
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">📧 GoMail</div>
|
||||
<div style="padding:12px 8px">
|
||||
<button onclick="openCompose()" class="btn btn-primary" style="width:100%;margin-bottom:12px">✎ Compose</button>
|
||||
<div id="folder-list"></div>
|
||||
<div class="nav-item" onclick="showQuarantine()" id="nav-quarantine" style="margin-top:8px">🔒 Quarantine</div>
|
||||
<div id="mfa-login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
||||
<div class="card" style="width:320px">
|
||||
<h1 class="serif" style="font-weight:700;color:#fff;font-size:18px;margin:0 0 4px">Two-factor code</h1>
|
||||
<p class="hint" style="color:var(--text-muted);font-size:12px;margin:0 0 14px">Enter a code from your authenticator app, or a backup code.</p>
|
||||
<div class="field"><input id="mfa-code" class="inp" placeholder="123456" onkeydown="if(event.key==='Enter')mfaVerifyLogin()"></div>
|
||||
<button onclick="mfaVerifyLogin()" class="btn btn-primary" style="width:100%">Verify</button>
|
||||
<button onclick="usePasskeyLogin()" class="btn btn-ghost" style="width:100%;margin-top:8px">Use a passkey instead</button>
|
||||
<p id="mfaerr" style="display:none;color:var(--danger);font-size:12px;text-align:center;margin-top:10px"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" style="display:none" class="app-shell">
|
||||
<aside class="rail">
|
||||
<div class="rail-header">
|
||||
<div class="seal" style="background:var(--accent);color:#1a1206;border:none">G</div>
|
||||
<div class="rail-brand serif">GoMail</div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:0;padding:12px;border-top:1px solid #334155;width:100%;box-sizing:border-box">
|
||||
<span id="me-email" style="font-size:12px;color:#64748b"></span>
|
||||
<button onclick="logout()" style="float:right;font-size:11px;color:#475569;background:none;border:none;cursor:pointer">Logout</button>
|
||||
<div style="padding:0 12px 8px">
|
||||
<button onclick="openCompose()" class="btn btn-primary" style="width:100%">✎ Compose</button>
|
||||
</div>
|
||||
<div class="rail-body">
|
||||
<div class="section-label">Accounts</div>
|
||||
<div id="account-switcher"></div>
|
||||
<div id="folder-section">
|
||||
<div class="section-label">Folders</div>
|
||||
<div id="folder-list"></div>
|
||||
</div>
|
||||
<div class="section-label"> </div>
|
||||
<div class="nav-row" onclick="showQuarantine()" id="nav-quarantine">
|
||||
<div class="seal">🔒</div><div class="nav-row-label">Quarantine</div>
|
||||
</div>
|
||||
<div class="nav-row" onclick="showSettings()" id="nav-settings">
|
||||
<div class="seal">⚙</div><div class="nav-row-label">Settings</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-footer">
|
||||
<span id="me-email" style="font-size:12px;color:var(--text-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button onclick="logout()" style="font-size:11px;color:var(--text-faint);background:none;border:none;cursor:pointer;flex:none">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div id="view-mail" style="display:flex;flex:1">
|
||||
<div class="msg-list" id="msg-list"></div>
|
||||
<div class="msg-view" id="msg-view"><div style="color:#475569;text-align:center;margin-top:60px">Select a message</div></div>
|
||||
<main class="workspace">
|
||||
<div id="view-mail" style="display:flex;flex:1;min-width:0">
|
||||
<section class="list-pane">
|
||||
<div class="list-toolbar">
|
||||
<div class="list-title" id="list-title">Inbox</div>
|
||||
<input id="search-box" class="inp" placeholder="Search this account…" oninput="onSearchInput(this.value)">
|
||||
<div id="search-body-toggle" style="display:none;margin-top:6px">
|
||||
<a href="#" onclick="event.preventDefault();runSearch(true)" style="font-size:11px;color:var(--text-muted)">Search message bodies too (slower)</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-list" id="msg-list"></div>
|
||||
</section>
|
||||
<section class="reading-pane" id="msg-view"><div class="reading-empty">Select a message</div></section>
|
||||
</div>
|
||||
<div id="view-quarantine" style="display:none;flex:1;padding:24px">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
|
||||
<div id="view-quarantine" class="view-single" style="display:none">
|
||||
<h2 class="serif" style="font-weight:700;margin-bottom:16px">Quarantine</h2>
|
||||
<div id="quarantine-list"></div>
|
||||
</div>
|
||||
<div id="view-settings" class="view-single" style="display:none">
|
||||
<h2 class="serif" style="font-weight:700;margin-bottom:20px">Settings</h2>
|
||||
<div id="settings-body"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="compose-modal" class="modal-bg" style="display:none">
|
||||
<div class="modal">
|
||||
<h3 style="color:#fff;font-weight:700;margin-bottom:16px">New Message</h3>
|
||||
<input id="c-to" class="inp" placeholder="To" style="margin-bottom:8px">
|
||||
<input id="c-subject" class="inp" placeholder="Subject" style="margin-bottom:8px">
|
||||
<textarea id="c-body" class="inp" rows="8" placeholder="Message..." style="margin-bottom:12px"></textarea>
|
||||
<h3 class="serif" style="font-weight:700;margin-bottom:16px">New message</h3>
|
||||
<div class="field">
|
||||
<label class="label">From</label>
|
||||
<select id="c-from" class="inp"></select>
|
||||
</div>
|
||||
<div class="field"><input id="c-to" class="inp" placeholder="To"></div>
|
||||
<div class="field"><input id="c-subject" class="inp" placeholder="Subject"></div>
|
||||
<div class="field"><textarea id="c-body" class="inp" rows="8" placeholder="Write something…"></textarea></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</button>
|
||||
<button onclick="sendMessage()" class="btn btn-primary">Send</button>
|
||||
@@ -78,126 +192,6 @@ body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api';
|
||||
let token=localStorage.getItem('gomail_token')||'';
|
||||
let currentFolder='INBOX';
|
||||
|
||||
async function api(path,opts={}){
|
||||
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
|
||||
if(r.status===401){showLogin();return null;}
|
||||
return r.ok?r.json():Promise.reject(await r.json());
|
||||
}
|
||||
|
||||
async function login(){
|
||||
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
|
||||
try{
|
||||
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
|
||||
if(d.error)throw new Error(d.error);
|
||||
token=d.token;localStorage.setItem('gomail_token',token);
|
||||
showApp();
|
||||
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
|
||||
}
|
||||
function logout(){localStorage.removeItem('gomail_token');token='';showLogin();}
|
||||
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
|
||||
async function showApp(){
|
||||
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
|
||||
const me=await api('/me');if(!me)return;
|
||||
document.getElementById('me-email').textContent=me.email;
|
||||
loadFolders();
|
||||
}
|
||||
|
||||
async function loadFolders(){
|
||||
const folders=await api('/folders');if(!folders)return;
|
||||
document.getElementById('folder-list').innerHTML=folders.map(f=>
|
||||
`<div class="nav-item ${f.id===currentFolder?'active':''}" onclick="selectFolder('${f.id}')">
|
||||
${f.display_name} ${f.unread_count>0?`<span class="badge" style="background:#7c3aed;color:#fff">${f.unread_count}</span>`:''}
|
||||
</div>`).join('');
|
||||
loadMessages(currentFolder);
|
||||
}
|
||||
|
||||
function selectFolder(id){
|
||||
currentFolder=id;
|
||||
document.getElementById('view-mail').style.display='flex';
|
||||
document.getElementById('view-quarantine').style.display='none';
|
||||
loadFolders();
|
||||
}
|
||||
|
||||
async function loadMessages(folderID){
|
||||
const msgs=await api('/folders/'+folderID+'/messages');if(!msgs)return;
|
||||
document.getElementById('msg-list').innerHTML=msgs.length?msgs.map(m=>{
|
||||
const unread=!(m.Flags||[]).includes('\\Seen');
|
||||
return `<div class="msg-row ${unread?'unread':''}" onclick="viewMessage('${folderID}','${m.ID}')">
|
||||
<div style="color:#e2e8f0">${esc(m.From||'(unknown)')}</div>
|
||||
<div style="color:#94a3b8">${esc(m.Subject||'(no subject)')}</div>
|
||||
</div>`;
|
||||
}).join(''):'<div style="padding:20px;color:#475569;text-align:center">No messages</div>';
|
||||
}
|
||||
|
||||
async function viewMessage(folderID,id){
|
||||
const msg=await api('/messages/'+folderID+'/'+id);if(!msg)return;
|
||||
document.getElementById('msg-view').innerHTML=`
|
||||
<div style="border-bottom:1px solid #334155;padding-bottom:12px;margin-bottom:12px">
|
||||
<div style="font-size:18px;font-weight:700;color:#fff">${esc(msg.Subject||'(no subject)')}</div>
|
||||
<div style="color:#94a3b8;font-size:13px;margin-top:4px">From: ${esc(msg.From)}</div>
|
||||
<div style="color:#94a3b8;font-size:13px">To: ${esc(msg.To)}</div>
|
||||
</div>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;color:#cbd5e1;font-size:13px">${esc(bodyOf(msg.Raw))}</pre>
|
||||
<div style="margin-top:16px">
|
||||
<button onclick="deleteMessage('${folderID}','${id}')" class="btn btn-ghost">🗑 Delete</button>
|
||||
</div>`;
|
||||
api('/messages/'+folderID+'/'+id+'/flags',{method:'PUT',body:JSON.stringify({Flags:['\\Seen']})});
|
||||
}
|
||||
|
||||
function bodyOf(raw){
|
||||
if(!raw)return'';
|
||||
const decoded=atob(raw);
|
||||
const idx=decoded.indexOf('\r\n\r\n');
|
||||
return idx>=0?decoded.slice(idx+4):decoded;
|
||||
}
|
||||
|
||||
async function deleteMessage(folderID,id){
|
||||
await api('/messages/'+folderID+'/'+id,{method:'DELETE'});
|
||||
loadMessages(folderID);
|
||||
document.getElementById('msg-view').innerHTML='<div style="color:#475569;text-align:center;margin-top:60px">Select a message</div>';
|
||||
}
|
||||
|
||||
function openCompose(){document.getElementById('compose-modal').style.display='flex';}
|
||||
function closeCompose(){document.getElementById('compose-modal').style.display='none';}
|
||||
async function sendMessage(){
|
||||
const to=document.getElementById('c-to').value.split(',').map(s=>s.trim());
|
||||
const subject=document.getElementById('c-subject').value;
|
||||
const body=document.getElementById('c-body').value;
|
||||
try{
|
||||
await api('/messages',{method:'POST',body:JSON.stringify({to,subject,body})});
|
||||
closeCompose();
|
||||
document.getElementById('c-to').value='';document.getElementById('c-subject').value='';document.getElementById('c-body').value='';
|
||||
}catch(e){alert('Send failed: '+(e.error||e.message));}
|
||||
}
|
||||
|
||||
async function showQuarantine(){
|
||||
document.getElementById('view-mail').style.display='none';
|
||||
document.getElementById('view-quarantine').style.display='block';
|
||||
const entries=await api('/quarantine');if(!entries)return;
|
||||
document.getElementById('quarantine-list').innerHTML=entries.length?entries.map(e=>`
|
||||
<div style="background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div><div style="color:#e2e8f0;font-size:13px">Reason: ${esc(e.Reason||'—')}</div>
|
||||
<div style="color:#64748b;font-size:12px">Held: ${e.CreatedAt}</div></div>
|
||||
<button onclick="releaseQ('${e.ID}')" class="btn btn-primary">Release</button>
|
||||
</div>`).join(''):'<div style="color:#475569;text-align:center;padding:40px">🎉 Nothing held</div>';
|
||||
}
|
||||
async function releaseQ(id){
|
||||
try{await api('/quarantine/'+id+'/release',{method:'POST'});showQuarantine();}
|
||||
catch(e){alert('Release failed: '+(e.error||e.message));}
|
||||
}
|
||||
|
||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
async function boot(){
|
||||
if(!token){showLogin();return;}
|
||||
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user