Compare commits

..
18 Commits
Author SHA1 Message Date
nahakubuilder 45ce8e4e24 notification and deleted messages cleanup fix 2026-08-30 09:50:18 +01:00
nahakubuilder de7f124d60 fix deleting messages was not updating server 2026-08-30 09:41:46 +01:00
nahakubuilder 69d82f1c96 update list of emails from/to preview 2026-08-30 08:54:10 +01:00
nahakubuilder 77f4b04af6 fix received message - read status 2026-08-30 08:44:34 +01:00
nahakubuilder cc9b987e83 image reder and drafts 2026-08-30 08:17:03 +01:00
nahakubuilder d005cbb931 fix labels 2026-08-29 11:59:47 +01:00
nahakubuilder f506183b6d layout and sync adjustment 2026-08-29 11:06:22 +01:00
ghostersk d470c8b71f added calendar and contact - basic 2026-03-22 11:28:33 +00:00
ghostersk 9e7e87d11b updated outlook account sync 2026-03-15 20:27:29 +00:00
ghostersk a9c7f4c575 personal outlook working - still needs tuning 2026-03-15 19:33:51 +00:00
ghostersk 1e08d5f50f add option to re-order accounts 2026-03-15 13:15:46 +00:00
ghostersk 015c00251b fixed Gmail authentication, hotmail still in progress 2026-03-15 09:04:40 +00:00
ghostersk 68c81ebaed update README.MD 2026-03-08 18:37:52 +00:00
ghostersk f122d29282 added parameters to list bans and unban ip from terminal 2026-03-08 18:07:19 +00:00
ghostersk d6e987f66c added per user ip block/whitelist 2026-03-08 17:54:13 +00:00
ghostersk ef85246806 added IP Block and notification for failed logins 2026-03-08 17:35:58 +00:00
ghostersk 948e111cc6 fix image and link rendering 2026-03-08 12:14:58 +00:00
ghostersk ac43075d62 fix attachments 2026-03-08 11:48:27 +00:00
61 changed files with 16612 additions and 818 deletions
+8 -1
View File
@@ -3,4 +3,11 @@ data/*.db
data/*.db-shm
data/*db-wal
data/gowebmail.conf
data/*.txt
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace
graphify-out
GEMINI.md
tests/
+247 -4
View File
@@ -1,13 +1,16 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -15,6 +18,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/handlers"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/syncer"
@@ -47,6 +51,16 @@ func main() {
}
runDisableMFA(args[1])
return
case "--blocklist":
runBlockList()
return
case "--unblock":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
os.Exit(1)
}
runUnblock(args[1])
return
case "--help", "-h":
printHelp()
return
@@ -62,6 +76,18 @@ func main() {
if err != nil {
log.Fatalf("config load: %v", err)
}
logger.Init(cfg.Debug)
// Install a filtered log writer that suppresses harmless go-imap v1 parser
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
// on Gmail connections due to non-standard server responses. These don't affect
// functionality — go-imap recovers and continues syncing correctly.
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
"imap/client:",
"atom contains forbidden",
"atom contains bad",
"bad brackets nesting",
}})
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
@@ -73,7 +99,7 @@ func main() {
log.Fatalf("migrations: %v", err)
}
sc := syncer.New(database)
sc := syncer.New(database, cfg)
sc.Start()
defer sc.Stop()
@@ -85,10 +111,27 @@ func main() {
r.Use(middleware.CORS)
r.Use(cfg.HostCheckMiddleware)
// Custom error handlers for non-API paths
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
})
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
})
// Static files
r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
)
// Legacy /app path redirect — some browsers bookmark this; redirect to root
// which RequireAuth will then forward to login if not signed in.
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
if err != nil {
@@ -103,7 +146,7 @@ func main() {
// Public auth routes
auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
// MFA (session exists but mfa_verified=0)
@@ -119,11 +162,15 @@ func main() {
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
// App
app := r.PathPrefix("").Subrouter()
app.Use(middleware.RequireAuth(database, cfg))
app.HandleFunc("/", h.App.Index).Methods("GET")
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
// Admin UI
adminUI := r.PathPrefix("/admin").Subrouter()
@@ -133,6 +180,7 @@ func main() {
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
// API
api := r.PathPrefix("/api").Subrouter()
@@ -141,10 +189,13 @@ func main() {
// Profile / auth
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
// Providers (which OAuth providers are configured)
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
@@ -153,6 +204,7 @@ func main() {
api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET")
api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST")
api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST")
api.HandleFunc("/accounts/trust-cert", h.API.TrustCertificate).Methods("POST")
api.HandleFunc("/accounts/detect", h.API.DetectMailSettings).Methods("POST")
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET")
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT")
@@ -167,19 +219,42 @@ func main() {
api.HandleFunc("/messages/{id:[0-9]+}/read", h.API.MarkRead).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.SnoozeMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.UnsnoozeMessage).Methods("DELETE")
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
api.HandleFunc("/messages/snoozed", h.API.SnoozedMessages).Methods("GET")
api.HandleFunc("/messages/by-label/{id:[0-9]+}", h.API.MessagesByLabel).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.AssignLabel).Methods("POST")
api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.UnassignLabel).Methods("DELETE")
api.HandleFunc("/labels", h.API.ListLabels).Methods("GET")
api.HandleFunc("/labels", h.API.CreateLabel).Methods("POST")
api.HandleFunc("/labels/{id:[0-9]+}", h.API.UpdateLabel).Methods("PUT")
api.HandleFunc("/labels/{id:[0-9]+}", h.API.DeleteLabel).Methods("DELETE")
// Remote content whitelist
api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET")
api.HandleFunc("/remote-content-whitelist", h.API.AddRemoteContentWhitelist).Methods("POST")
api.HandleFunc("/remote-content-whitelist", h.API.DeleteRemoteContentWhitelist).Methods("DELETE")
// Spam blocklist
api.HandleFunc("/spam-block", h.API.ListSpamBlock).Methods("GET")
api.HandleFunc("/spam-block", h.API.AddSpamBlock).Methods("POST")
api.HandleFunc("/spam-block", h.API.DeleteSpamBlock).Methods("DELETE")
// Send
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
api.HandleFunc("/draft/discard", h.API.DiscardDraft).Methods("POST")
api.HandleFunc("/send-later", h.API.CreateScheduledSend).Methods("POST")
api.HandleFunc("/scheduled-sends", h.API.ListScheduledSends).Methods("GET")
api.HandleFunc("/scheduled-sends/{id:[0-9]+}", h.API.CancelScheduledSend).Methods("DELETE")
// Folders
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
@@ -189,18 +264,79 @@ func main() {
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/export", h.API.ExportFolder).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
api.HandleFunc("/accounts/{account_id:[0-9]+}/folders", h.API.CreateFolder).Methods("POST")
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
api.HandleFunc("/new-messages", h.API.NewMessagesSince).Methods("GET")
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET")
// Search
api.HandleFunc("/search", h.API.Search).Methods("GET")
// Contacts
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
// Calendar events
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
// CalDAV API tokens
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
// CalDAV public feed — token-authenticated, no session needed
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
// Mail rules (filters)
api.HandleFunc("/rules", h.API.ListRules).Methods("GET")
api.HandleFunc("/rules", h.API.CreateRule).Methods("POST")
api.HandleFunc("/rules/{id:[0-9]+}", h.API.UpdateRule).Methods("PUT")
api.HandleFunc("/rules/{id:[0-9]+}", h.API.DeleteRule).Methods("DELETE")
// Signatures
api.HandleFunc("/signatures", h.API.ListSignatures).Methods("GET")
api.HandleFunc("/signatures", h.API.CreateSignature).Methods("POST")
api.HandleFunc("/signatures/{id:[0-9]+}", h.API.UpdateSignature).Methods("PUT")
api.HandleFunc("/signatures/{id:[0-9]+}", h.API.DeleteSignature).Methods("DELETE")
api.HandleFunc("/accounts/{id:[0-9]+}/signature-defaults", h.API.SetSignatureDefaults).Methods("PUT")
// S/MIME certificates
api.HandleFunc("/smime/identity", h.API.SMIMEIdentity).Methods("GET")
api.HandleFunc("/smime/identity", h.API.SMIMEGenerate).Methods("POST")
api.HandleFunc("/smime/identity/import", h.API.SMIMEImport).Methods("POST")
api.HandleFunc("/smime/identity/{id:[0-9]+}", h.API.SMIMERemoveIdentity).Methods("DELETE")
api.HandleFunc("/smime/contacts", h.API.SMIMEContacts).Methods("GET")
api.HandleFunc("/smime/contacts", h.API.SMIMEAddContact).Methods("POST")
api.HandleFunc("/smime/contacts/{id:[0-9]+}", h.API.SMIMERemoveContact).Methods("DELETE")
// PGP keys
api.HandleFunc("/pgp/identity", h.API.PGPIdentity).Methods("GET")
api.HandleFunc("/pgp/identity", h.API.PGPGenerate).Methods("POST")
api.HandleFunc("/pgp/identity/import", h.API.PGPImport).Methods("POST")
api.HandleFunc("/pgp/identity/{id:[0-9]+}", h.API.PGPRemoveIdentity).Methods("DELETE")
api.HandleFunc("/pgp/unlock", h.API.PGPUnlock).Methods("POST")
api.HandleFunc("/pgp/contacts", h.API.PGPContacts).Methods("GET")
api.HandleFunc("/pgp/contacts", h.API.PGPAddContact).Methods("POST")
api.HandleFunc("/pgp/contacts/{id:[0-9]+}", h.API.PGPRemoveContact).Methods("DELETE")
// Admin API
adminAPI := r.PathPrefix("/api/admin").Subrouter()
adminAPI.Use(middleware.RequireAuth(database, cfg))
@@ -213,6 +349,29 @@ func main() {
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
// Periodically purge expired IP blocks
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
database.PurgeExpiredBlocks()
}
}()
// Deliver due scheduled sends and wake expired message snoozes
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
h.API.ProcessDueScheduledSends()
h.API.WakeExpiredSnoozes()
}
}()
srv := &http.Server{
Addr: cfg.ListenAddr,
@@ -305,22 +464,106 @@ func runDisableMFA(username string) {
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
}
func runBlockList() {
database, close := openDB()
defer close()
blocks, err := database.ListIPBlocksWithUsername()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(blocks) == 0 {
fmt.Println("No blocked IPs.")
return
}
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"--", "-------------", "-----", "----------", "-------", "---------")
for _, b := range blocks {
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
var expires, remaining string
if b.IsPermanent || b.ExpiresAt == nil {
expires = "permanent"
remaining = "∞ (manual unblock)"
} else {
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
left := time.Until(*b.ExpiresAt)
if left <= 0 {
remaining = "expired (purge pending)"
} else {
h := int(left.Hours())
m := int(left.Minutes()) % 60
s := int(left.Seconds()) % 60
if h > 0 {
remaining = fmt.Sprintf("%dh %dm", h, m)
} else if m > 0 {
remaining = fmt.Sprintf("%dm %ds", m, s)
} else {
remaining = fmt.Sprintf("%ds", s)
}
}
}
username := b.LastUsername
if username == "" {
username = "(unknown)"
}
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
b.IP, username, b.Attempts, blockedAt, expires, remaining)
}
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
}
func runUnblock(ip string) {
database, close := openDB()
defer close()
if err := database.UnblockIP(ip); err != nil {
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
os.Exit(1)
}
fmt.Printf("IP %s has been unblocked.\n", ip)
}
func printHelp() {
fmt.Print(`GoMail — Admin CLI
fmt.Print(`GoWebMail — Admin CLI
Usage:
gowebmail Start the mail server
gowebmail --list-admin List all admin accounts (username, email, MFA status)
gowebmail --pw <username> <pass> Reset password for an admin account
gowebmail --mfa-off <username> Disable MFA for an admin account
gowebmail --blocklist List all currently blocked IP addresses
gowebmail --unblock <ip> Remove block for a specific IP address
Examples:
./gowebmail --list-admin
./gowebmail --pw admin "NewSecurePass123"
./gowebmail --mfa-off admin
./gowebmail --blocklist
./gowebmail --unblock 1.2.3.4
Note: These commands only work on admin accounts.
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
Regular user management is done through the web UI.
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
`)
}
// filteredWriter wraps an io.Writer and drops log lines containing any of the
// suppress substrings. Used to silence harmless go-imap internal parser errors.
type filteredWriter struct {
w io.Writer
suppress []string
}
func (f *filteredWriter) Write(p []byte) (n int, err error) {
line := string(bytes.TrimSpace(p))
for _, s := range f.suppress {
if strings.Contains(line, s) {
return len(p), nil // silently drop
}
}
return f.w.Write(p)
}
+222 -11
View File
@@ -1,4 +1,4 @@
// Package config loads and persists GoMail configuration from data/gowebmail.conf
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
package config
import (
@@ -21,6 +21,9 @@ type Config struct {
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Debug
Debug bool // set DEBUG=true in config to enable verbose logging
// Security
EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte
@@ -28,6 +31,23 @@ type Config struct {
SessionMaxAge int
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
// Notification SMTP (outbound alerts — separate from user mail accounts)
NotifyEnabled bool
NotifySMTPHost string
NotifySMTPPort int
NotifyFrom string
NotifyUser string // optional — leave blank for unauthenticated relay
NotifyPass string // optional
// Brute force protection
BruteEnabled bool
BruteMaxAttempts int
BruteWindowMins int
BruteBanHours int
BruteWhitelist []net.IP // IPs exempt from blocking
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
// Storage
DBPath string
@@ -59,7 +79,7 @@ var allFields = []configField{
defVal: "localhost",
comments: []string{
"--- Server ---",
"Public hostname of this GoMail instance (no port, no protocol).",
"Public hostname of this GoWebMail instance (no port, no protocol).",
"Examples: localhost | mail.example.com | 192.168.1.10",
"Used to build BASE_URL and OAuth redirect URIs automatically.",
"Also used in security checks to reject requests with unexpected Host headers.",
@@ -92,7 +112,7 @@ var allFields = []configField{
key: "SECURE_COOKIE",
defVal: "false",
comments: []string{
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
"Marks session cookies as Secure so browsers only send them over TLS.",
},
},
@@ -109,7 +129,7 @@ var allFields = []configField{
comments: []string{
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
"which GoMail uses to determine the real client IP and whether TLS is in use.",
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
" Examples:",
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
@@ -118,6 +138,108 @@ var allFields = []configField{
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
},
},
{
key: "NOTIFY_ENABLED",
defVal: "true",
comments: []string{
"--- Security Notifications ---",
"Send email alerts to users when their account is targeted by brute-force attacks.",
"Set to false to disable all security notification emails.",
},
},
{
key: "NOTIFY_SMTP_HOST",
defVal: "",
comments: []string{
"SMTP server hostname for sending security notification emails.",
"Example: smtp.example.com",
},
},
{
key: "NOTIFY_SMTP_PORT",
defVal: "587",
comments: []string{
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
},
},
{
key: "NOTIFY_FROM",
defVal: "",
comments: []string{
"Sender address for security notification emails. Example: security@example.com",
},
},
{
key: "NOTIFY_USER",
defVal: "",
comments: []string{
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "NOTIFY_PASS",
defVal: "",
comments: []string{
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "BRUTE_ENABLED",
defVal: "true",
comments: []string{
"--- Brute Force Protection ---",
"Enable automatic IP blocking after repeated failed logins.",
"Set to false to disable entirely.",
},
},
{
key: "BRUTE_MAX_ATTEMPTS",
defVal: "5",
comments: []string{
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
},
},
{
key: "BRUTE_WINDOW_MINUTES",
defVal: "30",
comments: []string{
"Time window in minutes for counting failed login attempts.",
},
},
{
key: "BRUTE_BAN_HOURS",
defVal: "12",
comments: []string{
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
},
},
{
key: "BRUTE_WHITELIST_IPS",
defVal: "",
comments: []string{
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
"Example: 192.168.1.1,10.0.0.1",
},
},
{
key: "GEO_BLOCK_COUNTRIES",
defVal: "",
comments: []string{
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
"Comma-separated 2-letter ISO country codes to DENY access from.",
"Example: CN,RU,KP",
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
},
},
{
key: "GEO_ALLOW_COUNTRIES",
defVal: "",
comments: []string{
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
"Example: SK,CZ,DE",
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
},
},
{
key: "DB_PATH",
defVal: "./data/gowebmail.db",
@@ -184,10 +306,15 @@ var allFields = []configField{
},
{
key: "MICROSOFT_TENANT_ID",
defVal: "common",
defVal: "consumers",
comments: []string{
"Use 'common' to allow any Microsoft account,",
"or your Azure tenant ID to restrict to one organisation.",
"Tenant endpoint to use for Microsoft OAuth2.",
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
" Use if registered as 'Personal accounts only'.",
" organizations - Work/school Microsoft 365 accounts only.",
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
},
},
{
@@ -228,7 +355,7 @@ func Load() (*Config, error) {
// get returns env var if set, else file value, else ""
get := func(key string) string {
// Only check env vars that are explicitly GoMail-namespaced or well-known.
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
// We deliberately do NOT fall back to generic vars like PORT to avoid
// picking up cloud-platform env vars unintentionally.
if v := os.Getenv("GOMAIL_" + key); v != "" {
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
Hostname: hostname,
BaseURL: baseURL,
DBPath: get("DB_PATH"),
Debug: atobool(get("DEBUG"), false),
EncryptionKey: encKey,
SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
TrustedProxies: trustedProxies,
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
NotifyFrom: get("NOTIFY_FROM"),
NotifyUser: get("NOTIFY_USER"),
NotifyPass: get("NOTIFY_PASS"),
GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: googleRedirect,
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
MicrosoftRedirectURL: outlookRedirect,
}
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
}
}
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
func (c *Config) IsIPWhitelisted(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, w := range c.BruteWhitelist {
if w.Equal(ip) {
return true
}
}
return false
}
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
func (c *Config) IsCountryAllowed(code string) bool {
code = strings.ToUpper(code)
if len(c.GeoBlockCountries) > 0 {
for _, bc := range c.GeoBlockCountries {
if bc == code {
return false
}
}
}
if len(c.GeoAllowCountries) > 0 {
for _, ac := range c.GeoAllowCountries {
if ac == code {
return true
}
}
return false
}
return true
}
// IsAllowedHost returns true if the request Host header matches our expected hostname.
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
func (c *Config) IsAllowedHost(requestHost string) bool {
@@ -443,7 +622,7 @@ func readConfigFile(path string) (map[string]string, error) {
func writeConfigFile(path string, values map[string]string) error {
var sb strings.Builder
sb.WriteString("# GoMail Configuration\n")
sb.WriteString("# GoWebMail Configuration\n")
sb.WriteString("# =====================\n")
sb.WriteString("# Auto-generated and updated on each startup.\n")
sb.WriteString("# Edit freely — your values are always preserved.\n")
@@ -576,7 +755,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
}
func logStartupInfo(cfg *Config) {
fmt.Printf("GoMail starting:\n")
fmt.Printf("GoWebMail starting:\n")
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
}
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
}
if cfg.GoogleClientID != "" {
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
}
if cfg.MicrosoftClientID != "" {
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
}
}
func parseIPList(s string) []net.IP {
var ips []net.IP
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if ip := net.ParseIP(raw); ip != nil {
ips = append(ips, ip)
}
}
return ips
}
func parseCountryList(s string) []string {
var codes []string
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(strings.ToUpper(raw))
if len(raw) == 2 {
codes = append(codes, raw)
}
}
return codes
}
func mustHex(n int) string {
-90
View File
@@ -1,90 +0,0 @@
# GoMail Configuration
# =====================
# Auto-generated and updated on each startup.
# Edit freely — your values are always preserved.
# Environment variables (or GOMAIL_<KEY>) override values here.
#
# --- Server ---
# Public hostname of this GoMail instance (no port, no protocol).
# Examples: localhost | mail.example.com | 192.168.1.10
# Used to build BASE_URL and OAuth redirect URIs automatically.
# Also used in security checks to reject requests with unexpected Host headers.
HOSTNAME = localhost
# Address and port to listen on. Format: [host]:port
# :8080 — all interfaces, port 8080
# 0.0.0.0:8080 — all interfaces (explicit)
# 127.0.0.1:8080 — localhost only
LISTEN_ADDR = :8080
# Public URL of this instance (no trailing slash). Leave blank to auto-build
# from HOSTNAME and LISTEN_ADDR port (recommended).
# Auto-build examples:
# HOSTNAME=localhost + :8080 → http://localhost:8080
# HOSTNAME=mail.example.com + :443 → https://mail.example.com
# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080
# Override here only if you need a custom path prefix or your proxy rewrites the URL.
BASE_URL =
# Set to true when GoMail is served over HTTPS (directly or via proxy).
# Marks session cookies as Secure so browsers only send them over TLS.
SECURE_COOKIE = false
# How long a login session lasts, in seconds. Default: 604800 (7 days).
SESSION_MAX_AGE = 604800
# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.
# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,
# which GoMail uses to determine the real client IP and whether TLS is in use.
# Examples:
# 127.0.0.1 (loopback only — Nginx/Traefik on same host)
# 10.0.0.0/8,172.16.0.0/12 (private networks)
# 192.168.1.50,192.168.1.51 (specific IPs)
# Leave blank to disable proxy trust (requests are taken at face value).
# NOTE: Do not add untrusted IPs — clients could spoof their source address.
TRUSTED_PROXIES =
# --- Storage ---
# Path to the SQLite database file.
DB_PATH = ./data/gowebmail.db
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.
# NOTE: Back this up. Losing it makes the entire database permanently unreadable.
# openssl rand -hex 32
ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97
# Secret used to sign session cookies. Auto-generated on first run.
# Changing this invalidates all active sessions (everyone gets logged out).
SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e
# --- Gmail / Google OAuth2 ---
# Create at: https://console.cloud.google.com/apis/credentials
# Application type : Web application
# Required scope : https://mail.google.com/
# Redirect URI : <BASE_URL>/auth/gmail/callback
GOOGLE_CLIENT_ID =
GOOGLE_CLIENT_SECRET =
# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
# Must exactly match what is registered in Google Cloud Console.
GOOGLE_REDIRECT_URL =
# --- Outlook / Microsoft 365 OAuth2 ---
# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email
# Redirect URI : <BASE_URL>/auth/outlook/callback
MICROSOFT_CLIENT_ID =
MICROSOFT_CLIENT_SECRET =
# Use 'common' to allow any Microsoft account,
# or your Azure tenant ID to restrict to one organisation.
MICROSOFT_TENANT_ID = common
# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
# Must exactly match what is registered in Azure.
MICROSOFT_REDIRECT_URL =
+14 -6
View File
@@ -1,18 +1,26 @@
module github.com/ghostersk/gowebmail
go 1.26
go 1.26.6
require (
github.com/ProtonMail/go-crypto v1.4.1
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
github.com/emersion/go-webdav v0.7.0
github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.22
golang.org/x/crypto v0.24.0
golang.org/x/oauth2 v0.21.0
github.com/mattn/go-sqlite3 v1.14.49
go.mozilla.org/pkcs7 v0.10.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/google/go-cmp v0.6.0 // indirect
golang.org/x/text v0.16.0 // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+26 -10
View File
@@ -1,23 +1,39 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg=
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+201 -17
View File
@@ -3,11 +3,15 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft"
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
// ---- Microsoft / Outlook OAuth2 ----
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
var OutlookScopes = []string{
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
// audience for IMAP XOAUTH2 authentication.
var OutlookAuthScopes = []string{
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
"openid",
"profile",
"email",
}
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
if tenantID == "" {
tenantID = "consumers"
}
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
// "common" routes personal accounts through login.live.com which returns
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: OutlookScopes,
Scopes: OutlookAuthScopes,
Endpoint: microsoft.AzureADEndpoint(tenantID),
}
}
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
// authorization and exchanges it for an access token scoped to the Outlook
// resource (aud=https://outlook.office.com), which the IMAP server requires.
// The two-step approach is necessary because:
// - Azure personal app registrations only expose bare Graph scope names in their UI
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
// - Using the refresh_token against the Outlook resource produces a correct token
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
if tenantID == "" {
tenantID = "consumers"
}
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
params := url.Values{}
params.Set("grant_type", "refresh_token")
params.Set("client_id", clientID)
params.Set("client_secret", clientSecret)
params.Set("refresh_token", refreshToken)
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
if err != nil {
return nil, fmt.Errorf("build IMAP token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("IMAP token request: %w", err)
}
defer resp.Body.Close()
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode IMAP token response: %w", err)
}
if result.Error != "" {
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
}
if result.AccessToken == "" {
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
}
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
preview := result.AccessToken
if len(preview) > 30 {
preview = preview[:30] + "..."
}
parts := strings.Count(result.AccessToken, ".") + 1
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
parts, preview, params.Get("scope"))
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
return &oauth2.Token{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
Expiry: expiry,
}, nil
}
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
type MicrosoftUserInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
DisplayName string `json:"displayName"` // Graph field
Name string `json:"name"` // ID token claim
Mail string `json:"mail"`
EmailClaim string `json:"email"` // ID token claim
UserPrincipalName string `json:"userPrincipalName"`
PreferredUsername string `json:"preferred_username"` // ID token claim
}
// Email returns the best available email address.
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
if m.Mail != "" {
return m.Mail
}
if m.EmailClaim != "" {
return m.EmailClaim
}
if m.PreferredUsername != "" {
return m.PreferredUsername
}
return m.UserPrincipalName
}
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
// BestName returns the best available display name.
func (m *MicrosoftUserInfo) BestName() string {
if m.DisplayName != "" {
return m.DisplayName
}
return m.Name
}
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
// The ID token is issued alongside the access token and contains email/name claims.
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
client := cfg.Client(ctx, token)
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
idToken, _ := token.Extra("id_token").(string)
if idToken == "" {
return nil, fmt.Errorf("no id_token in Microsoft token response")
}
// JWT structure: header.payload.signature — decode the payload only
parts := strings.SplitN(idToken, ".", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("graph /me request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
return nil, fmt.Errorf("id_token base64 decode: %w", err)
}
var info MicrosoftUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
if err := json.Unmarshal(decoded, &info); err != nil {
return nil, fmt.Errorf("id_token JSON decode: %w", err)
}
if info.Email() == "" {
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
}
return &info, nil
}
// ---- Outlook Personal (Graph API) ----
// OutlookPersonalScopes are used for personal outlook.com accounts.
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
// Mail is accessed via Graph REST API instead of IMAP.
var OutlookPersonalScopes = []string{
"https://graph.microsoft.com/Mail.ReadWrite",
"https://graph.microsoft.com/Mail.Send",
"https://graph.microsoft.com/User.Read",
"offline_access",
"openid",
"email",
}
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
if tenantID == "" {
tenantID = "consumers"
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: OutlookPersonalScopes,
Endpoint: microsoft.AzureADEndpoint(tenantID),
}
}
// ---- Token refresh helpers ----
// IsTokenExpired reports whether the token expires within a 60-second buffer.
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
return ts.Token()
}
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
// Pass the credentials for both providers; the correct ones are selected based
// on provider ("gmail" or "outlook").
func RefreshAccountToken(ctx context.Context,
provider, refreshToken, baseURL,
googleClientID, googleClientSecret,
msClientID, msClientSecret, msTenantID string,
) (accessToken, newRefresh string, expiry time.Time, err error) {
switch provider {
case "gmail":
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
case "outlook":
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
rt := tok.RefreshToken
if rt == "" {
rt = refreshToken
}
return tok.AccessToken, rt, tok.Expiry, nil
case "outlook_personal":
// Personal outlook.com accounts use Graph API scopes — standard refresh works
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
baseURL+"/auth/outlook-personal/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
rt := tok.RefreshToken
if rt == "" {
rt = refreshToken
}
return tok.AccessToken, rt, tok.Expiry, nil
default:
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
}
}
+157
View File
@@ -0,0 +1,157 @@
// Package caldav pulls calendar events and contacts from a remote
// CalDAV/CardDAV server so they can be mirrored into gowebmail's local DB.
// One-way (server -> gowebmail) read sync only.
package caldav
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-vcard"
dav "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"github.com/ghostersk/gowebmail/internal/models"
)
const timeout = 30 * time.Second
// SyncCalendar fetches all VEVENTs from the calendar collection at url
// (HTTP basic auth) and returns them as CalendarEvent rows tagged with accountID.
func SyncCalendar(ctx context.Context, url, user, pass string, accountID int64) ([]*models.CalendarEvent, error) {
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
c, err := caldav.NewClient(hc, url)
if err != nil {
return nil, fmt.Errorf("caldav client: %w", err)
}
objs, err := c.QueryCalendar(ctx, "", &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{AllProps: true, AllComps: true},
CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}},
})
if err != nil {
return nil, fmt.Errorf("caldav query: %w", err)
}
var out []*models.CalendarEvent
for _, obj := range objs {
if obj.Data == nil {
continue
}
for _, ev := range obj.Data.Events() {
ev := ev
out = append(out, eventFromICal(&ev, accountID))
}
}
return out, nil
}
func eventFromICal(ev *ical.Event, accountID int64) *models.CalendarEvent {
uid, _ := ev.Props.Text(ical.PropUID)
summary, _ := ev.Props.Text(ical.PropSummary)
desc, _ := ev.Props.Text(ical.PropDescription)
loc, _ := ev.Props.Text(ical.PropLocation)
allDay := false
if p := ev.Props.Get(ical.PropDateTimeStart); p != nil {
allDay = p.ValueType() == ical.ValueDate
}
start, _ := ev.DateTimeStart(time.UTC)
end, _ := ev.DateTimeEnd(time.UTC)
if end.IsZero() {
end = start
}
status := ""
if s, err := ev.Status(); err == nil {
status = strings.ToLower(string(s))
}
organizer := ""
if p := ev.Props.Get(ical.PropOrganizer); p != nil {
organizer = strings.TrimPrefix(p.Value, "mailto:")
}
var attendees []string
for _, p := range ev.Props.Values(ical.PropAttendee) {
attendees = append(attendees, strings.TrimPrefix(p.Value, "mailto:"))
}
rrule := ""
if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil {
rrule = p.Value
}
return &models.CalendarEvent{
AccountID: &accountID,
UID: uid,
Title: summary,
Description: desc,
Location: loc,
StartTime: formatEventTime(start, allDay),
EndTime: formatEventTime(end, allDay),
AllDay: allDay,
RecurrenceRule: rrule,
Status: status,
OrganizerEmail: organizer,
Attendees: strings.Join(attendees, ", "),
}
}
func formatEventTime(t time.Time, allDay bool) string {
if allDay {
return t.Format("2006-01-02")
}
return t.UTC().Format("2006-01-02T15:04:05Z")
}
// SyncContacts fetches all vCards from the address book collection at url
// (HTTP basic auth) and returns them as Contact rows tagged with accountID.
func SyncContacts(ctx context.Context, url, user, pass string, accountID int64) ([]*models.Contact, error) {
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
c, err := carddav.NewClient(hc, url)
if err != nil {
return nil, fmt.Errorf("carddav client: %w", err)
}
objs, err := c.QueryAddressBook(ctx, "", &carddav.AddressBookQuery{
DataRequest: carddav.AddressDataRequest{AllProp: true},
})
if err != nil {
return nil, fmt.Errorf("carddav query: %w", err)
}
var out []*models.Contact
for _, obj := range objs {
if obj.Card == nil {
continue
}
out = append(out, contactFromVCard(obj.Card, obj.Path, accountID))
}
return out, nil
}
func contactFromVCard(card vcard.Card, path string, accountID int64) *models.Contact {
uid := card.PreferredValue(vcard.FieldUID)
if uid == "" {
// vCard UID is only a SHOULD in vCard 3.0 — fall back to the stable
// resource path so contacts without one don't collide on upsert.
uid = path
}
name := card.PreferredValue(vcard.FieldFormattedName)
org := card.PreferredValue(vcard.FieldOrganization)
if i := strings.Index(org, ";"); i >= 0 {
org = org[:i]
}
return &models.Contact{
AccountID: &accountID,
UID: uid,
DisplayName: name,
Email: card.PreferredValue(vcard.FieldEmail),
Phone: card.PreferredValue(vcard.FieldTelephone),
Company: org,
Notes: card.PreferredValue(vcard.FieldNote),
}
}
+2127 -57
View File
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
package db
import (
"path/filepath"
"testing"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
// newTestDB creates a fresh, migrated DB backed by a temp file (WAL mode needs a real file,
// not :memory:) and returns it along with the bootstrap admin user's ID (always 1 — Migrate
// creates it when no users exist).
func newTestDB(t *testing.T) (*DB, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
d, err := New(path, key)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return d, 1 // bootstrap admin
}
// seedAccountAndFolder creates a minimal IMAP account + INBOX folder for userID, returning
// their IDs.
func seedAccountAndFolder(t *testing.T, d *DB, userID int64) (accountID, folderID int64) {
t.Helper()
acc := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: "user@example.com", DisplayName: "Test User",
IMAPHost: "imap.example.com", IMAPPort: 993,
SMTPHost: "smtp.example.com", SMTPPort: 587,
Color: "#4A90D9",
}
if err := d.CreateAccount(acc); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
f, err := d.GetFolderByPath(acc.ID, "INBOX")
if err != nil || f == nil {
t.Fatalf("GetFolderByPath: %v", err)
}
return acc.ID, f.ID
}
func seedMessage(t *testing.T, d *DB, accountID, folderID int64, remoteUID, subject string) int64 {
t.Helper()
m := &models.Message{
AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID,
Subject: subject, FromName: "Sender Name", FromEmail: "sender@example.com",
ToList: "user@example.com", BodyText: "hello world", Date: time.Now(),
}
if err := d.UpsertMessage(m); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
if m.ID == 0 {
t.Fatalf("UpsertMessage did not populate ID")
}
return m.ID
}
// ---- Encryption round-trip ----
func TestMessageEncryptionRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
const subject = `Subject with "quotes", unicode ✉️ and a semicolon; and a % sign`
msgID := seedMessage(t, d, accountID, folderID, "100", subject)
got, err := d.GetMessage(msgID, userID)
if err != nil || got == nil {
t.Fatalf("GetMessage: %v", err)
}
if got.Subject != subject {
t.Errorf("Subject = %q, want %q", got.Subject, subject)
}
if got.FromEmail != "sender@example.com" {
t.Errorf("FromEmail = %q", got.FromEmail)
}
}
func TestGetMessage_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "100", "secret")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
got, err := d.GetMessage(msgID, other.ID)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if got != nil {
t.Errorf("expected nil for another user's message, got %+v", got)
}
}
// ---- ListMessages / snooze filtering ----
func TestListMessages_ExcludesFutureSnoozed(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
visibleID := seedMessage(t, d, accountID, folderID, "1", "visible")
snoozedID := seedMessage(t, d, accountID, folderID, "2", "snoozed")
if err := d.SnoozeMessage(snoozedID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if page.Total != 1 {
t.Fatalf("Total = %d, want 1 (snoozed message should be excluded)", page.Total)
}
if len(page.Messages) != 1 || page.Messages[0].ID != visibleID {
t.Fatalf("Messages = %+v, want only %d", page.Messages, visibleID)
}
}
func TestListMessages_IncludesPastSnoozed(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "was snoozed")
if err := d.SnoozeMessage(msgID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
// Simulate the snooze having already expired (SnoozeMessage validates nothing server-side
// about "future", so write an already-past timestamp directly).
if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), msgID); err != nil {
t.Fatalf("backdate snooze: %v", err)
}
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if page.Total != 1 {
t.Fatalf("Total = %d, want 1 (past-snooze message should be visible again)", page.Total)
}
}
// ---- Snooze / unsnooze / wake ----
func TestSnoozeUnsnoozeRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "snooze me")
until := time.Now().Add(2 * time.Hour)
if err := d.SnoozeMessage(msgID, userID, until); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
snoozed, err := d.ListSnoozedMessages(userID, 1, 50)
if err != nil {
t.Fatalf("ListSnoozedMessages: %v", err)
}
if snoozed.Total != 1 || snoozed.Messages[0].ID != msgID {
t.Fatalf("ListSnoozedMessages = %+v, want [%d]", snoozed.Messages, msgID)
}
if snoozed.Messages[0].SnoozedUntil == nil {
t.Fatalf("SnoozedUntil not populated")
}
if err := d.UnsnoozeMessage(msgID, userID); err != nil {
t.Fatalf("UnsnoozeMessage: %v", err)
}
snoozed, err = d.ListSnoozedMessages(userID, 1, 50)
if err != nil {
t.Fatalf("ListSnoozedMessages after unsnooze: %v", err)
}
if snoozed.Total != 0 {
t.Fatalf("Total = %d after unsnooze, want 0", snoozed.Total)
}
}
func TestSnoozeMessage_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Attempting to snooze someone else's message must be a silent no-op (0 rows affected),
// not an error and not a mutation.
if err := d.SnoozeMessage(msgID, other.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("SnoozeMessage (other user): %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.SnoozedUntil != nil {
t.Errorf("message got snoozed by a non-owning user: %+v", msg.SnoozedUntil)
}
}
func TestWakeExpiredSnoozes(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
expiredID := seedMessage(t, d, accountID, folderID, "1", "expired")
futureID := seedMessage(t, d, accountID, folderID, "2", "future")
if err := d.SnoozeMessage(expiredID, userID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), expiredID); err != nil {
t.Fatalf("backdate: %v", err)
}
if err := d.SnoozeMessage(futureID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
// Mark both read=0 initially is already the UpsertMessage default; flip expired one to
// read=1 so we can prove WakeExpiredSnoozes resets it to unread.
if _, err := d.sql.Exec(`UPDATE messages SET is_read=1 WHERE id=?`, expiredID); err != nil {
t.Fatalf("mark read: %v", err)
}
folderIDs, err := d.WakeExpiredSnoozes()
if err != nil {
t.Fatalf("WakeExpiredSnoozes: %v", err)
}
if len(folderIDs) != 1 || folderIDs[0] != folderID {
t.Fatalf("folderIDs = %v, want [%d]", folderIDs, folderID)
}
expired, err := d.GetMessage(expiredID, userID)
if err != nil || expired == nil {
t.Fatalf("GetMessage(expired): %v", err)
}
if expired.SnoozedUntil != nil {
t.Errorf("expired message still snoozed: %+v", expired.SnoozedUntil)
}
if expired.IsRead {
t.Errorf("expired message should be marked unread on wake")
}
// GetMessage doesn't project snoozed_until (only the Snoozed-view listing does), so check
// the future message is still excluded from the normal folder listing instead.
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
for _, m := range page.Messages {
if m.ID == futureID {
t.Errorf("future-snoozed message reappeared in folder listing after wake sweep")
}
}
}
// ---- Scheduled sends ----
func TestScheduledSendRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
s := &models.ScheduledSend{
UserID: userID, AccountID: accountID,
To: []string{"a@example.com", "b@example.com"},
CC: []string{"c@example.com"},
Subject: `Meeting notes — "Q3 review"`, BodyHTML: "<p>hi</p>", BodyText: "hi",
ForwardFromIDs: []int64{42},
SendAt: time.Now().Add(time.Hour),
}
id, err := d.CreateScheduledSend(s)
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
if id == 0 {
t.Fatalf("CreateScheduledSend returned id=0")
}
list, err := d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends: %v", err)
}
if len(list) != 1 {
t.Fatalf("ListScheduledSends returned %d items, want 1", len(list))
}
got := list[0]
if got.Subject != s.Subject {
t.Errorf("Subject = %q, want %q", got.Subject, s.Subject)
}
if len(got.To) != 2 || got.To[0] != "a@example.com" || got.To[1] != "b@example.com" {
t.Errorf("To = %v", got.To)
}
if len(got.CC) != 1 || got.CC[0] != "c@example.com" {
t.Errorf("CC = %v", got.CC)
}
if len(got.ForwardFromIDs) != 1 || got.ForwardFromIDs[0] != 42 {
t.Errorf("ForwardFromIDs = %v", got.ForwardFromIDs)
}
// Not due yet (send_at is an hour out).
due, err := d.ListDueScheduledSends()
if err != nil {
t.Fatalf("ListDueScheduledSends: %v", err)
}
if len(due) != 0 {
t.Fatalf("ListDueScheduledSends = %d items, want 0 (not due yet)", len(due))
}
if err := d.DeleteScheduledSend(id, userID); err != nil {
t.Fatalf("DeleteScheduledSend: %v", err)
}
list, err = d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends after delete: %v", err)
}
if len(list) != 0 {
t.Fatalf("ListScheduledSends after delete = %d, want 0", len(list))
}
}
func TestListDueScheduledSends(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
dueID, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "due", SendAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
// Backdate it into the past so it's due.
if _, err := d.sql.Exec(`UPDATE scheduled_sends SET send_at=? WHERE id=?`,
time.Now().Add(-time.Minute).UTC().Format("2006-01-02 15:04:05"), dueID); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "not due", SendAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
due, err := d.ListDueScheduledSends()
if err != nil {
t.Fatalf("ListDueScheduledSends: %v", err)
}
if len(due) != 1 || due[0].ID != dueID {
t.Fatalf("ListDueScheduledSends = %+v, want only id=%d", due, dueID)
}
}
func TestDeleteScheduledSend_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
id, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "mine", SendAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := d.DeleteScheduledSend(id, other.ID); err != nil {
t.Fatalf("DeleteScheduledSend: %v", err)
}
list, err := d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends: %v", err)
}
if len(list) != 1 {
t.Fatalf("scheduled send was deleted by a non-owning user; list = %+v", list)
}
}
// ---- Labels ----
func TestLabelCRUDAndAssignment(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "label me")
// userID (the bootstrap admin) already has the 4 seeded default labels — use a name that
// doesn't collide with those ("Important", "Personal", "Work", "ToDo").
baseline, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels (baseline): %v", err)
}
label, err := d.CreateLabel(userID, "Project Zeta", "#e74c3c")
if err != nil {
t.Fatalf("CreateLabel: %v", err)
}
if label.ID == 0 {
t.Fatalf("CreateLabel returned id=0")
}
if _, err := d.CreateLabel(userID, "Project Zeta", "#000000"); err == nil {
t.Errorf("expected duplicate label name to fail")
}
if err := d.AssignLabel(msgID, label.ID, userID); err != nil {
t.Fatalf("AssignLabel: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 1 || msg.Labels[0].ID != label.ID {
t.Fatalf("Labels = %+v, want [%d]", msg.Labels, label.ID)
}
if err := d.UpdateLabel(label.ID, userID, "Project Zeta Renamed", "#ff0000"); err != nil {
t.Fatalf("UpdateLabel: %v", err)
}
labels, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels: %v", err)
}
if len(labels) != len(baseline)+1 {
t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1)
}
found := false
for _, l := range labels {
if l.ID == label.ID {
found = true
if l.Name != "Project Zeta Renamed" {
t.Errorf("renamed label Name = %q", l.Name)
}
}
}
if !found {
t.Fatalf("renamed label not found in ListLabels: %+v", labels)
}
if err := d.UnassignLabel(msgID, label.ID, userID); err != nil {
t.Fatalf("UnassignLabel: %v", err)
}
msg, err = d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 0 {
t.Fatalf("Labels after unassign = %+v, want none", msg.Labels)
}
if err := d.DeleteLabel(label.ID, userID); err != nil {
t.Fatalf("DeleteLabel: %v", err)
}
labels, err = d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels after delete: %v", err)
}
if len(labels) != len(baseline) {
t.Fatalf("ListLabels after delete = %+v, want back to baseline %+v", labels, baseline)
}
}
func TestAssignLabel_CannotCrossUserBoundary(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "protected")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
label, err := d.CreateLabel(other.ID, "Bob's label", "#123456")
if err != nil {
t.Fatalf("CreateLabel: %v", err)
}
// Bob tries to label userID's message with his own label — must be a no-op.
if err := d.AssignLabel(msgID, label.ID, other.ID); err != nil {
t.Fatalf("AssignLabel: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 0 {
t.Errorf("cross-user label assignment succeeded: %+v", msg.Labels)
}
}
// ---- Folder export support ----
func TestListMessageIDsByFolder(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
id1 := seedMessage(t, d, accountID, folderID, "1", "one")
id2 := seedMessage(t, d, accountID, folderID, "2", "two")
ids, err := d.ListMessageIDsByFolder(folderID, userID)
if err != nil {
t.Fatalf("ListMessageIDsByFolder: %v", err)
}
if len(ids) != 2 {
t.Fatalf("ids = %v, want 2 entries", ids)
}
got := map[int64]bool{ids[0]: true, ids[1]: true}
if !got[id1] || !got[id2] {
t.Errorf("ids = %v, want %d and %d", ids, id1, id2)
}
}
func TestListMessageIDsByFolder_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
seedMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
ids, err := d.ListMessageIDsByFolder(folderID, other.ID)
if err != nil {
t.Fatalf("ListMessageIDsByFolder: %v", err)
}
if len(ids) != 0 {
t.Errorf("non-owning user got message IDs from another user's folder: %v", ids)
}
}
// ---- Delete / star (existing behavior, previously untested) ----
func TestDeleteMessage(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "delete me")
if err := d.DeleteMessage(msgID, userID); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg != nil {
t.Errorf("message still present after delete: %+v", msg)
}
}
func TestToggleMessageStar(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "star me")
starred, err := d.ToggleMessageStar(msgID, userID)
if err != nil {
t.Fatalf("ToggleMessageStar: %v", err)
}
if !starred {
t.Errorf("expected starred=true after first toggle")
}
starred, err = d.ToggleMessageStar(msgID, userID)
if err != nil {
t.Fatalf("ToggleMessageStar: %v", err)
}
if starred {
t.Errorf("expected starred=false after second toggle")
}
}
+107
View File
@@ -0,0 +1,107 @@
package db
import (
"database/sql"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- PGP identities ----
// private_key_armor relies on OpenPGP's own native S2K passphrase protection (no app-layer
// encryption needed here, unlike smime.go's key_pem) — stored exactly as produced.
func (d *DB) ListPGPIdentities(accountID int64) ([]models.PGPIdentity, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM pgp_identities WHERE account_id=? ORDER BY created_at DESC`, accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PGPIdentity
for rows.Next() {
var p models.PGPIdentity
if err := rows.Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (d *DB) GetPGPIdentity(accountID, id int64) (*models.PGPIdentity, error) {
p := &models.PGPIdentity{}
err := d.sql.QueryRow(
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM pgp_identities WHERE account_id=? AND id=?`, accountID, id,
).Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (d *DB) CreatePGPIdentity(accountID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
res, err := d.sql.Exec(
`INSERT INTO pgp_identities (account_id, label, email, fingerprint, public_key_armor, private_key_armor) VALUES (?,?,?,?,?,?)`,
accountID, label, email, fingerprint, publicKeyArmor, privateKeyArmor,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) DeletePGPIdentity(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM pgp_identities WHERE id=? AND account_id=?`, id, accountID)
return err
}
// ---- PGP contact public keys (per-user address book) ----
func (d *DB) ListPGPContacts(userID int64) ([]models.PGPContact, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? ORDER BY email`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PGPContact
for rows.Next() {
var c models.PGPContact
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetPGPContactByEmail looks up a contact's public key by address. Returns nil, nil if not found.
func (d *DB) GetPGPContactByEmail(userID int64, email string) (*models.PGPContact, error) {
c := &models.PGPContact{}
err := d.sql.QueryRow(
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
userID, email,
).Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
func (d *DB) UpsertPGPContact(userID int64, email, label, fingerprint, publicKeyArmor string) error {
_, err := d.sql.Exec(
`INSERT INTO pgp_contacts (user_id, email, label, fingerprint, public_key_armor) VALUES (?,?,?,?,?)
ON CONFLICT(user_id, email) DO UPDATE SET label=excluded.label, fingerprint=excluded.fingerprint, public_key_armor=excluded.public_key_armor`,
userID, email, label, fingerprint, publicKeyArmor,
)
return err
}
func (d *DB) DeletePGPContact(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM pgp_contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
+152
View File
@@ -0,0 +1,152 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- Rules (filters) ----
func scanRule(rowConditions, rowActionOptions string, r *models.Rule) {
_ = json.Unmarshal([]byte(rowConditions), &r.Conditions)
_ = json.Unmarshal([]byte(rowActionOptions), &r.ActionOptions)
}
// ListRules returns all rules for an account, ordered by priority (lowest first, then id).
func (d *DB) ListRules(accountID int64) ([]models.Rule, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
action_options, is_active, created_at
FROM rules WHERE account_id=? ORDER BY priority ASC, id ASC`, accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Rule
for rows.Next() {
var r models.Rule
var conditionsJSON, optionsJSON string
var isActive int
if err := rows.Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt); err != nil {
return nil, err
}
r.IsActive = isActive == 1
scanRule(conditionsJSON, optionsJSON, &r)
out = append(out, r)
}
return out, rows.Err()
}
// ListActiveRules returns only is_active rules for an account, same ordering as ListRules.
func (d *DB) ListActiveRules(accountID int64) ([]models.Rule, error) {
all, err := d.ListRules(accountID)
if err != nil {
return nil, err
}
var active []models.Rule
for _, r := range all {
if r.IsActive {
active = append(active, r)
}
}
return active, nil
}
// GetRule fetches a single rule scoped to an account (so one user can't touch another's rule by id).
func (d *DB) GetRule(accountID, id int64) (*models.Rule, error) {
r := &models.Rule{}
var conditionsJSON, optionsJSON string
var isActive int
err := d.sql.QueryRow(
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
action_options, is_active, created_at
FROM rules WHERE account_id=? AND id=?`, accountID, id,
).Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
r.IsActive = isActive == 1
scanRule(conditionsJSON, optionsJSON, r)
return r, nil
}
// CreateRule inserts a new rule and returns its id.
func (d *DB) CreateRule(r *models.Rule) (int64, error) {
conditionsJSON, err := json.Marshal(r.Conditions)
if err != nil {
return 0, fmt.Errorf("marshal conditions: %w", err)
}
optionsJSON, err := json.Marshal(r.ActionOptions)
if err != nil {
return 0, fmt.Errorf("marshal action_options: %w", err)
}
if r.MatchType == "" {
r.MatchType = "all"
}
res, err := d.sql.Exec(
`INSERT INTO rules (account_id, name, priority, conditions, match_type, action, action_value, action_options, is_active)
VALUES (?,?,?,?,?,?,?,?,?)`,
r.AccountID, r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateRule replaces an existing rule's fields (scoped to account_id).
func (d *DB) UpdateRule(r *models.Rule) error {
conditionsJSON, err := json.Marshal(r.Conditions)
if err != nil {
return fmt.Errorf("marshal conditions: %w", err)
}
optionsJSON, err := json.Marshal(r.ActionOptions)
if err != nil {
return fmt.Errorf("marshal action_options: %w", err)
}
_, err = d.sql.Exec(
`UPDATE rules SET name=?, priority=?, conditions=?, match_type=?, action=?, action_value=?, action_options=?, is_active=?
WHERE id=? AND account_id=?`,
r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
r.ID, r.AccountID,
)
return err
}
// DeleteRule removes a rule (scoped to account_id).
func (d *DB) DeleteRule(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM rules WHERE id=? AND account_id=?`, id, accountID)
return err
}
// HasRecentAutoReply reports whether an auto-reply was already sent to recipientEmail
// for this rule within the last 24h, to prevent auto-reply loops.
func (d *DB) HasRecentAutoReply(accountID, ruleID int64, recipientEmail string) (bool, error) {
var n int
err := d.sql.QueryRow(
`SELECT COUNT(*) FROM auto_reply_log
WHERE account_id=? AND rule_id=? AND recipient_email=? COLLATE NOCASE
AND sent_at > datetime('now', '-1 day')`,
accountID, ruleID, recipientEmail,
).Scan(&n)
return n > 0, err
}
// LogAutoReply records that an auto-reply was just sent, for HasRecentAutoReply's window check.
func (d *DB) LogAutoReply(accountID, ruleID int64, recipientEmail string) error {
_, err := d.sql.Exec(
`INSERT INTO auto_reply_log (account_id, rule_id, recipient_email) VALUES (?,?,?)`,
accountID, ruleID, recipientEmail,
)
return err
}
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"database/sql"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- Signatures ----
// ListSignatures returns all signatures owned by a user.
func (d *DB) ListSignatures(userID int64) ([]models.Signature, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? ORDER BY name`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Signature
for rows.Next() {
var s models.Signature
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetSignature fetches one signature scoped to its owning user.
func (d *DB) GetSignature(userID, id int64) (*models.Signature, error) {
s := &models.Signature{}
err := d.sql.QueryRow(
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? AND id=?`, userID, id,
).Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return s, err
}
// CreateSignature inserts a new signature and returns its id.
func (d *DB) CreateSignature(userID int64, name, contentHTML string) (int64, error) {
res, err := d.sql.Exec(
`INSERT INTO signatures (user_id, name, content_html) VALUES (?,?,?)`, userID, name, contentHTML,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateSignature updates name/content of a signature (scoped to owner).
func (d *DB) UpdateSignature(userID, id int64, name, contentHTML string) error {
_, err := d.sql.Exec(
`UPDATE signatures SET name=?, content_html=? WHERE id=? AND user_id=?`, name, contentHTML, id, userID,
)
return err
}
// DeleteSignature removes a signature (scoped to owner). Any signature_defaults rows
// pointing at it are cleared automatically via ON DELETE SET NULL.
func (d *DB) DeleteSignature(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM signatures WHERE id=? AND user_id=?`, id, userID)
return err
}
// GetSignatureDefaults returns the default-new/default-reply signature ids for an account.
// Returns a zero-value struct (no error) if the account has no defaults row yet.
func (d *DB) GetSignatureDefaults(accountID int64) (models.SignatureDefaults, error) {
sd := models.SignatureDefaults{AccountID: accountID}
var newID, replyID sql.NullInt64
err := d.sql.QueryRow(
`SELECT default_new_id, default_reply_id FROM signature_defaults WHERE account_id=?`, accountID,
).Scan(&newID, &replyID)
if err == sql.ErrNoRows {
return sd, nil
}
if err != nil {
return sd, err
}
sd.DefaultNewID = newID.Int64
sd.DefaultReplyID = replyID.Int64
return sd, nil
}
// SetSignatureDefaults upserts which signature is default-for-new / default-for-reply on an account.
// A ProviderID of 0 clears that default (stored as NULL).
func (d *DB) SetSignatureDefaults(accountID, defaultNewID, defaultReplyID int64) error {
var newVal, replyVal interface{}
if defaultNewID > 0 {
newVal = defaultNewID
}
if defaultReplyID > 0 {
replyVal = defaultReplyID
}
_, err := d.sql.Exec(
`INSERT INTO signature_defaults (account_id, default_new_id, default_reply_id) VALUES (?,?,?)
ON CONFLICT(account_id) DO UPDATE SET default_new_id=excluded.default_new_id, default_reply_id=excluded.default_reply_id`,
accountID, newVal, replyVal,
)
return err
}
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"database/sql"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- S/MIME identities ----
// key_pem is encrypted at rest via d.enc (internal/crypto.Encryptor), same as OAuth tokens elsewhere.
// ListSMIMEIdentities returns all S/MIME identities for an account (key_pem decrypted).
func (d *DB) ListSMIMEIdentities(accountID int64) ([]models.SMIMEIdentity, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? ORDER BY created_at DESC`,
accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SMIMEIdentity
for rows.Next() {
var s models.SMIMEIdentity
var keyEnc string
if err := rows.Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt); err != nil {
return nil, err
}
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
out = append(out, s)
}
return out, rows.Err()
}
// GetSMIMEIdentity fetches one S/MIME identity scoped to its account (key_pem decrypted).
func (d *DB) GetSMIMEIdentity(accountID, id int64) (*models.SMIMEIdentity, error) {
s := &models.SMIMEIdentity{}
var keyEnc string
err := d.sql.QueryRow(
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? AND id=?`,
accountID, id,
).Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
return s, nil
}
// CreateSMIMEIdentity encrypts keyPEM at rest and inserts a new identity, returning its id.
func (d *DB) CreateSMIMEIdentity(accountID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
keyEnc, err := d.enc.Encrypt(keyPEM)
if err != nil {
return 0, err
}
res, err := d.sql.Exec(
`INSERT INTO smime_identities (account_id, cert_pem, key_pem, not_after) VALUES (?,?,?,?)`,
accountID, certPEM, keyEnc, notAfter,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// DeleteSMIMEIdentity removes an identity (scoped to account_id).
func (d *DB) DeleteSMIMEIdentity(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM smime_identities WHERE id=? AND account_id=?`, id, accountID)
return err
}
// ---- S/MIME contact certs (per-user address book, unencrypted — public certs only) ----
func (d *DB) ListSMIMEContacts(userID int64) ([]models.SMIMEContact, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? ORDER BY email`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SMIMEContact
for rows.Next() {
var c models.SMIMEContact
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetSMIMEContactByEmail looks up a contact's cert by address (used when signer/encryptor
// needs to know if a recipient has a cert on file). Returns nil, nil if not found.
func (d *DB) GetSMIMEContactByEmail(userID int64, email string) (*models.SMIMEContact, error) {
c := &models.SMIMEContact{}
err := d.sql.QueryRow(
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
userID, email,
).Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
// UpsertSMIMEContact adds or replaces a contact's cert for an email address.
func (d *DB) UpsertSMIMEContact(userID int64, email, certPEM string) error {
_, err := d.sql.Exec(
`INSERT INTO smime_contacts (user_id, email, cert_pem) VALUES (?,?,?)
ON CONFLICT(user_id, email) DO UPDATE SET cert_pem=excluded.cert_pem`,
userID, email, certPEM,
)
return err
}
// DeleteSMIMEContact removes a contact cert (scoped to owner).
func (d *DB) DeleteSMIMEContact(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM smime_contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
+694 -58
View File
@@ -4,26 +4,76 @@ package email
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"path/filepath"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// defaultNetTimeout bounds any dial/command whose caller passed a context
// with no deadline (e.g. TestConnection). Callers with a deadline (deltaSync,
// idleWatcher) get that deadline instead — see dialTimeout.
const defaultNetTimeout = 20 * time.Second
func dialTimeout(ctx context.Context) time.Duration {
if dl, ok := ctx.Deadline(); ok {
if d := time.Until(dl); d > 0 {
return d
}
}
return defaultNetTimeout
}
// connectIMAP dials host:port, trying implicit TLS first — this covers both
// the standard 993 port and non-standard implicit-SSL ports (e.g. 40993).
// If the server isn't speaking TLS at all (tls.RecordHeaderError), it falls
// back to plaintext + STARTTLS. A genuine TLS error (bad/self-signed cert)
// is NOT retried in plaintext — it's returned so callers can surface it.
// The dial and every subsequent IMAP command are bounded by timeout, so a
// misconfigured or unreachable server can never hang a caller forever.
func connectIMAP(ctx context.Context, host string, port int) (*client.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
c, err := client.DialWithDialerTLS(dialer, addr, &tls.Config{ServerName: host})
if err != nil {
if _, notTLS := err.(tls.RecordHeaderError); !notTLS {
return nil, err
}
c, err = client.DialWithDialer(dialer, addr)
if err != nil {
return nil, err
}
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
c.Logout()
return nil, fmt.Errorf("STARTTLS: %w", err)
}
}
c.Timeout = timeout
return c, nil
}
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
switch provider {
case gomailModels.ProviderGmail:
@@ -54,7 +104,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
return "XOAUTH2", []byte(payload), nil
}
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
// Next handles the XOAUTH2 challenge from the server.
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
// The correct response is an empty \x01 byte to abort; go-imap then gets the
// final tagged NO response and returns a proper error.
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
if len(challenge) > 0 {
// Decode and log the error from Microsoft so it appears in server logs
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
} else {
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
}
// Send empty response to let the server send the final error
return []byte("\x01"), nil
}
return nil, nil
}
type xoauth2SMTP struct{ user, token string }
@@ -80,6 +147,9 @@ type Client struct {
}
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
if account.Provider == gomailModels.ProviderOutlookPersonal {
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
}
host, port := imapHostFor(account.Provider)
if account.IMAPHost != "" {
host = account.IMAPHost
@@ -89,25 +159,40 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
return nil, fmt.Errorf("IMAP host not configured for account %s", account.EmailAddress)
}
addr := fmt.Sprintf("%s:%d", host, port)
var c *client.Client
var err error
if port == 993 {
c, err = client.DialTLS(addr, &tls.Config{ServerName: host})
} else {
c, err = client.Dial(addr)
if err == nil {
// Attempt STARTTLS; ignore error if server doesn't support it
_ = c.StartTLS(&tls.Config{ServerName: host})
}
}
c, err := connectIMAP(ctx, host, port)
if err != nil {
return nil, fmt.Errorf("IMAP connect %s: %w", addr, err)
return nil, fmt.Errorf("IMAP connect %s:%d: %w", host, port, err)
}
switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
tokenPreview := account.AccessToken
if len(tokenPreview) > 20 {
tokenPreview = tokenPreview[:20] + "..."
}
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
var claims struct {
Aud interface{} `json:"aud"`
Scp string `json:"scp"`
Upn string `json:"upn"`
}
if json.Unmarshal(payload, &claims) == nil {
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
} else {
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
account.EmailAddress, string(payload), tokenPreview)
}
} else {
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
account.EmailAddress, tokenPreview)
}
} else {
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
}
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
if err := c.Authenticate(sasl); err != nil {
c.Logout()
@@ -123,6 +208,15 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
return &Client{imap: c, account: account}, nil
}
// TestConnectionError details why a connection failed.
type TestConnectionError struct {
Type string `json:"type"` // "connection_error", "cert_error", "auth_error"
Message string `json:"message"`
CertPEM string `json:"cert_pem,omitempty"`
CertHash string `json:"cert_hash,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func TestConnection(account *gomailModels.EmailAccount) error {
c, err := Connect(context.Background(), account)
if err != nil {
@@ -132,12 +226,104 @@ func TestConnection(account *gomailModels.EmailAccount) error {
return nil
}
// TestConnectionDetailed returns structured error info including cert details on failure.
func TestConnectionDetailed(account *gomailModels.EmailAccount, db interface{}) *TestConnectionError {
host, port := imapHostFor(account.Provider)
if account.IMAPHost != "" {
host = account.IMAPHost
port = account.IMAPPort
}
addr := fmt.Sprintf("%s:%d", host, port)
ctx, cancel := context.WithTimeout(context.Background(), defaultNetTimeout)
defer cancel()
c, err := connectIMAP(ctx, host, port)
if err != nil {
errInfo := &TestConnectionError{Message: err.Error()}
// Check if it's a cert error
if certErr, ok := err.(tls.RecordHeaderError); ok && certErr.Msg != "" {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Try to dial and capture the cert for display
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil {
if len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
conn.Close()
}
} else {
// Check if underlying error is a cert error
if e, ok := err.(*x509.UnknownAuthorityError); ok {
errInfo.Type = "cert_error"
errInfo.Hostname = host
if cert := e.Cert; cert != nil {
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
} else if strings.Contains(err.Error(), "certificate") {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Dial insecurely to get the cert
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil && len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
conn.Close()
}
} else if strings.Contains(err.Error(), "auth") {
errInfo.Type = "auth_error"
} else {
errInfo.Type = "connection_error"
}
}
return errInfo
}
// Try auth
switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
if err := c.Authenticate(sasl); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("OAuth auth failed: %v", err)}
}
default:
if err := c.Login(account.EmailAddress, account.AccessToken); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("Login failed: %v", err)}
}
}
c.Close()
return nil
}
func mustEncodeCert(derBytes []byte) []byte {
// Encode DER to PEM
return []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
base64.StdEncoding.EncodeToString(derBytes)))
}
func (c *Client) Close() { c.imap.Logout() }
func (c *Client) DeleteMailbox(name string) error {
return c.imap.Delete(name)
}
func (c *Client) CreateMailbox(name string) error {
return c.imap.Create(name)
}
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
if _, err := c.imap.Select(srcMailbox, false); err != nil {
@@ -277,11 +463,14 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
}
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
// Fetch FetchRFC822 (full raw message) so we can properly parse MIME
// Full raw message, needed for proper MIME parsing — fetched via BODY.PEEK[] (not the
// plain RFC822/BODY[] item) so reading it during a background sync doesn't implicitly
// mark the message \Seen on the server before the user has actually opened it.
peekBody := &imap.BodySectionName{Peek: true}
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822, // full message including headers needed for proper MIME parsing
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -305,10 +494,11 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -392,8 +582,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
return m, nil
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
@@ -405,12 +601,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
// cidMap: Content-ID → base64 data URI for inline images
cidMap := make(map[string]string)
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
// Rewrite cid: references in HTML to data: URIs
if html != "" && len(cidMap) > 0 {
html = rewriteCIDReferences(html, cidMap)
}
return
}
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
func rewriteCIDReferences(html string, cidMap map[string]string) string {
for cid, dataURI := range cidMap {
// Match both with and without angle brackets
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
// Some clients wrap CID in angle brackets in the src attribute
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
}
return html
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
return parsePartIndexed(contentType, transferEncoding, body, []int{})
}
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
}
mediaType = strings.ToLower(mediaType)
decoded := decodeTransfer(transferEncoding, body)
switch {
case mediaType == "text/plain":
text = decodeCharset(params["charset"], decoded)
case mediaType == "text/html":
html = decodeCharset(params["charset"], decoded)
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
disposition := part.Header.Get("Content-Disposition")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
if filename != "" {
wd := mime.WordDecoder{}
if dec, e := wd.DecodeHeader(filename); e == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
partMediaLower := strings.ToLower(partMedia)
// Inline image with Content-ID → embed as data URI for cid: resolution
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
decodedPart := decodeTransfer(partTE, partBody)
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
cidMap[contentID] = dataURI
// Don't add as attachment chip — it's inline
continue
}
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
!strings.HasPrefix(partMediaLower, "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath,
})
continue
}
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
@@ -430,11 +758,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
@@ -444,24 +776,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
disposition := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
if strings.EqualFold(dispType, "attachment") {
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
// Filename from Content-Disposition or Content-Type params
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
// Decode RFC 2047 encoded filename
if filename != "" {
wd := mime.WordDecoder{}
if dec, err := wd.DecodeHeader(filename); err == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
partMedia, _, _ := mime.ParseMediaType(partCT)
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePart(partCT, partTE, partBody)
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
@@ -471,13 +820,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
attachments = append(attachments, atts...)
}
default:
// Any other type treat as attachment if it has a filename
mt, _, _ := mime.ParseMediaType(contentType)
_ = mt
// Any other non-text type with a filename → treat as attachment
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// mimePathString converts an int path like [1,2] to "1.2".
func mimePathString(path []int) string {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
return strings.Join(parts, ".")
}
func decodeTransfer(encoding string, data []byte) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
@@ -654,9 +1025,37 @@ func authSMTP(c *smtp.Client, account *gomailModels.EmailAccount, host string) e
}
}
// Signer optionally S/MIME-signs and/or PGP-encrypts the raw outgoing MIME message before
// it is sent. Implemented in internal/handlers using loaded S/MIME/PGP identities and
// contacts — kept as an interface here so this package never needs to import internal/db.
// A nil Signer (the common case: no certs configured) is a no-op.
type Signer interface {
SignAndEncrypt(account *gomailModels.EmailAccount, recipients []string, raw []byte) ([]byte, error)
}
// SendMessageFull sends an email via SMTP using the account's configured server.
// It also appends the sent message to the IMAP Sent folder.
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) error {
// It also appends the sent message to the IMAP Sent folder. signer may be nil.
// BuildRawMessage assembles the RFC822 message body for req (optionally signed/
// encrypted via signer), shared by the SMTP (SendMessageFull) and JMAP
// (SendMessageJMAP) send paths.
func BuildRawMessage(account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) ([]byte, error) {
var buf bytes.Buffer
buildMIMEMessage(&buf, account, req)
rawMsg := buf.Bytes()
if signer != nil {
allRecipients := append(append([]string{}, req.To...), req.CC...)
allRecipients = append(allRecipients, req.BCC...)
signed, err := signer.SignAndEncrypt(account, allRecipients, rawMsg)
if err != nil {
return nil, fmt.Errorf("sign/encrypt: %w", err)
}
rawMsg = signed
}
return rawMsg, nil
}
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
host, port := smtpHostFor(account.Provider)
if account.SMTPHost != "" {
host = account.SMTPHost
@@ -666,26 +1065,36 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
return fmt.Errorf("SMTP host not configured")
}
var buf bytes.Buffer
buildMIMEMessage(&buf, account, req)
rawMsg := buf.Bytes()
rawMsg, err := BuildRawMessage(account, req, signer)
if err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", host, port)
log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
var c *smtp.Client
var err error
if port == 465 {
// Implicit TLS (SMTPS)
conn, err2 := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err2 != nil {
return fmt.Errorf("SMTPS dial %s: %w", addr, err2)
}
c, err = smtp.NewClient(conn, host)
// Try implicit TLS first — covers both the standard 465 port and
// non-standard implicit-SSL ports (e.g. 40465). Fall back to plaintext +
// STARTTLS only if the server isn't speaking TLS at all; a genuine TLS
// error (bad/self-signed cert) is returned as-is, not retried in plaintext.
tlsConn, tlsErr := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: host})
if tlsErr == nil {
tlsConn.SetDeadline(time.Now().Add(timeout))
c, err = smtp.NewClient(tlsConn, host)
} else if _, notTLS := tlsErr.(tls.RecordHeaderError); !notTLS {
return fmt.Errorf("SMTPS dial %s: %w", addr, tlsErr)
} else {
// Plain SMTP then upgrade with STARTTLS (port 587 / 25)
c, err = smtp.Dial(addr)
conn, dialErr := dialer.Dial("tcp", addr)
if dialErr != nil {
return fmt.Errorf("SMTP dial %s: %w", addr, dialErr)
}
conn.SetDeadline(time.Now().Add(timeout))
c, err = smtp.NewClient(conn, host)
if err == nil {
// EHLO with sender's domain (not "localhost") to avoid rejection by strict MTAs
senderDomain := "localhost"
@@ -712,7 +1121,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
if err := authSMTP(c, account, host); err != nil {
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
}
log.Printf("[SMTP] auth OK")
logger.Debug("[SMTP] auth OK")
if err := c.Mail(account.EmailAddress); err != nil {
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
@@ -742,7 +1151,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
// DATA close is where the server accepts or rejects the message
return fmt.Errorf("SMTP server rejected message: %w", err)
}
log.Printf("[SMTP] message accepted by server")
logger.Debug("[SMTP] message accepted by server")
_ = c.Quit()
// Append to Sent folder via IMAP (best-effort, don't fail the send)
@@ -763,7 +1172,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// Use the sender's actual domain for Message-ID so it passes spam filters
domain := account.EmailAddress
if at := strings.Index(domain, "@"); at >= 0 {
@@ -781,24 +1191,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
buf.WriteString("\r\n")
// Plain text part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
hasAttachments := len(req.Attachments) > 0
if hasAttachments {
// Outer multipart/mixed wraps body + attachments
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
buf.WriteString("--" + mixedBoundary + "\r\n")
}
// Inner multipart/alternative: text/plain + text/html
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
// HTML part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw2 := quotedprintable.NewWriter(buf)
@@ -809,8 +1227,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
}
qpw2.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "--\r\n")
if hasAttachments {
for _, att := range req.Attachments {
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
buf.WriteString(encoded[i:end] + "\r\n")
}
}
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
}
buf.WriteString("--" + boundary + "--\r\n")
return msgID
}
@@ -875,6 +1316,168 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// draftsMailboxName finds the account's Drafts folder, or "" if none exists.
func (c *Client) draftsMailboxName() (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
for _, mb := range mailboxes {
if InferFolderType(mb.Name, mb.Attributes) == "drafts" {
return mb.Name, nil
}
}
return "", nil
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND. When prevUID
// is non-zero, that earlier draft copy is deleted first, so repeated autosaves of the same
// in-progress compose replace the draft in place instead of piling up duplicates. Returns
// the folder name (for sync purposes) and the new draft's UID (0 if it couldn't be
// determined — e.g. concurrent mailbox activity — in which case the next save just
// appends another copy rather than risk deleting the wrong message).
//
// UID lookup is done via a plain UID SEARCH ALL (already used elsewhere for sync) rather
// than SEARCH HEADER on a custom marker header: some real-world IMAP servers (observed:
// centrum.sk) reject arbitrary HEADER search keys with "Unsupported search key", which
// would silently break both the replace-in-place and the discard-on-close paths.
func (c *Client) AppendToDrafts(rawMsg []byte, prevUID uint32) (string, uint32, error) {
draftsName, err := c.draftsMailboxName()
if err != nil {
return "", 0, err
}
if draftsName == "" {
return "", 0, nil // no Drafts folder, skip silently
}
if prevUID != 0 {
_ = c.DeleteByUID(draftsName, prevUID, "")
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
if err := c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg)); err != nil {
return draftsName, 0, err
}
uids, err := c.ListAllUIDs(draftsName)
if err != nil || len(uids) == 0 {
return draftsName, 0, nil
}
newUID := uids[0]
for _, u := range uids {
if u > newUID {
newUID = u
}
}
return draftsName, newUID, nil
}
// DiscardDraftUID deletes a previously-autosaved draft by UID — used when the user closes
// an in-progress compose and chooses not to keep the draft that autosave already wrote to
// the server.
func (c *Client) DiscardDraftUID(uid uint32) error {
if uid == 0 {
return nil
}
draftsName, err := c.draftsMailboxName()
if err != nil || draftsName == "" {
return err
}
return c.DeleteByUID(draftsName, uid, "")
}
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
// raw message and parsing the requested MIME part path.
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
raw, err := c.FetchRawByUID(mailboxName, uid)
if err != nil {
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
}
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, "", "", fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
if err != nil {
return nil, "", "", err
}
return data, filename, contentType, nil
}
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
}
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
}
decoded := decodeTransfer(transferEncoding, body)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, "", "", fmt.Errorf("no boundary")
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, currentPath...), partIdx)
childPathStr := mimePathString(childPath)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
if childPathStr == targetPath {
// Found it
disposition := part.Header.Get("Content-Disposition")
_, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
wd2 := mime.WordDecoder{}
if dec, e := wd2.DecodeHeader(filename); e == nil {
filename = dec
}
partMedia, _, _ := mime.ParseMediaType(partCT)
return decodeTransfer(partTE, partBody), filename, partMedia, nil
}
// Recurse into multipart children
partMedia, _, _ := mime.ParseMediaType(partCT)
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
return data, fn, ct2, nil
}
}
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
// Leaf node — only matches if path is root (empty)
if targetPath == "" || targetPath == "1" {
return decoded, "", strings.ToLower(mediaType), nil
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
@@ -961,6 +1564,19 @@ func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) {
}, nil
}
// GetFolderCounts returns the true total/unread message counts for a mailbox straight from
// the server (IMAP STATUS), independent of how much history has been synced locally — a
// SELECT's response doesn't carry a real unseen count (only the sequence number of the
// first unseen message), so this needs its own STATUS query. STATUS doesn't disturb the
// currently selected mailbox, so it's safe to call alongside GetFolderStatus/syncFolder.
func (c *Client) GetFolderCounts(mailboxName string) (total, unread uint32, err error) {
status, err := c.imap.Status(mailboxName, []imap.StatusItem{imap.StatusMessages, imap.StatusUnseen})
if err != nil {
return 0, 0, err
}
return status.Messages, status.Unseen, nil
}
// ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection.
func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
mbox, err := c.imap.Select(mailboxName, true)
@@ -977,6 +1593,25 @@ func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
return uids, nil
}
// FetchByUIDs fetches specific messages by UID, regardless of the incremental last_seen_uid
// cursor — used by the sync reconciliation pass (see syncer.syncFolder) to recover messages
// that exist on the server but are missing from the local cache, so a local-only data loss
// (from any cause) self-heals on the next sync instead of leaving that message permanently
// unreachable (incremental fetch only ever asks for UIDs newer than what it last saw).
func (c *Client) FetchByUIDs(mailboxName string, uids []uint32) ([]*gomailModels.Message, error) {
if len(uids) == 0 {
return nil, nil
}
if _, err := c.imap.Select(mailboxName, true); err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
seqSet := new(imap.SeqSet)
for _, uid := range uids {
seqSet.AddNum(uid)
}
return c.fetchByUIDSet(seqSet)
}
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
mbox, err := c.imap.Select(mailboxName, true)
@@ -991,10 +1626,11 @@ func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomai
seqSet := new(imap.SeqSet)
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
+47
View File
@@ -0,0 +1,47 @@
package email
import "testing"
// InferFolderType drives how the sync engine classifies each IMAP folder (inbox/sent/drafts/
// trash/spam/archive/custom) — used for default folder discovery, DeleteByUID's trash-move
// target lookup, and rule actions like "mark_as_spam". Covers both the IMAP SPECIAL-USE
// attribute path (authoritative when the server sends it) and the name-guessing fallback.
func TestInferFolderType(t *testing.T) {
cases := []struct {
name string
folderName string
attrs []string
want string
}{
{"special-use inbox", "Whatever", []string{`\Inbox`}, "inbox"},
{"special-use sent", "Whatever", []string{`\Sent`}, "sent"},
{"special-use drafts", "Whatever", []string{`\Drafts`}, "drafts"},
{"special-use trash", "Whatever", []string{`\Trash`}, "trash"},
{"special-use deleted alias", "Whatever", []string{`\Deleted`}, "trash"},
{"special-use junk", "Whatever", []string{`\Junk`}, "spam"},
{"special-use spam alias", "Whatever", []string{`\Spam`}, "spam"},
{"special-use archive", "Whatever", []string{`\Archive`}, "archive"},
{"special-use case-insensitive", "Whatever", []string{`\SENT`}, "sent"},
{"special-use wins over misleading name", "Trash Talk", []string{`\Sent`}, "sent"},
{"name INBOX exact", "INBOX", nil, "inbox"},
{"name lowercase inbox", "inbox", nil, "inbox"},
{"name contains sent", "Sent Items", nil, "sent"},
{"name contains draft", "Drafts", nil, "drafts"},
{"name contains trash", "Trash", nil, "trash"},
{"name contains deleted", "Deleted Items", nil, "trash"},
{"name contains spam", "Spam", nil, "spam"},
{"name contains junk", "Junk E-mail", nil, "spam"},
{"name contains archive", "Archive", nil, "archive"},
{"unrecognized name is custom", "Projects", nil, "custom"},
{"gmail-style path", "[Gmail]/Sent Mail", nil, "sent"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferFolderType(tc.folderName, tc.attrs)
if got != tc.want {
t.Errorf("InferFolderType(%q, %v) = %q, want %q", tc.folderName, tc.attrs, got, tc.want)
}
})
}
}
+48
View File
@@ -0,0 +1,48 @@
package email
import (
"context"
"fmt"
"github.com/ghostersk/gowebmail/internal/jmap"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// SaveDraftJMAP saves req as a draft on the account's JMAP server, mirroring
// AppendToDrafts' replace-in-place behavior: if prevID is non-empty that earlier draft
// copy is deleted first (best-effort — a failure there shouldn't block saving the new
// one), then the new message is uploaded + imported into the Drafts mailbox and flagged
// $draft. Returns the new draft's email id.
func SaveDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, prevID string) (string, error) {
rawMsg, err := BuildRawMessage(account, req, nil)
if err != nil {
return "", err
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
draftsID, err := jc.FindMailboxByRole(ctx, "drafts")
if err != nil {
return "", fmt.Errorf("jmap find Drafts folder: %w", err)
}
if prevID != "" {
_ = jc.DeleteEmail(ctx, prevID)
}
blobID, err := jc.UploadBlob(ctx, rawMsg)
if err != nil {
return "", fmt.Errorf("jmap upload draft: %w", err)
}
newID, err := jc.ImportEmail(ctx, blobID, draftsID)
if err != nil {
return "", fmt.Errorf("jmap import draft: %w", err)
}
_ = jc.SetKeyword(ctx, newID, "$draft", true)
return newID, nil
}
// DeleteDraftJMAP deletes a previously-autosaved draft by id.
func DeleteDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, id string) error {
if id == "" {
return nil
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
return jc.DeleteEmail(ctx, id)
}
+27
View File
@@ -0,0 +1,27 @@
package email
import (
"context"
"fmt"
"github.com/ghostersk/gowebmail/internal/jmap"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// SendMessageJMAP sends via the account's JMAP server instead of SMTP — used
// for ProviderJMAP accounts. Builds the same RFC822 body as SendMessageFull
// (optionally signed/encrypted via signer), then uploads + imports + submits
// it over JMAP; the import into Sent replaces SMTP's separate append-to-Sent
// step, since JMAP's Email/import already files the message.
func SendMessageJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
rawMsg, err := BuildRawMessage(account, req, signer)
if err != nil {
return err
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
sentID, err := jc.FindMailboxByRole(ctx, "sent")
if err != nil {
return fmt.Errorf("jmap find Sent folder: %w", err)
}
return jc.Send(ctx, sentID, rawMsg)
}
+97
View File
@@ -0,0 +1,97 @@
// Package geo provides IP geolocation lookup using the free ip-api.com service.
// No API key is required. Rate limit: 45 requests/minute on the free tier.
// Results are cached in memory to reduce API calls.
package geo
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
type GeoResult struct {
CountryCode string
Country string
Cached bool
}
type cacheEntry struct {
result GeoResult
fetchedAt time.Time
}
var (
mu sync.Mutex
cache = make(map[string]*cacheEntry)
)
const cacheTTL = 24 * time.Hour
// Lookup returns the country for an IP address.
// Returns empty strings on failure (private IPs, rate limit, etc.).
func Lookup(ip string) GeoResult {
// Skip private / loopback
parsed := net.ParseIP(ip)
if parsed == nil || isPrivate(parsed) {
return GeoResult{}
}
mu.Lock()
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
mu.Unlock()
r := e.result
r.Cached = true
return r
}
mu.Unlock()
result := fetchFromAPI(ip)
mu.Lock()
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
mu.Unlock()
return result
}
func fetchFromAPI(ip string) GeoResult {
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("geo lookup failed for %s: %v", ip, err)
return GeoResult{}
}
defer resp.Body.Close()
var data struct {
Status string `json:"status"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
return GeoResult{}
}
return GeoResult{
CountryCode: strings.ToUpper(data.CountryCode),
Country: data.Country,
}
}
func isPrivate(ip net.IP) bool {
privateRanges := []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "::1/128", "fc00::/7",
}
for _, cidr := range privateRanges {
_, network, _ := net.ParseCIDR(cidr)
if network != nil && network.Contains(ip) {
return true
}
}
return false
}
+487
View File
@@ -0,0 +1,487 @@
// Package graph provides Microsoft Graph API mail access for personal
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
// so we use the Graph REST API instead with the JWT access token.
package graph
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
const baseURL = "https://graph.microsoft.com/v1.0/me"
// Client wraps Graph API calls for a single account.
type Client struct {
token string
account *models.EmailAccount
http *http.Client
}
// New creates a Graph client for the given account.
func New(account *models.EmailAccount) *Client {
return &Client{
token: account.AccessToken,
account: account,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
fullURL := path
if !strings.HasPrefix(path, "https://") {
fullURL = baseURL + path
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
}
return nil
}
func (c *Client) deleteReq(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
}
return nil
}
// ---- Folders ----
// GraphFolder represents a mail folder from Graph API.
type GraphFolder struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
TotalCount int `json:"totalItemCount"`
UnreadCount int `json:"unreadItemCount"`
WellKnown string `json:"wellKnownName"`
}
type foldersResp struct {
Value []GraphFolder `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListFolders returns all mail folders for the account.
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
var all []GraphFolder
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
for path != "" {
var resp foldersResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// ---- Messages ----
// EmailAddress wraps a Graph email address object.
type EmailAddress struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
}
// GraphMessage represents a mail message from Graph API.
type GraphMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
IsRead bool `json:"isRead"`
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
ReceivedDateTime time.Time `json:"receivedDateTime"`
HasAttachments bool `json:"hasAttachments"`
From *EmailAddress `json:"from"`
ToRecipients []EmailAddress `json:"toRecipients"`
CcRecipients []EmailAddress `json:"ccRecipients"`
Body struct {
Content string `json:"content"`
ContentType string `json:"contentType"`
} `json:"body"`
InternetMessageID string `json:"internetMessageId"`
}
// IsFlagged returns true if the message is flagged.
func (m *GraphMessage) IsFlagged() bool {
return m.Flag.Status == "flagged"
}
// FromName returns the sender display name.
func (m *GraphMessage) FromName() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Name
}
// FromEmail returns the sender email address.
func (m *GraphMessage) FromEmail() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Address
}
// ToList returns a comma-separated list of recipients.
func (m *GraphMessage) ToList() string {
var parts []string
for _, r := range m.ToRecipients {
parts = append(parts, r.EmailAddress.Address)
}
return strings.Join(parts, ", ")
}
type messagesResp struct {
Value []GraphMessage `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListMessages returns messages in a folder, optionally filtered by received date.
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
filter := ""
if !since.IsZero() {
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
}
top := 50
if maxResults > 0 && maxResults < top {
top = maxResults
}
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
folderID, top, filter)
var all []GraphMessage
for path != "" {
var resp messagesResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// GetMessage returns a single message with full body.
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
var msg GraphMessage
err := c.get(ctx, "/messages/"+msgID+
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
&msg)
return &msg, err
}
// GetMessageRaw returns the raw RFC 822 message bytes.
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
baseURL+"/messages/"+msgID+"/$value", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// MarkRead sets the isRead flag on a message.
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
}
// MarkFlagged sets or clears the flag on a message.
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
status := "notFlagged"
if flagged {
status = "flagged"
}
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
"flag": map[string]string{"flagStatus": status},
})
}
// DeleteMessage moves a message to Deleted Items (soft delete).
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
return c.deleteReq(ctx, "/messages/"+msgID)
}
// MoveMessage moves a message to a different folder.
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph move returned %d", resp.StatusCode)
}
return nil
}
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
// WellKnown field is not selectable via $select — we infer from displayName instead.
func InferFolderType(displayName string) string {
switch strings.ToLower(displayName) {
case "inbox":
return "inbox"
case "sent items", "sent":
return "sent"
case "drafts":
return "drafts"
case "deleted items", "trash", "bin":
return "trash"
case "junk email", "spam", "junk":
return "spam"
case "archive":
return "archive"
default:
return "custom"
}
}
// WellKnownToFolderType kept for compatibility.
func WellKnownToFolderType(wk string) string {
return InferFolderType(wk)
}
// ---- Send mail ----
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
func stripHTML(s string) string {
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&#39;", "'", "&nbsp;", " ").Replace(s)
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// SendMail sends an email via Graph API POST /me/sendMail.
// Sets both HTML and plain-text body to improve deliverability (spam filters
// penalise HTML-only messages with no text/plain alternative).
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
// Build body: always provide both HTML and plain text for better deliverability
body := map[string]string{
"contentType": "HTML",
"content": req.BodyHTML,
}
if req.BodyHTML == "" {
body["contentType"] = "Text"
body["content"] = req.BodyText
}
// Set explicit from with display name
var fromField interface{}
if c.account.DisplayName != "" {
fromField = map[string]interface{}{
"emailAddress": map[string]string{
"address": c.account.EmailAddress,
"name": c.account.DisplayName,
},
}
}
msg := map[string]interface{}{
"subject": req.Subject,
"body": body,
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
if fromField != nil {
msg["from"] = fromField
}
if len(req.Attachments) > 0 {
var atts []map[string]interface{}
for _, a := range req.Attachments {
atts = append(atts, map[string]interface{}{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": a.Filename,
"contentType": a.ContentType,
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
})
}
msg["attachments"] = atts
}
payload, err := json.Marshal(map[string]interface{}{
"message": msg,
"saveToSentItems": true,
})
if err != nil {
return fmt.Errorf("marshal sendMail: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/sendMail", strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("build sendMail request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return fmt.Errorf("sendMail request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
}
return nil
}
func (c *Client) post(ctx context.Context, path string, body map[string]interface{}, out interface{}) error {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("graph POST %s returned %d: %s", path, resp.StatusCode, string(errBody))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func draftBody(req *models.ComposeRequest) map[string]interface{} {
body := map[string]string{"contentType": "HTML", "content": req.BodyHTML}
if req.BodyHTML == "" {
body["contentType"] = "Text"
body["content"] = req.BodyText
}
return map[string]interface{}{
"subject": req.Subject,
"body": body,
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
}
// CreateDraft creates a new draft message (POST /me/messages, which — unlike /sendMail —
// files into Drafts instead of sending) and returns its Graph message id.
func (c *Client) CreateDraft(ctx context.Context, req *models.ComposeRequest) (string, error) {
var out struct {
ID string `json:"id"`
}
if err := c.post(ctx, "/messages", draftBody(req), &out); err != nil {
return "", err
}
return out.ID, nil
}
// UpdateDraft overwrites an existing draft's subject/body/recipients in place.
func (c *Client) UpdateDraft(ctx context.Context, draftID string, req *models.ComposeRequest) error {
return c.patch(ctx, "/messages/"+draftID, draftBody(req))
}
// DeleteDraft deletes a draft message by id — used when the user closes a compose panel and
// chooses not to keep the draft that autosave already wrote to the server.
func (c *Client) DeleteDraft(ctx context.Context, draftID string) error {
return c.deleteReq(ctx, "/messages/"+draftID)
}
func graphRecipients(addrs []string) []map[string]interface{} {
result := []map[string]interface{}{}
for _, a := range addrs {
a = strings.TrimSpace(a)
if a != "" {
result = append(result, map[string]interface{}{
"emailAddress": map[string]string{"address": a},
})
}
}
return result
}
+76 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/gorilla/mux"
@@ -108,9 +109,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
DisableMFA bool `json:"disable_mfa"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.DisableMFA {
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
"changed": changed,
})
}
// ---- IP Blocks ----
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
blocks, err := h.db.ListIPBlocks()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
return
}
if blocks == nil {
blocks = []db.IPBlock{}
}
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
}
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Reason string `json:"reason"`
BanHours int `json:"ban_hours"` // 0 = permanent
}
json.NewDecoder(r.Body).Decode(&req)
if req.IP == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
// Try geo lookup for the IP being manually blocked
g := geo.Lookup(req.IP)
if req.Reason == "" {
req.Reason = "Manual admin block"
}
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
ip := mux.Vars(r)["ip"]
if ip == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
if err := h.db.UnblockIP(ip); err != nil {
h.writeError(w, http.StatusInternalServerError, "unblock failed")
return
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Login Attempts ----
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
return
}
if stats == nil {
stats = []db.LoginAttemptStat{}
}
h.writeJSON(w, map[string]interface{}{"attempts": stats})
}
+1314 -88
View File
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// newTestHandler builds an APIHandler backed by a fresh, migrated temp-file DB, with no
// syncer/cfg — sufficient for the local-only handlers under test here (Labels, Snooze,
// Send-later, Folder export), none of which touch IMAP/Graph/JMAP or config.
func newTestHandler(t *testing.T) (*APIHandler, *db.DB, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
d, err := db.New(path, key)
if err != nil {
t.Fatalf("db.New: %v", err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return &APIHandler{db: d}, d, 1 // bootstrap admin
}
func seedTestAccountAndFolder(t *testing.T, d *db.DB, userID int64) (accountID, folderID int64) {
t.Helper()
acc := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: "user@example.com", DisplayName: "Test User", Color: "#4A90D9",
}
if err := d.CreateAccount(acc); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
f, err := d.GetFolderByPath(acc.ID, "INBOX")
if err != nil || f == nil {
t.Fatalf("GetFolderByPath: %v", err)
}
return acc.ID, f.ID
}
func seedTestMessage(t *testing.T, d *db.DB, accountID, folderID int64, remoteUID, subject string) int64 {
t.Helper()
m := &models.Message{
AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID,
Subject: subject, FromName: "Sender", FromEmail: "sender@example.com",
ToList: "user@example.com", BodyText: "hello", Date: time.Now(),
}
if err := d.UpsertMessage(m); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
return m.ID
}
// authedRequest builds a request carrying userID the way RequireAuth middleware would (via
// context), with mux path vars set directly (bypassing the router) and an optional JSON body.
func authedRequest(t *testing.T, method, target string, userID int64, vars map[string]string, body interface{}) *http.Request {
t.Helper()
var r *http.Request
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
r = httptest.NewRequest(method, target, bytes.NewReader(b))
} else {
r = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID)
r = r.WithContext(ctx)
if vars != nil {
r = mux.SetURLVars(r, vars)
}
return r
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder, v interface{}) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
}
// ---- Labels ----
func TestCreateAndListLabels(t *testing.T) {
h, d, userID := newTestHandler(t)
baseline, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels (baseline): %v", err)
}
rec := httptest.NewRecorder()
h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "Project Zeta", "Color": "#abcdef"}))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CreateLabel status = %d, body = %s", rec.Code, rec.Body.String())
}
var created models.Label
decodeJSON(t, rec, &created)
if created.ID == 0 || created.Name != "Project Zeta" {
t.Fatalf("created label = %+v", created)
}
rec = httptest.NewRecorder()
h.ListLabels(rec, authedRequest(t, "GET", "/api/labels", userID, nil, nil))
var labels []models.Label
decodeJSON(t, rec, &labels)
if len(labels) != len(baseline)+1 {
t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1)
}
}
func TestCreateLabel_MissingFields(t *testing.T) {
h, _, userID := newTestHandler(t)
rec := httptest.NewRecorder()
h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "", "Color": "#fff"}))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
// ---- Snooze ----
func TestSnoozeMessage_Handler(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
msgID := seedTestMessage(t, d, accountID, folderID, "1", "snooze via handler")
until := time.Now().Add(time.Hour).Format(time.RFC3339)
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(msgID)}
h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{"until": until}))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("SnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil))
var page models.PagedMessages
decodeJSON(t, rec, &page)
if page.Total != 1 || len(page.Messages) != 1 || page.Messages[0].ID != msgID {
t.Fatalf("SnoozedMessages = %+v", page)
}
rec = httptest.NewRecorder()
h.UnsnoozeMessage(rec, authedRequest(t, "DELETE", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("UnsnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil))
decodeJSON(t, rec, &page)
if page.Total != 0 {
t.Fatalf("SnoozedMessages after unsnooze = %+v, want empty", page)
}
}
func TestSnoozeMessage_RejectsMissingUntil(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
msgID := seedTestMessage(t, d, accountID, folderID, "1", "no until")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(msgID)}
h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{}))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// ---- Send-later ----
func TestCreateScheduledSend_RejectsPastDate(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "hi", "send_at": time.Now().Add(-time.Hour).Format(time.RFC3339),
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestCreateScheduledSend_RejectsFileAttachments(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "hi", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339),
"attachments": []map[string]string{{"filename": "x.pdf", "content_type": "application/pdf"}},
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestScheduledSend_CreateListCancel(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "Scheduled", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339),
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CreateScheduledSend status = %d, body = %s", rec.Code, rec.Body.String())
}
var created struct {
OK bool `json:"ok"`
ID int64 `json:"id"`
}
decodeJSON(t, rec, &created)
if !created.OK || created.ID == 0 {
t.Fatalf("CreateScheduledSend result = %+v", created)
}
rec = httptest.NewRecorder()
h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil))
var list []models.ScheduledSend
decodeJSON(t, rec, &list)
if len(list) != 1 || list[0].ID != created.ID {
t.Fatalf("ListScheduledSends = %+v", list)
}
rec = httptest.NewRecorder()
vars := map[string]string{"id": itoa(created.ID)}
h.CancelScheduledSend(rec, authedRequest(t, "DELETE", "/api/scheduled-sends/"+itoa(created.ID), userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CancelScheduledSend status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil))
decodeJSON(t, rec, &list)
if len(list) != 0 {
t.Fatalf("ListScheduledSends after cancel = %+v, want empty", list)
}
}
// ---- Folder export ----
func TestExportFolder_Zip(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "one")
seedTestMessage(t, d, accountID, folderID, "2", "two")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
target := "/api/folders/" + itoa(folderID) + "/export?format=zip"
h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Errorf("Content-Type = %q", ct)
}
body := rec.Body.Bytes()
if len(body) < 2 || string(body[:2]) != "PK" {
t.Errorf("body doesn't look like a zip (got %d bytes, prefix %q)", len(body), body[:min(4, len(body))])
}
}
func TestExportFolder_Mbox(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "one")
seedTestMessage(t, d, accountID, folderID, "2", "two")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
target := "/api/folders/" + itoa(folderID) + "/export?format=mbox"
h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/mbox" {
t.Errorf("Content-Type = %q", ct)
}
body := rec.Body.String()
count := bytesCount(body, "From MAILER-DAEMON")
if count != 2 {
t.Errorf("mbox has %d envelope lines, want 2; body:\n%s", count, body)
}
}
func TestExportFolder_EmptyFolderRejected(t *testing.T) {
h, d, userID := newTestHandler(t)
_, folderID := seedTestAccountAndFolder(t, d, userID)
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", userID, vars, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestExportFolder_WrongUserScoped(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", other.ID, vars, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("non-owning user's export status = %d, want %d (folder empty for them); body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
// ---- small local helpers ----
func itoa(id int64) string {
if id == 0 {
return "0"
}
neg := id < 0
if neg {
id = -id
}
var buf [20]byte
i := len(buf)
for id > 0 {
i--
buf[i] = byte('0' + id%10)
id /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
func bytesCount(s, substr string) int {
count := 0
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
count++
i += len(substr) - 1
}
}
return count
}
+10
View File
@@ -17,3 +17,13 @@ type AppHandler struct {
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil)
}
// ViewMessage renders a single message in a full browser tab.
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "message", nil)
}
// ComposePage renders the compose form in a full browser tab.
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "compose", nil)
}
+345 -11
View File
@@ -4,9 +4,15 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"log"
"net"
"net/http"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto"
@@ -14,6 +20,7 @@ import (
"github.com/ghostersk/gowebmail/internal/mfa"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/pgp"
"golang.org/x/oauth2"
)
@@ -23,6 +30,8 @@ type AuthHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
syncer interface{ TriggerReconcile() }
pgpCache *pgp.Cache
}
// ---- Login ----
@@ -53,6 +62,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
// Per-user IP access check — evaluated before password to avoid timing leaks
switch h.db.CheckUserIPAccess(user.ID, ip) {
case "deny":
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
return
case "skip_brute":
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
w.Header().Set("X-Skip-Brute", "1")
}
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
uid := user.ID
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
@@ -83,6 +103,9 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent())
}
h.db.DeleteSession(cookie.Value)
if h.pgpCache != nil {
h.pgpCache.ClearSession(cookie.Value)
}
}
http.SetCookie(w, &http.Cookie{
Name: "gomail_session", Value: "", MaxAge: -1, Path: "/",
@@ -142,8 +165,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
@@ -299,12 +322,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
action := "gmail:" + userInfo.Email
if !created {
action = "gmail-reconnect:" + userInfo.Email
}
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
if h.syncer != nil {
h.syncer.TriggerReconcile()
}
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
}
@@ -319,13 +350,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
state := encodeOAuthState(userID, "outlook")
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
oauth2.SetAuthURLParam("prompt", "consent"))
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
// Microsoft returns ?error=...&error_description=... instead of ?code=...
// when the user denies consent or the app has misconfigured permissions.
if msErr := r.URL.Query().Get("error"); msErr != "" {
msDesc := r.URL.Query().Get("error_description")
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
<h2>Microsoft returned: %s</h2>
<pre>%s</pre>
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
<ul>
<li>In Azure portal API Permissions Add a permission</li>
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
<li>Search: <strong>Office 365 Exchange Online</strong></li>
<li>Delegated permissions add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
<li>Then click <strong>Grant admin consent</strong></li>
<li>Do NOT use Microsoft Graph versions of these scopes</li>
</ul>
<p><a href="/"> Back to GoWebMail</a></p>
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
return
}
if code == "" {
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
return
}
userID, provider := decodeOAuthState(state)
if userID == 0 || provider != "outlook" {
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
@@ -335,29 +404,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
token, err := oauthCfg.Exchange(r.Context(), code)
if err != nil {
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
// Show the raw error in the browser so the user can diagnose the problem
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
<h2>Outlook OAuth Token Exchange Failed</h2>
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
<pre>%s</pre>
<hr>
<p><strong>Things to check:</strong></p>
<ul>
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
<li>Tenant ID in config: <code>%s</code> must match your app's "Supported account types"</li>
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
</ul>
<p><a href="/"> Back to GoWebMail</a></p>
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
return
}
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
if err != nil {
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return
}
logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
// Exchange initial token for one scoped to https://outlook.office.com
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
imapToken, err := goauth.ExchangeForIMAPToken(
r.Context(),
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, token.RefreshToken,
)
if err != nil {
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
imapToken = token
} else {
logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
if imapToken.RefreshToken == "" {
imapToken.RefreshToken = token.RefreshToken
}
}
accounts, _ := h.db.ListAccountsByUser(userID)
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
color := colors[len(accounts)%len(colors)]
account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderOutlook,
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
action := "outlook:" + userInfo.Email()
if !created {
action = "outlook-reconnect:" + userInfo.Email()
}
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
if h.syncer != nil {
h.syncer.TriggerReconcile()
}
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
}
@@ -403,3 +523,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ---- Profile Updates ----
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return
}
var req struct {
Field string `json:"field"` // "email" | "username"
Value string `json:"value"`
Password string `json:"password"` // current password required for confirmation
}
json.NewDecoder(r.Body).Decode(&req)
if req.Value == "" {
writeJSONError(w, http.StatusBadRequest, "value required")
return
}
if req.Password == "" {
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
return
}
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
writeJSONError(w, http.StatusForbidden, "incorrect password")
return
}
switch req.Field {
case "email":
// Check uniqueness
existing, _ := h.db.GetUserByEmail(req.Value)
if existing != nil && existing.ID != userID {
writeJSONError(w, http.StatusConflict, "email already in use")
return
}
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
return
}
case "username":
existing, _ := h.db.GetUserByUsername(req.Value)
if existing != nil && existing.ID != userID {
writeJSONError(w, http.StatusConflict, "username already in use")
return
}
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
return
}
default:
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
return
}
ip := middleware.ClientIP(r)
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- Per-User IP Rules ----
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
rule, err := h.db.GetUserIPRule(userID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "db error")
return
}
if rule == nil {
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rule)
}
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
IPList string `json:"ip_list"` // comma-separated
}
json.NewDecoder(r.Body).Decode(&req)
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
if !validModes[req.Mode] {
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
return
}
// Validate IPs
for _, rawIP := range db.SplitIPList(req.IPList) {
if net.ParseIP(rawIP) == nil {
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
return
}
}
if req.Mode == "disabled" {
h.db.DeleteUserIPRule(userID)
} else {
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
return
}
}
ip := middleware.ClientIP(r)
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- Outlook Personal (Graph API) OAuth2 ----
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
if h.cfg.MicrosoftClientID == "" {
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
return
}
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
userID := middleware.GetUserID(r)
state := encodeOAuthState(userID, "outlook_personal")
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, redirectURL)
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
oauth2.SetAuthURLParam("prompt", "consent"))
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
h.cfg.MicrosoftTenantID, redirectURL)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if msErr := r.URL.Query().Get("error"); msErr != "" {
msDesc := r.URL.Query().Get("error_description")
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
<h2>Microsoft returned: %s</h2><pre>%s</pre>
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
<p><a href="/"> Back</a></p></body></html>`,
html.EscapeString(msErr), html.EscapeString(msDesc))
return
}
if code == "" {
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
return
}
userID, provider := decodeOAuthState(state)
if userID == 0 || provider != "outlook_personal" {
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
return
}
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
token, err := oauthCfg.Exchange(r.Context(), code)
if err != nil {
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
return
}
// Get user info from ID token
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
if err != nil {
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return
}
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
tokenParts := len(strings.Split(token.AccessToken, "."))
logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d",
userInfo.Email(), tokenParts)
accounts, _ := h.db.ListAccountsByUser(userID)
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
color := colors[len(accounts)%len(colors)]
account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderOutlookPersonal,
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
action := "outlook-personal:" + userInfo.Email()
if !created {
action = "outlook-personal-reconnect:" + userInfo.Email()
}
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
if h.syncer != nil {
h.syncer.TriggerReconcile()
}
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
}
+462
View File
@@ -0,0 +1,462 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/pgp"
"github.com/ghostersk/gowebmail/internal/smime"
)
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
// PGP contact keys the sending account/user actually has on file. Implements
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
// design: "S/MIME certificates sign... PGP keys encrypt...".
type dbSigner struct {
db *db.DB
userID int64
}
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
out := raw
identities, err := s.db.ListSMIMEIdentities(account.ID)
if err == nil && len(identities) > 0 {
id := identities[0]
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
if err != nil {
return nil, fmt.Errorf("smime sign: %w", err)
}
out = signed
}
if len(recipients) > 0 {
var pgpEntities []*openpgp.Entity
allHaveKeys := true
for _, addr := range recipients {
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
if err != nil || contact == nil {
allHaveKeys = false
break
}
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
if err != nil {
allHaveKeys = false
break
}
pgpEntities = append(pgpEntities, entity)
}
if allHaveKeys && len(pgpEntities) > 0 {
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
if err != nil {
return nil, fmt.Errorf("pgp encrypt: %w", err)
}
out = encrypted
}
}
return out, nil
}
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
func (h *APIHandler) newSigner(userID int64) *dbSigner {
return &dbSigner{db: h.db, userID: userID}
}
// ---- S/MIME handlers ----
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListSMIMEIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
account, _ := h.db.GetAccount(req.AccountID)
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
return
}
cert, _ := smime.ParseCertPEM(certPEM)
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
file, _, err := r.FormFile("p12_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "p12_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
password := r.FormValue("p12_password")
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
cert, _ := smime.ParseCertPEM(certPEM)
notAfter := time.Now().Add(smime.DefaultValidity)
if cert != nil {
notAfter = cert.NotAfter
}
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListSMIMEContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
file, _, err := r.FormFile("cert_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "cert_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
if _, err := smime.ParseCertPEM(data); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
return
}
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// ---- PGP handlers ----
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListPGPIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
Label string `json:"label"`
Passphrase string `json:"passphrase"`
Confirm string `json:"passphrase_confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
if len(req.Passphrase) < 8 {
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
return
}
if req.Passphrase != req.Confirm {
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
return
}
account, _ := h.db.GetAccount(req.AccountID)
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
fingerprint := ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
}
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
passphrase := r.FormValue("passphrase")
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
email, fingerprint := "", ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
for name := range entity.Identities {
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
email = id.UserId.Email
break
}
}
}
account, _ := h.db.GetAccount(accountID)
if email == "" && account != nil {
email = account.EmailAddress
}
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
// logout (AuthHandler.Logout).
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IdentityID int64 `json:"identity_id"`
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
h.writeError(w, http.StatusBadRequest, "identity_id required")
return
}
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
if err != nil || identity == nil {
h.writeError(w, http.StatusNotFound, "identity not found")
return
}
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
return
}
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
return
}
if h.pgpCache != nil {
if cookie, err := r.Cookie("gomail_session"); err == nil {
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
}
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListPGPContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
entity, err := pgp.ParsePublicKey(data)
if err != nil {
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
return
}
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeletePGPContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+309
View File
@@ -0,0 +1,309 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// ======== Contacts ========
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var contacts interface{}
var err error
if q != "" {
contacts, err = h.db.SearchContacts(userID, q)
} else {
contacts, err = h.db.ListContacts(userID)
}
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
if contacts == nil {
contacts = []*models.Contact{}
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
c, err := h.db.GetContact(id, userID)
if err != nil || c == nil {
h.writeError(w, http.StatusNotFound, "contact not found")
return
}
h.writeJSON(w, c)
}
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if req.AvatarColor == "" {
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
req.AvatarColor = colors[int(userID)%len(colors)]
}
if err := h.db.CreateContact(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
if err := h.db.UpdateContact(&req, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteContact(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== Calendar Events ========
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
}
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*models.CalendarEvent{}
}
h.writeJSON(w, events)
}
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
ev, err := h.db.GetCalendarEvent(id, userID)
if err != nil || ev == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
h.writeJSON(w, ev)
}
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
existing, err := h.db.GetCalendarEvent(id, userID)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
req.UserID = userID
req.UID = existing.UID // preserve original UID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Tokens ========
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
tokens, err := h.db.ListCalDAVTokens(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
return
}
if tokens == nil {
tokens = []*models.CalDAVToken{}
}
h.writeJSON(w, tokens)
}
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Label string `json:"label"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Label == "" {
req.Label = "CalDAV token"
}
t, err := h.db.CreateCalDAVToken(userID, req.Label)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create token")
return
}
h.writeJSON(w, t)
}
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Server ========
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
// Compatible with any CalDAV client that supports basic calendar subscription.
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
userID, err := h.db.GetUserByCalDAVToken(token)
if err != nil || userID == 0 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Fetch events for next 12 months + past 3 months
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
for _, ev := range events {
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
if ev.Description != "" {
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
}
if ev.Location != "" {
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
}
if ev.AllDay {
// All-day events use DATE format
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
} else {
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
}
if ev.OrganizerEmail != "" {
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
}
if ev.Status != "" {
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
}
if ev.RecurrenceRule != "" {
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
}
fmt.Fprintf(w, "END:VEVENT\r\n")
}
fmt.Fprintf(w, "END:VCALENDAR\r\n")
}
func escICAL(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
// Fold long lines at 75 chars
if len(s) > 70 {
var out strings.Builder
for i, ch := range s {
if i > 0 && i%70 == 0 {
out.WriteString("\r\n ")
}
out.WriteRune(ch)
}
return out.String()
}
return s
}
func toICALDate(s string) string {
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s)
}
if err != nil {
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
}
return t.UTC().Format("20060102T150405Z")
}
+7 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/pgp"
"github.com/ghostersk/gowebmail/internal/syncer"
)
@@ -21,10 +22,14 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
log.Fatalf("failed to load templates: %v", err)
}
// Shared unlocked-PGP-key cache: populated by APIHandler.PGPUnlock, cleared by
// AuthHandler.Logout. No TTL — memory-bounded by active sessions (see internal/pgp.Cache).
pgpCache := pgp.NewCache()
return &Handlers{
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc, pgpCache: pgpCache},
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
API: &APIHandler{db: database, cfg: cfg, syncer: sc, pgpCache: pgpCache},
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
}
}
+2
View File
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
"login.html",
"mfa.html",
"admin.html",
"message.html",
"compose.html",
}
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
if err != nil {
+153
View File
@@ -0,0 +1,153 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
var validRuleFields = map[string]bool{
"from": true, "to": true, "subject": true, "body": true, "has_attachment": true, "recipient_type": true,
}
var validRuleOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
var validRuleActions = map[string]bool{
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
}
// ownAccount verifies accountID belongs to the current user, writing a 404 and returning false if not.
func (h *APIHandler) ownAccount(w http.ResponseWriter, r *http.Request, accountID int64) bool {
userID := middleware.GetUserID(r)
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return false
}
return true
}
// ---- Rules ----
func (h *APIHandler) ListRules(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
rules, err := h.db.ListRules(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list rules")
return
}
h.writeJSON(w, rules)
}
func validateRule(r *models.Rule) string {
if strings.TrimSpace(r.Name) == "" {
return "name required"
}
if len(r.Conditions) == 0 {
return "at least one condition required"
}
for _, c := range r.Conditions {
if !validRuleFields[c.Field] {
return "invalid condition field: " + c.Field
}
if !validRuleOps[c.Op] {
return "invalid condition op: " + c.Op
}
if strings.TrimSpace(c.Value) == "" {
return "condition value required"
}
}
if r.MatchType != "any" {
r.MatchType = "all"
}
if !validRuleActions[r.Action] {
return "invalid action: " + r.Action
}
if r.Action == "move_to_folder" && strings.TrimSpace(r.ActionValue) == "" {
return "folder name required for move_to_folder"
}
if r.Action == "forward" && !strings.Contains(r.ActionValue, "@") {
return "valid forward address required"
}
if r.Action == "auto_reply" && strings.TrimSpace(r.ActionValue) == "" {
return "auto-reply subject required"
}
return ""
}
func (h *APIHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
var req models.Rule
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
if req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if msg := validateRule(&req); msg != "" {
h.writeError(w, http.StatusBadRequest, msg)
return
}
id, err := h.db.CreateRule(&req)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create rule")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
var req models.Rule
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
if req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
existing, err := h.db.GetRule(req.AccountID, id)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "rule not found")
return
}
if msg := validateRule(&req); msg != "" {
h.writeError(w, http.StatusBadRequest, msg)
return
}
req.ID = id
if err := h.db.UpdateRule(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update rule")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeleteRule(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete rule")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+106
View File
@@ -0,0 +1,106 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/ghostersk/gowebmail/internal/middleware"
)
func (h *APIHandler) ListSignatures(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
sigs, err := h.db.ListSignatures(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list signatures")
return
}
h.writeJSON(w, sigs)
}
func (h *APIHandler) CreateSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Name string `json:"name"`
ContentHTML string `json:"content_html"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
h.writeError(w, http.StatusBadRequest, "name required")
return
}
id, err := h.db.CreateSignature(userID, req.Name, req.ContentHTML)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create signature")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req struct {
Name string `json:"name"`
ContentHTML string `json:"content_html"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
h.writeError(w, http.StatusBadRequest, "name required")
return
}
existing, err := h.db.GetSignature(userID, id)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "signature not found")
return
}
if err := h.db.UpdateSignature(userID, id, req.Name, req.ContentHTML); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update signature")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteSignature(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete signature")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// SetSignatureDefaults sets which signature (if any) is default-for-new / default-for-reply
// on one account. A ProviderID of 0 in the request clears that default.
func (h *APIHandler) SetSignatureDefaults(w http.ResponseWriter, r *http.Request) {
accountID := pathInt64(r, "id")
if !h.ownAccount(w, r, accountID) {
return
}
var req struct {
DefaultNewID int64 `json:"default_new_id"`
DefaultReplyID int64 `json:"default_reply_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// A signature id of 0 is "clear this default" — otherwise verify the user actually owns it.
userID := middleware.GetUserID(r)
if req.DefaultNewID > 0 {
if s, err := h.db.GetSignature(userID, req.DefaultNewID); err != nil || s == nil {
h.writeError(w, http.StatusBadRequest, "invalid default_new_id")
return
}
}
if req.DefaultReplyID > 0 {
if s, err := h.db.GetSignature(userID, req.DefaultReplyID); err != nil || s == nil {
h.writeError(w, http.StatusBadRequest, "invalid default_reply_id")
return
}
}
if err := h.db.SetSignatureDefaults(accountID, req.DefaultNewID, req.DefaultReplyID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to set defaults")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+507
View File
@@ -0,0 +1,507 @@
// Package jmap is a minimal JMAP (RFC 8620 Core + RFC 8621 Mail) client for
// ProviderJMAP accounts — an alternative to IMAP/SMTP for mail servers that
// speak JMAP instead. It follows internal/graph's shape (a thin REST/JSON
// wrapper), since both are HTTP+JSON providers unlike IMAP's binary protocol.
//
// Authenticated via HTTP Basic (mailbox email + app password), matching the
// reference server this was built against — see tests/jmap-client.md.
// One HTTP call per JMAP method call: no request batching or back-references,
// since sync here isn't latency-sensitive enough to justify that complexity.
package jmap
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client wraps JMAP API calls for a single mailbox account.
type Client struct {
baseURL string
username string
password string
http *http.Client
accountID string // resolved lazily from /jmap/session
apiURL string
uploadURL string
}
// New creates a JMAP client. baseURL is the server's base URL, e.g.
// "https://mail.example.com:8443" (no trailing slash needed).
func New(baseURL, username, password string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
http: &http.Client{
Timeout: 30 * time.Second,
// Force HTTP/1.1: the reference server (tests/jmap-client.md)
// closes the connection with no response over HTTP/2 — verified
// live (curl negotiates h2 by default and gets a broken pipe;
// --http1.1 works). TLSNextProto disables Go's automatic h2 ALPN
// upgrade for HTTPS requests.
Transport: &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}},
},
}
}
func (c *Client) doReq(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
url := path
if !strings.HasPrefix(path, "http") {
url = c.baseURL + path
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("jmap %s %s returned %d: %s", method, path, resp.StatusCode, string(b))
}
return resp, nil
}
// Session is the RFC 8620 §2 session resource.
type Session struct {
PrimaryAccounts map[string]string `json:"primaryAccounts"`
Username string `json:"username"`
APIURL string `json:"apiUrl"`
UploadURL string `json:"uploadUrl"`
State string `json:"state"`
}
// Session fetches /jmap/session and resolves the mail account id + API/upload
// URLs. Also serves as a pure connectivity/auth test (used by TestConnection).
func (c *Client) Session(ctx context.Context) (*Session, error) {
resp, err := c.doReq(ctx, http.MethodGet, "/jmap/session", nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var s Session
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return nil, fmt.Errorf("decode jmap session: %w", err)
}
c.accountID = s.PrimaryAccounts["urn:ietf:params:jmap:mail"]
if c.accountID == "" {
return nil, fmt.Errorf("jmap session: no mail account found")
}
c.apiURL = s.APIURL
c.uploadURL = strings.ReplaceAll(s.UploadURL, "{accountId}", c.accountID)
return &s, nil
}
func (c *Client) ensureSession(ctx context.Context) error {
if c.accountID != "" {
return nil
}
_, err := c.Session(ctx)
return err
}
type apiRequest struct {
Using []string `json:"using"`
MethodCalls [][3]interface{} `json:"methodCalls"`
}
type apiResponse struct {
MethodResponses [][]json.RawMessage `json:"methodResponses"`
}
// call makes a single JMAP method call and decodes its result args into out
// (which may be nil if the caller doesn't need the response body).
func (c *Client) call(ctx context.Context, method string, args map[string]interface{}, out interface{}) error {
if err := c.ensureSession(ctx); err != nil {
return err
}
body := apiRequest{
Using: []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
MethodCalls: [][3]interface{}{{method, args, "c1"}},
}
b, err := json.Marshal(body)
if err != nil {
return err
}
resp, err := c.doReq(ctx, http.MethodPost, c.apiURL, bytes.NewReader(b), "application/json")
if err != nil {
return err
}
defer resp.Body.Close()
var ar apiResponse
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
return fmt.Errorf("decode jmap response: %w", err)
}
if len(ar.MethodResponses) == 0 || len(ar.MethodResponses[0]) < 2 {
return fmt.Errorf("jmap %s: empty or malformed response", method)
}
first := ar.MethodResponses[0]
var name string
json.Unmarshal(first[0], &name)
if name == "error" {
return fmt.Errorf("jmap %s error: %s", method, string(first[1]))
}
if out != nil {
return json.Unmarshal(first[1], out)
}
return nil
}
func (c *Client) withAccount(args map[string]interface{}) map[string]interface{} {
if args == nil {
args = map[string]interface{}{}
}
args["accountId"] = c.accountID
return args
}
// ---- Mailboxes ----
// Mailbox is a JMAP folder.
type Mailbox struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parentId"`
Role string `json:"role"` // "inbox","sent","drafts","trash","junk", or "" for custom folders
TotalEmails int `json:"totalEmails"`
UnreadEmails int `json:"unreadEmails"`
}
// InferFolderType maps a JMAP Mailbox role to gowebmail's folder type.
func InferFolderType(role string) string {
switch role {
case "inbox":
return "inbox"
case "sent":
return "sent"
case "drafts":
return "drafts"
case "trash":
return "trash"
case "junk":
return "spam"
default:
return "custom"
}
}
// ListMailboxes returns every mailbox (folder) for the account.
func (c *Client) ListMailboxes(ctx context.Context) ([]Mailbox, error) {
var out struct {
List []Mailbox `json:"list"`
}
if err := c.call(ctx, "Mailbox/get", c.withAccount(nil), &out); err != nil {
return nil, err
}
return out.List, nil
}
// FindMailboxByRole returns the id of the mailbox with the given role (e.g.
// "sent", "inbox"), or an error if none is found.
func (c *Client) FindMailboxByRole(ctx context.Context, role string) (string, error) {
boxes, err := c.ListMailboxes(ctx)
if err != nil {
return "", err
}
for _, b := range boxes {
if b.Role == role {
return b.ID, nil
}
}
return "", fmt.Errorf("no mailbox with role %q", role)
}
// ---- Emails ----
// EmailAddr is a JMAP EmailAddress object.
type EmailAddr struct {
Name string `json:"name"`
Email string `json:"email"`
}
// BodyPart is an entry in an Email's textBody/htmlBody list.
type BodyPart struct {
PartID string `json:"partId"`
Type string `json:"type"`
}
// BodyValue is the decoded content for one BodyPart, keyed by partId in Email.BodyValues.
type BodyValue struct {
Value string `json:"value"`
}
// Email is a JMAP message. Keywords/mailboxIds mirror IMAP flags/folder
// membership, except a message here lives in exactly one mailbox (see
// tests/jmap-client.md — "single-mailbox membership").
type Email struct {
ID string `json:"id"`
MailboxIDs map[string]bool `json:"mailboxIds"`
Keywords map[string]bool `json:"keywords"`
Size int `json:"size"`
ReceivedAt time.Time `json:"receivedAt"`
Subject string `json:"subject"`
From []EmailAddr `json:"from"`
To []EmailAddr `json:"to"`
Preview string `json:"preview"`
HasAttachment bool `json:"hasAttachment"`
TextBody []BodyPart `json:"textBody"`
HTMLBody []BodyPart `json:"htmlBody"`
BodyValues map[string]BodyValue `json:"bodyValues"`
}
func (e *Email) FromName() string {
if len(e.From) == 0 {
return ""
}
return e.From[0].Name
}
func (e *Email) FromEmail() string {
if len(e.From) == 0 {
return ""
}
return e.From[0].Email
}
func (e *Email) ToList() string {
parts := make([]string, 0, len(e.To))
for _, t := range e.To {
parts = append(parts, t.Email)
}
return strings.Join(parts, ", ")
}
func (e *Email) IsRead() bool { return e.Keywords["$seen"] }
func (e *Email) IsFlagged() bool { return e.Keywords["$flagged"] }
// TextValue returns the plain-text body, if fetched via GetEmailBody.
func (e *Email) TextValue() string {
for _, p := range e.TextBody {
if bv, ok := e.BodyValues[p.PartID]; ok {
return bv.Value
}
}
return ""
}
// HTMLValue returns the HTML body, if fetched via GetEmailBody.
func (e *Email) HTMLValue() string {
for _, p := range e.HTMLBody {
if bv, ok := e.BodyValues[p.PartID]; ok {
return bv.Value
}
}
return ""
}
// ListEmails returns cheap-field emails in mailboxID. Newest-first order is
// not guaranteed (the reference server's Email/query sort support is
// undocumented — see tests/jmap-client.md — so no sort is requested; callers
// that need a specific order should sort client-side).
func (c *Client) ListEmails(ctx context.Context, mailboxID string, limit int) ([]Email, error) {
if limit <= 0 {
limit = 100
}
var qout struct {
IDs []string `json:"ids"`
}
qargs := c.withAccount(map[string]interface{}{
"filter": map[string]string{"inMailbox": mailboxID},
"limit": limit,
})
if err := c.call(ctx, "Email/query", qargs, &qout); err != nil {
return nil, err
}
if len(qout.IDs) == 0 {
return nil, nil
}
return c.GetEmails(ctx, qout.IDs, false)
}
// GetEmails fetches full Email objects for ids. withBody also fetches
// text/html body content (an expensive decrypt+MIME-parse server-side).
func (c *Client) GetEmails(ctx context.Context, ids []string, withBody bool) ([]Email, error) {
var out struct {
List []Email `json:"list"`
}
args := c.withAccount(map[string]interface{}{"ids": ids})
if withBody {
args["fetchTextBodyValues"] = true
args["fetchHTMLBodyValues"] = true
}
if err := c.call(ctx, "Email/get", args, &out); err != nil {
return nil, err
}
return out.List, nil
}
// GetEmailBody fetches a single email with its full text/html body.
func (c *Client) GetEmailBody(ctx context.Context, id string) (*Email, error) {
list, err := c.GetEmails(ctx, []string{id}, true)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, fmt.Errorf("email %s not found", id)
}
return &list[0], nil
}
// SetKeyword sets or clears a single keyword (e.g. "$seen", "$flagged") on a message.
func (c *Client) SetKeyword(ctx context.Context, emailID, keyword string, on bool) error {
args := c.withAccount(map[string]interface{}{
"update": map[string]interface{}{
emailID: map[string]interface{}{"keywords/" + keyword: on},
},
})
var out struct {
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotUpdated[emailID]; bad {
return fmt.Errorf("jmap keyword update rejected: %s", e)
}
return nil
}
// MoveEmail reassigns a message to a different (single) mailbox.
func (c *Client) MoveEmail(ctx context.Context, emailID, destMailboxID string) error {
args := c.withAccount(map[string]interface{}{
"update": map[string]interface{}{
emailID: map[string]interface{}{"mailboxIds": map[string]bool{destMailboxID: true}},
},
})
var out struct {
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotUpdated[emailID]; bad {
return fmt.Errorf("jmap move rejected: %s", e)
}
return nil
}
// DeleteEmail hard-deletes a message. Unlike Mailbox/set destroy (soft, see
// tests/jmap-client.md), Email/set destroy is a real, unrecoverable delete.
func (c *Client) DeleteEmail(ctx context.Context, emailID string) error {
args := c.withAccount(map[string]interface{}{"destroy": []string{emailID}})
var out struct {
NotDestroyed map[string]json.RawMessage `json:"notDestroyed"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotDestroyed[emailID]; bad {
return fmt.Errorf("jmap delete rejected: %s", e)
}
return nil
}
// ---- Sending ----
// UploadBlob uploads raw message bytes and returns the blob id.
func (c *Client) UploadBlob(ctx context.Context, data []byte) (string, error) {
if err := c.ensureSession(ctx); err != nil {
return "", err
}
resp, err := c.doReq(ctx, http.MethodPost, c.uploadURL, bytes.NewReader(data), "message/rfc822")
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
BlobID string `json:"blobId"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode jmap upload response: %w", err)
}
return out.BlobID, nil
}
// ImportEmail imports an uploaded blob as a message into mailboxID, returning
// the new email id. There is no Email/set create (see tests/jmap-client.md) —
// this upload+import step is the only way to add a message.
func (c *Client) ImportEmail(ctx context.Context, blobID, mailboxID string) (string, error) {
args := c.withAccount(map[string]interface{}{
"emails": map[string]interface{}{
"c1": map[string]interface{}{
"blobId": blobID,
"mailboxIds": map[string]bool{mailboxID: true},
},
},
})
var out struct {
Created map[string]struct {
ID string `json:"id"`
} `json:"created"`
NotCreated map[string]json.RawMessage `json:"notCreated"`
}
if err := c.call(ctx, "Email/import", args, &out); err != nil {
return "", err
}
if created, ok := out.Created["c1"]; ok {
return created.ID, nil
}
return "", fmt.Errorf("jmap import failed: %s", out.NotCreated["c1"])
}
// Submit sends a previously-imported message via EmailSubmission/set.
func (c *Client) Submit(ctx context.Context, emailID string) error {
args := c.withAccount(map[string]interface{}{
"create": map[string]interface{}{
"s1": map[string]interface{}{"emailId": emailID},
},
})
var out struct {
NotCreated map[string]json.RawMessage `json:"notCreated"`
}
if err := c.call(ctx, "EmailSubmission/set", args, &out); err != nil {
return err
}
if e, bad := out.NotCreated["s1"]; bad {
return fmt.Errorf("jmap submission rejected: %s", e)
}
return nil
}
// Send uploads rawMessage, imports it into mailboxID (typically the Sent
// mailbox — the server doesn't auto-file after submission), and submits it
// for delivery.
func (c *Client) Send(ctx context.Context, mailboxID string, rawMessage []byte) error {
blobID, err := c.UploadBlob(ctx, rawMessage)
if err != nil {
return fmt.Errorf("jmap upload: %w", err)
}
emailID, err := c.ImportEmail(ctx, blobID, mailboxID)
if err != nil {
return fmt.Errorf("jmap import: %w", err)
}
if err := c.Submit(ctx, emailID); err != nil {
return fmt.Errorf("jmap submit: %w", err)
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
// Package logger provides a conditional debug logger controlled by config.Debug.
package logger
import "log"
var debugEnabled bool
// Init sets whether debug logging is active. Call once at startup.
func Init(debug bool) {
debugEnabled = debug
if debug {
log.Println("[logger] debug logging enabled")
}
}
// Debug logs a message only when debug mode is on.
func Debug(format string, args ...interface{}) {
if debugEnabled {
log.Printf(format, args...)
}
}
// IsEnabled returns true if debug logging is on.
func IsEnabled() bool { return debugEnabled }
+1 -1
View File
@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
}
// OTPAuthURL builds an otpauth:// URI for QR code generation.
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
func OTPAuthURL(issuer, accountName, secret string) string {
v := url.Values{}
v.Set("secret", secret)
+222 -4
View File
@@ -1,8 +1,10 @@
// Package middleware provides HTTP middleware for GoMail.
// Package middleware provides HTTP middleware for GoWebMail.
package middleware
import (
"context"
"fmt"
"html/template"
"log"
"net"
"net/http"
@@ -11,7 +13,9 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/notify"
)
type contextKey string
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
next.ServeHTTP(w, r)
})
}
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
if role != models.RoleAdmin {
if isAPIPath(r) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"forbidden"}`)
} else {
http.Error(w, "403 Forbidden", http.StatusForbidden)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"You don't have permission to access this page. Admin privileges are required.")
}
return
}
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
}
return r.RemoteAddr
}
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
// It must be called with the raw handler so it can intercept BEFORE auth.
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
if cfg.IsIPWhitelisted(ip) {
next.ServeHTTP(w, r)
return
}
// Resolve country for geo-block and attempt recording.
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
geoResult := geo.Lookup(ip)
// --- Geo block (apply to all requests) ---
if geoResult.CountryCode != "" {
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"Access from your country is not permitted.")
return
}
}
if !cfg.BruteEnabled || r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
// Check if already blocked
if database.IsIPBlocked(ip) {
renderErrorPage(w, r, http.StatusForbidden,
"IP Address Blocked",
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
return
}
// Wrap the response writer to detect a failed login (redirect to error vs success)
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(rw, r)
// Determine success: a redirect away from login = success
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
username := r.FormValue("username")
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
if !success && !rw.skipBrute {
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
if failures >= cfg.BruteMaxAttempts {
reason := "Too many failed logins"
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
// Send security notification to the targeted user (non-blocking goroutine)
go func(targetUsername string) {
user, _ := database.GetUserByUsername(targetUsername)
if user == nil {
user, _ = database.GetUserByEmail(targetUsername)
}
if user != nil && user.Email != "" {
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
Username: user.Username,
ToEmail: user.Email,
AttackerIP: ip,
Country: geoResult.Country,
CountryCode: geoResult.CountryCode,
Attempts: failures,
BlockedAt: time.Now().UTC(),
BanHours: cfg.BruteBanHours,
Hostname: cfg.Hostname,
})
}
}(username)
}
}
})
}
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
type loginResponseCapture struct {
http.ResponseWriter
statusCode int
location string
skipBrute bool
}
func (lrc *loginResponseCapture) WriteHeader(code int) {
lrc.statusCode = code
lrc.location = lrc.ResponseWriter.Header().Get("Location")
if lrc.Header().Get("X-Skip-Brute") == "1" {
lrc.skipBrute = true
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
}
lrc.ResponseWriter.WriteHeader(code)
}
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
renderErrorPage(w, r, status, title, message)
}
// renderErrorPage writes a themed HTML error page for browser requests,
// or a JSON error for API paths.
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
if isAPIPath(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, message)
return
}
// Back-button destination: always send to "/" which RequireAuth will
// transparently forward to /auth/login if the session is absent or invalid.
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
backHref := "/"
backLabel := "← Go Back"
data := struct {
Status int
Title string
Message string
BackHref string
BackLabel string
}{status, title, message, backHref, backLabel}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := errorPageTmpl.Execute(w, data); err != nil {
// Last-resort plain text fallback
fmt.Fprintf(w, "%d %s: %s", status, title, message)
}
}
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Status}} {{.Title}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css">
<style>
html, body { height: 100%; margin: 0; }
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg, #18191b);
font-family: 'DM Sans', sans-serif;
}
.error-card {
background: var(--surface, #232428);
border: 1px solid var(--border, #2e2f34);
border-radius: 16px;
padding: 48px 56px;
text-align: center;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
}
.error-code {
font-size: 64px;
font-weight: 700;
color: var(--accent, #6b8afd);
line-height: 1;
margin: 0 0 8px;
letter-spacing: -2px;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: var(--text, #e8e9ed);
margin: 0 0 12px;
}
.error-message {
font-size: 14px;
color: var(--muted, #8b8d97);
line-height: 1.6;
margin: 0 0 32px;
}
.error-back {
display: inline-block;
padding: 10px 24px;
background: var(--accent, #6b8afd);
color: #fff;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: opacity .15s;
}
.error-back:hover { opacity: .85; }
</style>
</head>
<body>
<div class="error-page">
<div class="error-card">
<div class="error-code">{{.Status}}</div>
<h1 class="error-title">{{.Title}}</h1>
<p class="error-message">{{.Message}}</p>
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
</div>
</div>
</body>
</html>`))
+221 -22
View File
@@ -4,7 +4,7 @@ import "time"
// ---- Users ----
// UserRole controls access level within GoMail.
// UserRole controls access level within GoWebMail.
type UserRole string
const (
@@ -12,7 +12,7 @@ const (
RoleUser UserRole = "user"
)
// User represents a GoMail application user.
// User represents a GoWebMail application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
@@ -83,9 +83,11 @@ type AuditPage struct {
type AccountProvider string
const (
ProviderGmail AccountProvider = "gmail"
ProviderOutlook AccountProvider = "outlook"
ProviderIMAPSMTP AccountProvider = "imap_smtp"
ProviderGmail AccountProvider = "gmail"
ProviderOutlook AccountProvider = "outlook"
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
ProviderIMAPSMTP AccountProvider = "imap_smtp"
ProviderJMAP AccountProvider = "jmap" // generic JMAP (RFC 8620/8621) server
)
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
@@ -99,11 +101,18 @@ type EmailAccount struct {
AccessToken string `json:"-"`
RefreshToken string `json:"-"`
TokenExpiry time.Time `json:"-"`
// IMAP/SMTP settings (optional, stored encrypted)
// IMAP/SMTP settings (optional, stored encrypted).
// For ProviderJMAP accounts, IMAPHost holds the JMAP server base URL
// (e.g. "https://mail.example.com:8443") and AccessToken holds the app
// password — IMAPPort/SMTPHost/SMTPPort are unused for that provider.
IMAPHost string `json:"imap_host,omitempty"`
IMAPPort int `json:"imap_port,omitempty"`
SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
// CalDAV/CardDAV sync — optional, works alongside any provider above.
// Blank = disabled. Uses EmailAddress + AccessToken for HTTP basic auth.
CalDAVURL string `json:"caldav_url,omitempty"`
CardDAVURL string `json:"carddav_url,omitempty"`
// Sync settings
SyncDays int `json:"sync_days"` // how many days back to fetch (0 = all)
SyncMode string `json:"sync_mode"` // "days" or "all"
@@ -113,9 +122,27 @@ type EmailAccount struct {
// Display
Color string `json:"color"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
LastSync time.Time `json:"last_sync"`
CreatedAt time.Time `json:"created_at"`
}
// Label is a user-defined organizational tag, local to gowebmail (not synced to the mail
// provider — labels don't have a reliable cross-provider equivalent: Gmail's are IMAP-
// extension-specific, Outlook's Categories need the Graph API, plain IMAP has none).
type Label struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Color string `json:"color"` // hex, e.g. "#5b8def"
}
// SpamBlockEntry pairs a blocked sender address with when it was added — Settings >
// Security > Spam Block.
type SpamBlockEntry struct {
Sender string `json:"sender"`
CreatedAt time.Time `json:"created_at"`
}
// Folder represents a mailbox folder or Gmail label.
type Folder struct {
ID int64 `json:"id"`
@@ -177,26 +204,51 @@ type Message struct {
IsStarred bool `json:"is_starred"`
IsDraft bool `json:"is_draft"`
HasAttachment bool `json:"has_attachment"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Labels []Label `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// MessageSummary is a lightweight version for list views.
type MessageSummary struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
HasAttachment bool `json:"has_attachment"`
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountName string `json:"account_name"` // account's own display_name (may be blank)
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
ToList string `json:"to_list"` // comma-separated; only shown in the Sent folder view
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
HasAttachment bool `json:"has_attachment"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
Size int64 `json:"size,omitempty"` // approximate; only populated by search results
Labels []Label `json:"labels,omitempty"`
}
// ScheduledSend is a fully-composed message held until SendAt, delivered by the background
// sweep via the same send path as an immediate send. No raw file attachments in v1 — only
// forwarded-message .eml attachments (ForwardFromIDs).
type ScheduledSend struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountID int64 `json:"account_id"`
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"`
SendAt time.Time `json:"send_at"`
CreatedAt time.Time `json:"created_at"`
}
// ---- Compose ----
@@ -211,8 +263,18 @@ type ComposeRequest struct {
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
// ForwardFromIDs: each message here is fetched as a raw .eml and attached to the outgoing
// message — independent of mode (new/reply/forward), so a user can attach one or more
// original emails to any compose session, not just a dedicated "forward as attachment" one.
ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
// DraftID identifies this compose session's previously-autosaved draft ("" if never
// saved) — an IMAP UID, Graph message id, or JMAP email id depending on the account's
// provider, opaque to the caller. A resave replaces that copy in place (delete-then-
// recreate for IMAP/JMAP, PATCH for Graph) instead of piling up duplicates.
DraftID string `json:"draft_id,omitempty"`
}
// ---- Search ----
@@ -239,3 +301,140 @@ type PagedMessages struct {
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
}
// ---- Contacts ----
type Contact struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountID *int64 `json:"account_id,omitempty"` // set when synced from an account's CardDAV server
UID string `json:"uid,omitempty"` // CardDAV UID, or "gwm-..." for locally-created contacts
DisplayName string `json:"display_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Company string `json:"company"`
Notes string `json:"notes"`
AvatarColor string `json:"avatar_color"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ---- Calendar ----
type CalendarEvent struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountID *int64 `json:"account_id,omitempty"`
UID string `json:"uid"`
Title string `json:"title"`
Description string `json:"description"`
Location string `json:"location"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
AllDay bool `json:"all_day"`
RecurrenceRule string `json:"recurrence_rule"`
Color string `json:"color"`
Status string `json:"status"`
OrganizerEmail string `json:"organizer_email"`
Attendees string `json:"attendees"`
AccountColor string `json:"account_color,omitempty"`
AccountEmail string `json:"account_email,omitempty"`
}
type CalDAVToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Label string `json:"label"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used,omitempty"`
}
// ---- Rules (filters) ----
// RuleCondition is one field/op/value test within a Rule.
type RuleCondition struct {
Field string `json:"field"` // from|to|subject|body|has_attachment|recipient_type
Op string `json:"op"` // contains|equals|starts_with
Value string `json:"value"`
}
// RuleActionOptions holds action-specific extra settings, stored as JSON.
type RuleActionOptions struct {
KeepCopy bool `json:"keep_copy,omitempty"` // forward action
Body string `json:"body,omitempty"` // auto_reply action
}
// Rule is a mail filter evaluated against newly-synced messages for one account.
type Rule struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
Name string `json:"name"`
Priority int `json:"priority"`
Conditions []RuleCondition `json:"conditions"`
MatchType string `json:"match_type"` // all|any
Action string `json:"action"` // move_to_folder|delete|mark_read|mark_as_spam|forward|auto_reply
ActionValue string `json:"action_value"`
ActionOptions RuleActionOptions `json:"action_options"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at,omitempty"`
}
// ---- Signatures ----
type Signature struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
ContentHTML string `json:"content_html"`
CreatedAt string `json:"created_at,omitempty"`
}
// SignatureDefaults maps an account to its default-for-new/default-for-reply signature.
type SignatureDefaults struct {
AccountID int64 `json:"account_id"`
DefaultNewID int64 `json:"default_new_id,omitempty"`
DefaultReplyID int64 `json:"default_reply_id,omitempty"`
}
// ---- S/MIME ----
type SMIMEIdentity struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"-"` // never serialized to API responses
NotAfter time.Time `json:"not_after"`
CreatedAt string `json:"created_at,omitempty"`
}
type SMIMEContact struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Email string `json:"email"`
CertPEM string `json:"cert_pem"`
CreatedAt string `json:"created_at,omitempty"`
}
// ---- PGP ----
type PGPIdentity struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
Label string `json:"label"`
Email string `json:"email"`
Fingerprint string `json:"fingerprint"`
PublicKeyArmor string `json:"public_key_armor"`
PrivateKeyArmor string `json:"-"` // never serialized to API responses
CreatedAt string `json:"created_at,omitempty"`
}
type PGPContact struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Email string `json:"email"`
Label string `json:"label"`
Fingerprint string `json:"fingerprint"`
PublicKeyArmor string `json:"public_key_armor"`
CreatedAt string `json:"created_at,omitempty"`
}
+201
View File
@@ -0,0 +1,201 @@
// Package notify sends security alert emails using a configurable SMTP relay.
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
package notify
import (
"bytes"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"text/template"
"time"
"github.com/ghostersk/gowebmail/config"
)
// BruteForceAlert holds the data for the brute-force notification email.
type BruteForceAlert struct {
Username string
ToEmail string
AttackerIP string
Country string
CountryCode string
Attempts int
BlockedAt time.Time
BanHours int // 0 = permanent
AppName string
Hostname string
}
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
To: {{.ToEmail}}
Subject: Security Alert: Failed login attempts on your account
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hello {{.Username}},
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
We detected multiple failed login attempts on your account and have
automatically blocked the source IP address.
Account targeted : {{.Username}}
Source IP : {{.AttackerIP}}
{{- if .Country}}
Country : {{.Country}} ({{.CountryCode}})
{{- end}}
Failed attempts : {{.Attempts}}
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
{{- if eq .BanHours 0}}
Block duration : Permanent (administrator action required to unblock)
{{- else}}
Block duration : {{.BanHours}} hours
{{- end}}
If this was you, you may have mistyped your password. The block will
{{- if eq .BanHours 0}} remain until removed by an administrator.
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
If you did not attempt to log in, your account credentials may be at
risk. We recommend changing your password as soon as possible.
This is an automated message. Please do not reply.
--
{{.AppName}} Security
{{.Hostname}}
`))
type templateData struct {
BruteForceAlert
From string
}
// SendBruteForceAlert sends a security notification email to the targeted user.
// It runs in a goroutine — errors are logged but not returned.
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
return
}
if alert.ToEmail == "" {
return
}
go func() {
if err := sendAlert(cfg, alert); err != nil {
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
} else {
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
}
}()
}
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
if alert.AppName == "" {
alert.AppName = "GoWebMail"
}
if alert.Hostname == "" {
alert.Hostname = cfg.Hostname
}
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
var buf bytes.Buffer
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
return fmt.Errorf("template execute: %w", err)
}
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
// Choose auth method
var auth smtp.Auth
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
}
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
if cfg.NotifySMTPPort == 465 {
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer c.Close()
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
if ok, _ := c.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := c.StartTLS(tlsCfg); err != nil {
// Log but continue — some relays advertise STARTTLS but don't enforce it
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
// sendTLS sends via direct TLS connection (port 465).
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("tls dial %s: %w", addr, err)
}
// Resolve host for the smtp.NewClient call
bareHost, _, _ := net.SplitHostPort(addr)
if bareHost == "" {
bareHost = host
}
c, err := smtp.NewClient(conn, bareHost)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
// Normalise line endings to CRLF
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
if _, err := w.Write([]byte(normalized)); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}
+51
View File
@@ -0,0 +1,51 @@
package pgp
import (
"sync"
"github.com/ProtonMail/go-crypto/openpgp"
)
// Cache holds unlocked (passphrase-decrypted) PGP identities in memory, scoped to the
// session that unlocked them — never written to disk. No TTL: memory-bounded by active
// sessions, cleared only on explicit logout (see internal/handlers/auth.go Logout).
type Cache struct {
mu sync.Mutex
byTok map[string]map[int64]*openpgp.Entity // sessionToken -> identityID -> unlocked entity
}
// NewCache creates an empty unlocked-key cache.
func NewCache() *Cache {
return &Cache{byTok: make(map[string]map[int64]*openpgp.Entity)}
}
// Get returns the unlocked entity for identityID under sessionToken, if present.
func (c *Cache) Get(sessionToken string, identityID int64) (*openpgp.Entity, bool) {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.byTok[sessionToken]
if !ok {
return nil, false
}
e, ok := m[identityID]
return e, ok
}
// Put stores an unlocked entity under sessionToken.
func (c *Cache) Put(sessionToken string, identityID int64, entity *openpgp.Entity) {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.byTok[sessionToken]
if !ok {
m = make(map[int64]*openpgp.Entity)
c.byTok[sessionToken] = m
}
m[identityID] = entity
}
// ClearSession discards every unlocked identity for a session (call on logout).
func (c *Cache) ClearSession(sessionToken string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byTok, sessionToken)
}
+85
View File
@@ -0,0 +1,85 @@
package pgp
import (
"bytes"
"io"
"mime"
"mime/multipart"
"net/mail"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
)
func TestEncryptMIMERoundTrip(t *testing.T) {
pubArmor, privArmor, err := GenerateKeyPair("frank@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
pubEntity, err := ParsePublicKey(pubArmor)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
raw := []byte(
"Message-ID: <1.frank.example.com@example.com>\r\n" +
"From: Frank <frank@example.com>\r\n" +
"To: grace@example.com\r\n" +
"Subject: Secret\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: quoted-printable\r\n" +
"\r\n" +
"Hello, Grace! This is secret.\r\n")
encryptedMsg, err := EncryptMIME(raw, []*openpgp.Entity{pubEntity})
if err != nil {
t.Fatalf("EncryptMIME: %v", err)
}
msg, err := mail.ReadMessage(bytes.NewReader(encryptedMsg))
if err != nil {
t.Fatalf("mail.ReadMessage: %v", err)
}
if got := msg.Header.Get("Subject"); got != "Secret" {
t.Errorf("Subject header = %q, want %q (top-level headers must survive encryption)", got, "Secret")
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType: %v", err)
}
if mediaType != "multipart/encrypted" {
t.Fatalf("Content-Type = %q, want multipart/encrypted", mediaType)
}
mr := multipart.NewReader(msg.Body, params["boundary"])
if _, err := mr.NextPart(); err != nil { // control part: application/pgp-encrypted, Version: 1
t.Fatalf("first part: %v", err)
}
part2, err := mr.NextPart()
if err != nil {
t.Fatalf("second part: %v", err)
}
armored, err := io.ReadAll(part2)
if err != nil {
t.Fatalf("read second part: %v", err)
}
privEntity, err := ParsePrivateKey(privArmor)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if err := UnlockPrivateKey(privEntity, "hunter2hunter2"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(armored, privEntity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
want := "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello, Grace! This is secret.\r\n"
if string(decrypted) != want {
t.Errorf("DecryptEntity() = %q, want %q", decrypted, want)
}
}
+285
View File
@@ -0,0 +1,285 @@
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
// "unsafe by design". This package is encryption-only: no PGP signature generation
// or verification (S/MIME, internal/smime, handles signing).
package pgp
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
func defaultConfig() *packet.Config {
return &packet.Config{
DefaultCipher: packet.CipherAES256, // library default is AES-128
RSABits: 2048,
}
}
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
config := defaultConfig()
entity, err := openpgp.NewEntity(email, "", email, config)
if err != nil {
return nil, nil, fmt.Errorf("generate entity: %w", err)
}
if err := lockEntity(entity, passphrase); err != nil {
return nil, nil, err
}
publicArmor, err = serializePublic(entity)
if err != nil {
return nil, nil, err
}
privateArmor, err = serializePrivate(entity, config)
if err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
func lockEntity(entity *openpgp.Entity, passphrase string) error {
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("lock primary key: %w", err)
}
for _, sub := range entity.Subkeys {
if sub.PrivateKey == nil {
continue
}
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("lock subkey: %w", err)
}
}
return nil
}
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.Serialize(w); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
// which requires the (now-encrypted) private key and fails once it's locked.
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
entity, err := ParsePrivateKey(armoredData)
if err != nil {
return nil, nil, err
}
// Verify the passphrase actually unlocks it before accepting the import.
if err := UnlockPrivateKey(entity, passphrase); err != nil {
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
}
publicArmor, err = serializePublic(entity)
if err != nil {
return nil, nil, err
}
privateArmor = armoredData
return publicArmor, privateArmor, nil
}
// ParsePublicKey reads a single armored public key.
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
return parseEntity(armoredData)
}
// ParsePrivateKey reads a single armored private key. The key remains locked
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
return parseEntity(armoredData)
}
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
if err != nil {
return nil, fmt.Errorf("parse key: %w", err)
}
if len(entities) == 0 {
return nil, fmt.Errorf("no key found in armored data")
}
return entities[0], nil
}
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("unlock primary key: %w", err)
}
}
for _, sub := range entity.Subkeys {
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("unlock subkey: %w", err)
}
}
}
return nil
}
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
func Fingerprint(entity *openpgp.Entity) string {
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
}
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
// recipients' public keys.
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
if err != nil {
return nil, err
}
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
if _, err := pt.Write(raw); err != nil {
return nil, err
}
if err := pt.Close(); err != nil {
return nil, err
}
if err := aw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// DecryptEntity opens an armored PGP message using an already-unlocked identity
// (see UnlockPrivateKey).
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
block, err := armor.Decode(bytes.NewReader(armored))
if err != nil {
return nil, fmt.Errorf("decode armor: %w", err)
}
keyring := openpgp.EntityList{unlockedIdentity}
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
if err != nil {
return nil, fmt.Errorf("read message: %w", err)
}
return io.ReadAll(md.UnverifiedBody)
}
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
// what was encrypted regardless of a trailing CRLF.
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
topLines, entity, err := splitMIMEEntity(raw)
if err != nil {
return nil, err
}
encrypted, err := EncryptEntity(entity, recipients)
if err != nil {
return nil, err
}
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
var out bytes.Buffer
for _, l := range topLines {
out.WriteString(l + "\r\n")
}
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
out.WriteString("--" + boundary + "\r\n")
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
out.WriteString("--" + boundary + "\r\n")
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
out.Write(encrypted)
out.WriteString("\r\n--" + boundary + "--\r\n")
return out.Bytes(), nil
}
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
// stay behind as a stray top-level header of the wrapper message.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
// Encoding/Content-Disposition headers plus blank line plus body.
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
return nil, nil, errors.New("no header/body separator found in message")
}
headerBlock := string(raw[:idx])
body := raw[idx+4:]
rest := strings.Split(headerBlock, "\r\n")
var entityLines []string
for _, name := range entityHeaderNames {
var val string
val, rest = extractHeader(rest, name)
if val != "" {
entityLines = append(entityLines, val)
}
}
if len(entityLines) == 0 {
return nil, nil, errors.New("no Content-Type header found in message")
}
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
return rest, entity, nil
}
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
// returning its full value and the remaining lines with it removed.
func extractHeader(lines []string, name string) (value string, rest []string) {
prefix := strings.ToLower(name) + ":"
for i, l := range lines {
if strings.HasPrefix(strings.ToLower(l), prefix) {
value = l
j := i + 1
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
value += "\r\n" + lines[j]
j++
}
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
return value, rest
}
}
return "", lines
}
+68
View File
@@ -0,0 +1,68 @@
package pgp
import (
"bytes"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
)
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
pubArmor, privArmor, err := GenerateKeyPair("carol@example.com", "correct-horse-battery-staple")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
pubEntity, err := ParsePublicKey(pubArmor)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
raw := []byte("the secret message body")
encrypted, err := EncryptEntity(raw, []*openpgp.Entity{pubEntity})
if err != nil {
t.Fatalf("EncryptEntity: %v", err)
}
privEntity, err := ParsePrivateKey(privArmor)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if !privEntity.PrivateKey.Encrypted {
t.Fatal("private key should be Encrypted (passphrase-protected) before unlocking")
}
// Wrong passphrase must fail.
if err := UnlockPrivateKey(privEntity, "wrong-passphrase"); err == nil {
t.Error("UnlockPrivateKey succeeded with wrong passphrase, want error")
}
if err := UnlockPrivateKey(privEntity, "correct-horse-battery-staple"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(encrypted, privEntity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
if !bytes.Equal(decrypted, raw) {
t.Errorf("DecryptEntity() = %q, want %q", decrypted, raw)
}
}
func TestCache(t *testing.T) {
c := NewCache()
if _, ok := c.Get("tok1", 1); ok {
t.Fatal("expected empty cache miss")
}
e := &openpgp.Entity{}
c.Put("tok1", 1, e)
got, ok := c.Get("tok1", 1)
if !ok || got != e {
t.Fatal("expected cache hit for tok1/1")
}
c.ClearSession("tok1")
if _, ok := c.Get("tok1", 1); ok {
t.Fatal("expected cache miss after ClearSession")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package rules implements mail-filter matching: given a message and an account's
// active rules (already ordered by priority), find the first rule that matches.
package rules
import "strings"
// Condition is one field/op/value test. Mirrors models.RuleCondition but this package
// stays free of the models/db dependency so Match is trivially unit-testable.
type Condition struct {
Field string
Op string
Value string
}
// MessageFields is the subset of a message's data rules can match against.
type MessageFields struct {
From string
To string
Subject string
Body string
HasAttachment bool
RecipientType string // "to" | "cc" | "bcc"
}
// Rule is one filter: conditions (AND'd or OR'd per MatchType) plus an action.
type Rule struct {
ID int64
Priority int
Conditions []Condition
MatchType string // "all" (AND, default) | "any" (OR)
Action string
ActionValue string
ActionOptions map[string]any
}
// Match returns the first rule (by priority, ascending) whose conditions match msg,
// or nil if none match. Callers must pass rules pre-filtered to is_active and pre-sorted
// by priority ascending (ListActiveRules already does this).
func Match(msg MessageFields, activeRules []Rule) *Rule {
for i := range activeRules {
if ruleMatches(&activeRules[i], msg) {
return &activeRules[i]
}
}
return nil
}
func ruleMatches(r *Rule, msg MessageFields) bool {
if len(r.Conditions) == 0 {
return false
}
if r.MatchType == "any" {
for _, c := range r.Conditions {
if conditionMatches(c, msg) {
return true
}
}
return false
}
// default "all" (AND)
for _, c := range r.Conditions {
if !conditionMatches(c, msg) {
return false
}
}
return true
}
func conditionMatches(c Condition, msg MessageFields) bool {
var target string
switch c.Field {
case "from":
target = msg.From
case "to":
target = msg.To
case "subject":
target = msg.Subject
case "body":
target = msg.Body
case "has_attachment":
if msg.HasAttachment {
target = "yes"
} else {
target = "no"
}
case "recipient_type":
target = msg.RecipientType
default:
return false
}
return matchOp(c.Op, c.Value, target)
}
func matchOp(op, value, target string) bool {
value = strings.ToLower(strings.TrimSpace(value))
target = strings.ToLower(target)
switch op {
case "contains":
return value != "" && strings.Contains(target, value)
case "equals":
return target == value
case "starts_with":
return value != "" && strings.HasPrefix(target, value)
default:
return false
}
}
+98
View File
@@ -0,0 +1,98 @@
package rules
import "testing"
func TestMatch(t *testing.T) {
msg := MessageFields{
From: "boss@work.com",
To: "me@example.com",
Subject: "Weekly Report Due",
Body: "please see attached",
HasAttachment: true,
RecipientType: "to",
}
cases := []struct {
name string
rules []Rule
want string // expected matched rule action value marker, "" for no match
}{
{
name: "single contains condition matches",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "from", Op: "contains", Value: "work.com"}}},
},
want: "hit",
},
{
name: "equals is case-insensitive and exact",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "to", Op: "equals", Value: "ME@EXAMPLE.COM"}}},
},
want: "hit",
},
{
name: "starts_with no match",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "subject", Op: "starts_with", Value: "URGENT"}}},
},
want: "",
},
{
name: "match_type all requires every condition",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit", Conditions: []Condition{
{Field: "from", Op: "contains", Value: "work.com"},
{Field: "subject", Op: "contains", Value: "NOPE"},
}},
},
want: "",
},
{
name: "match_type any needs only one condition",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "any", ActionValue: "hit", Conditions: []Condition{
{Field: "from", Op: "contains", Value: "NOPE"},
{Field: "subject", Op: "contains", Value: "Report"},
}},
},
want: "hit",
},
{
name: "has_attachment field",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "has_attachment", Op: "equals", Value: "yes"}}},
},
want: "hit",
},
{
name: "first matching rule in list order wins, later matching rules ignored",
rules: []Rule{
{ID: 1, Priority: 5, MatchType: "all", ActionValue: "nope",
Conditions: []Condition{{Field: "from", Op: "contains", Value: "does-not-appear"}}},
{ID: 2, Priority: 0, MatchType: "all", ActionValue: "first",
Conditions: []Condition{{Field: "to", Op: "contains", Value: "example"}}},
{ID: 3, Priority: 10, MatchType: "all", ActionValue: "second",
Conditions: []Condition{{Field: "subject", Op: "contains", Value: "Report"}}},
},
want: "first", // Match trusts caller ordering (ListActiveRules sorts by priority ASC before calling)
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Match(msg, tc.rules)
gotVal := ""
if got != nil {
gotVal = got.ActionValue
}
if gotVal != tc.want {
t.Errorf("Match() = %q, want %q", gotVal, tc.want)
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package smime
import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/mail"
"testing"
)
func TestSignMIMERoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("dave@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
// CTE deliberately "7bit", not "quoted-printable": Go's mime/multipart.Part.Read
// auto-decodes quoted-printable/base64 parts, which would make this test compare
// decoded bytes against the raw wire bytes that were actually signed — a test-harness
// footgun, not a production concern (a spec-compliant S/MIME verifier signs/checks the
// encoded wire octets, never the decoded form).
raw := []byte(
"Message-ID: <1.dave.example.com@example.com>\r\n" +
"From: Dave <dave@example.com>\r\n" +
"To: eve@example.com\r\n" +
"Subject: Hello\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: 7bit\r\n" +
"\r\n" +
"Hello, Eve!\r\n")
signed, err := SignMIME(certPEM, keyPEM, raw)
if err != nil {
t.Fatalf("SignMIME: %v", err)
}
// Parse it back like a real mail client would: read top-level headers, find the
// multipart/signed boundary, split into the two parts, and verify.
msg, err := mail.ReadMessage(bytes.NewReader(signed))
if err != nil {
t.Fatalf("mail.ReadMessage: %v", err)
}
if got := msg.Header.Get("Subject"); got != "Hello" {
t.Errorf("Subject header = %q, want %q (top-level headers must survive signing)", got, "Hello")
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType: %v", err)
}
if mediaType != "multipart/signed" {
t.Fatalf("Content-Type = %q, want multipart/signed", mediaType)
}
mr := multipart.NewReader(msg.Body, params["boundary"])
part1, err := mr.NextPart()
if err != nil {
t.Fatalf("first part: %v", err)
}
part1Headers := "Content-Type: " + part1.Header.Get("Content-Type") + "\r\n"
if cte := part1.Header.Get("Content-Transfer-Encoding"); cte != "" {
part1Headers += "Content-Transfer-Encoding: " + cte + "\r\n"
}
part1Body, err := io.ReadAll(part1)
if err != nil {
t.Fatalf("read first part: %v", err)
}
entity := append([]byte(part1Headers+"\r\n"), part1Body...)
part2, err := mr.NextPart()
if err != nil {
t.Fatalf("second part: %v", err)
}
sigB64, err := io.ReadAll(part2)
if err != nil {
t.Fatalf("read second part: %v", err)
}
sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigB64)))
if err != nil {
t.Fatalf("decode signature base64: %v", err)
}
signer, err := VerifySigned(entity, sig)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer.EmailAddresses[0] != "dave@example.com" {
t.Errorf("signer = %v, want dave@example.com", signer.EmailAddresses)
}
}
+289
View File
@@ -0,0 +1,289 @@
// Package smime provides S/MIME certificate generation, signing, and encryption
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
//
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
// signature matches the given certificate, not that the certificate is trusted by any
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
// that want a "known sender" UI hint should compare against the user's own S/MIME
// contact address book, not treat a successful Verify as proof of identity.
package smime
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math/big"
"strings"
"time"
"go.mozilla.org/pkcs7"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
func init() {
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
}
// DefaultValidity is the lifetime used for a freshly self-signed identity.
const DefaultValidity = 365 * 24 * time.Hour
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("generate serial: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: email},
EmailAddresses: []string{email},
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
key, cert, err := pkcs12.Decode(data, password)
if err != nil {
return nil, nil, fmt.Errorf("decode p12: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
}
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("invalid certificate PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("invalid key PEM")
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return key, nil
}
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
sd, err := pkcs7.NewSignedData(raw)
if err != nil {
return nil, fmt.Errorf("new signed data: %w", err)
}
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
return nil, fmt.Errorf("add signer: %w", err)
}
sd.Detach()
return sd.Finish()
}
// VerifySigned checks a detached signature against the original content and returns the
// signer's certificate. It does NOT validate the certificate against any trust store —
// see the package doc comment.
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
p7, err := pkcs7.Parse(signature)
if err != nil {
return nil, fmt.Errorf("parse signature: %w", err)
}
p7.Content = raw
if err := p7.Verify(); err != nil {
return nil, fmt.Errorf("verify: %w", err)
}
signer := p7.GetOnlySigner()
if signer == nil {
return nil, errors.New("no signer certificate found in signature")
}
return signer, nil
}
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
// smime-type=enveloped-data) for the given recipient certificates.
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
return pkcs7.Encrypt(raw, recipients)
}
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
p7, err := pkcs7.Parse(enveloped)
if err != nil {
return nil, fmt.Errorf("parse enveloped data: %w", err)
}
return p7.Decrypt(cert, key)
}
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
//
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
// above only handle the CMS blob itself.
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
// original message's Content-Type + body become the first part, and a detached CMS
// signature over that part becomes the second. All other top-level headers (From, To,
// Subject, Date, Message-ID, ...) are preserved unchanged.
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
topLines, entity, err := splitMIMEEntity(raw)
if err != nil {
return nil, err
}
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
// the delimiter, not the signed content — a compliant multipart parser hands back the
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
sig, err := Sign(certPEM, keyPEM, signedContent)
if err != nil {
return nil, err
}
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
var out bytes.Buffer
for _, l := range topLines {
out.WriteString(l + "\r\n")
}
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
out.WriteString("--" + boundary + "\r\n")
out.Write(signedContent)
out.WriteString("\r\n--" + boundary + "\r\n")
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
out.WriteString("Content-Transfer-Encoding: base64\r\n")
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
out.WriteString(base64Wrap(sig))
out.WriteString("\r\n--" + boundary + "--\r\n")
return out.Bytes(), nil
}
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
// not stay behind as a stray top-level header of the wrapper message.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
return nil, nil, errors.New("no header/body separator found in message")
}
headerBlock := string(raw[:idx])
body := raw[idx+4:]
rest := strings.Split(headerBlock, "\r\n")
var entityLines []string
for _, name := range entityHeaderNames {
var val string
val, rest = extractHeader(rest, name)
if val != "" {
entityLines = append(entityLines, val)
}
}
if len(entityLines) == 0 {
return nil, nil, errors.New("no Content-Type header found in message")
}
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
return rest, entity, nil
}
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
// returning its full value and the remaining lines with it removed.
func extractHeader(lines []string, name string) (value string, rest []string) {
prefix := strings.ToLower(name) + ":"
for i, l := range lines {
if strings.HasPrefix(strings.ToLower(l), prefix) {
value = l
j := i + 1
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
value += "\r\n" + lines[j]
j++
}
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
return value, rest
}
}
return "", lines
}
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
func base64Wrap(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var out strings.Builder
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
out.WriteString(encoded[i:end])
if end < len(encoded) {
out.WriteString("\r\n")
}
}
return out.String()
}
+60
View File
@@ -0,0 +1,60 @@
package smime
import (
"bytes"
"crypto/x509"
"testing"
)
func TestSignVerifyRoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("alice@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
raw := []byte("this is the raw MIME message body")
sig, err := Sign(certPEM, keyPEM, raw)
if err != nil {
t.Fatalf("Sign: %v", err)
}
signer, err := VerifySigned(raw, sig)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if len(signer.EmailAddresses) == 0 || signer.EmailAddresses[0] != "alice@example.com" {
t.Errorf("signer email = %v, want [alice@example.com]", signer.EmailAddresses)
}
// Tamper one byte of the content — verification must fail.
tampered := bytes.Clone(raw)
tampered[0] ^= 0xFF
if _, err := VerifySigned(tampered, sig); err == nil {
t.Error("VerifySigned succeeded against tampered content, want error")
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("bob@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
cert, err := ParseCertPEM(certPEM)
if err != nil {
t.Fatalf("ParseCertPEM: %v", err)
}
raw := []byte("secret message body")
enveloped, err := Encrypt(raw, []*x509.Certificate{cert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
decrypted, err := Decrypt(enveloped, certPEM, keyPEM)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !bytes.Equal(decrypted, raw) {
t.Errorf("Decrypt() = %q, want %q", decrypted, raw)
}
}
+272
View File
@@ -0,0 +1,272 @@
package syncer
import (
"context"
"fmt"
"log"
"strings"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/graph"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/rules"
)
// matchRule evaluates a message against an account's active rules (as loaded from the DB)
// and returns the matching models.Rule (with full action data), or nil if none match.
func matchRule(msg *models.Message, accountEmail string, activeRules []models.Rule) *models.Rule {
if len(activeRules) == 0 {
return nil
}
engineRules := make([]rules.Rule, 0, len(activeRules))
for _, r := range activeRules {
conds := make([]rules.Condition, 0, len(r.Conditions))
for _, c := range r.Conditions {
conds = append(conds, rules.Condition{Field: c.Field, Op: c.Op, Value: c.Value})
}
engineRules = append(engineRules, rules.Rule{
ID: r.ID, Priority: r.Priority, Conditions: conds, MatchType: r.MatchType,
Action: r.Action, ActionValue: r.ActionValue,
})
}
mf := rules.MessageFields{
From: msg.FromEmail, To: msg.ToList, Subject: msg.Subject, Body: msg.BodyText,
HasAttachment: msg.HasAttachment, RecipientType: recipientType(msg, accountEmail),
}
matched := rules.Match(mf, engineRules)
if matched == nil {
return nil
}
for i := range activeRules {
if activeRules[i].ID == matched.ID {
return &activeRules[i]
}
}
return nil
}
func recipientType(msg *models.Message, accountEmail string) string {
if msg.CCList != "" && containsAddress(msg.CCList, accountEmail) {
return "cc"
}
if msg.BCCList != "" && containsAddress(msg.BCCList, accountEmail) {
return "bcc"
}
return "to"
}
func containsAddress(list, addr string) bool {
// list is comma-separated; a substring check is enough since we only use this
// to pick a synthetic recipient_type label, not for anything security-relevant.
for _, part := range splitAndTrim(list) {
if part == addr {
return true
}
}
return false
}
func splitAndTrim(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == ',' {
out = append(out, trimLower(cur))
cur = ""
continue
}
cur += string(r)
}
if cur != "" {
out = append(out, trimLower(cur))
}
return out
}
func trimLower(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return strings.ToLower(s[start:end])
}
func parseUID(s string) uint32 {
var uid uint32
fmt.Sscanf(s, "%d", &uid)
return uid
}
// ---- Spam blocklist (Settings > Security > Spam Block) ----
// A user-managed list of blocked senders, separate from the Rules engine so it gets its own
// simple add/remove UI instead of the generic condition/action rule builder — but enforced
// the same way the Rules engine's mark_as_spam action already is: move to the account's Spam
// folder. Applied to every provider's newly-synced messages, mirroring where matchRule runs.
func (s *Scheduler) moveToSpamIMAP(account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
uid := parseUID(msg.RemoteUID)
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
}
func (s *Scheduler) moveToSpamGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
if err := gc.MoveMessage(context.Background(), msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[spam-block] graph move: %v", err)
}
}
// ---- IMAP path ----
func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) {
uid := parseUID(msg.RemoteUID)
switch rule.Action {
case "move_to_folder":
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
if err != nil || dest == nil {
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
return
}
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: dest.FullPath})
s.TriggerAccountSync(account.ID)
case "mark_as_spam":
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
return
}
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
case "delete":
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "delete", RemoteUID: uid, FolderPath: dbFolder.FullPath})
s.TriggerAccountSync(account.ID)
case "mark_read":
if err := c.SetFlagByUID(dbFolder.FullPath, uid, `\Seen`, true); err != nil {
log.Printf("[rules] mark_read: %v", err)
}
case "forward":
s.ruleForwardIMAP(account, msg, rule.ActionValue)
case "auto_reply":
s.ruleAutoReplyIMAP(account, msg, rule)
}
}
func (s *Scheduler) ruleForwardIMAP(account *models.EmailAccount, msg *models.Message, to string) {
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{to},
Subject: "Fwd: " + msg.Subject,
BodyHTML: msg.BodyHTML,
BodyText: msg.BodyText,
}
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
log.Printf("[rules] forward to %s: %v", to, err)
}
}
func (s *Scheduler) ruleAutoReplyIMAP(account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
recipient := msg.FromEmail
if recipient == "" {
return // never reply to a bounce/empty sender — avoids loops
}
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
return
}
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{recipient},
Subject: rule.ActionValue,
BodyText: rule.ActionOptions.Body,
BodyHTML: rule.ActionOptions.Body,
}
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
log.Printf("[rules] auto_reply to %s: %v", recipient, err)
return
}
s.db.LogAutoReply(account.ID, rule.ID, recipient)
}
// ---- Graph (personal Outlook.com) path ----
// msg.RemoteUID already holds the opaque Graph message ID (set at construction in graphDeltaSync).
func (s *Scheduler) applyRuleGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
ctx := context.Background()
switch rule.Action {
case "move_to_folder":
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
if err != nil || dest == nil {
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
return
}
if err := gc.MoveMessage(ctx, msg.RemoteUID, dest.FullPath); err != nil {
log.Printf("[rules] graph move: %v", err)
}
case "mark_as_spam":
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
return
}
if err := gc.MoveMessage(ctx, msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[rules] graph move: %v", err)
}
case "delete":
if err := gc.DeleteMessage(ctx, msg.RemoteUID); err != nil {
log.Printf("[rules] graph delete: %v", err)
}
case "mark_read":
if err := gc.MarkRead(ctx, msg.RemoteUID, true); err != nil {
log.Printf("[rules] graph mark_read: %v", err)
}
case "forward":
s.ruleForwardGraph(gc, account, msg, rule.ActionValue)
case "auto_reply":
s.ruleAutoReplyGraph(gc, account, msg, rule)
}
}
func (s *Scheduler) ruleForwardGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, to string) {
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{to},
Subject: "Fwd: " + msg.Subject,
BodyHTML: msg.BodyHTML,
BodyText: msg.BodyText,
}
if err := gc.SendMail(context.Background(), req); err != nil {
log.Printf("[rules] graph forward to %s: %v", to, err)
}
}
func (s *Scheduler) ruleAutoReplyGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
recipient := msg.FromEmail
if recipient == "" {
return
}
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
return
}
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{recipient},
Subject: rule.ActionValue,
BodyText: rule.ActionOptions.Body,
BodyHTML: rule.ActionOptions.Body,
}
if err := gc.SendMail(context.Background(), req); err != nil {
log.Printf("[rules] graph auto_reply to %s: %v", recipient, err)
return
}
s.db.LogAutoReply(account.ID, rule.ID, recipient)
}
+131
View File
@@ -0,0 +1,131 @@
package syncer
import (
"testing"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- matchRule ----
func TestMatchRule_NoActiveRules(t *testing.T) {
msg := &models.Message{Subject: "hello"}
if got := matchRule(msg, "me@example.com", nil); got != nil {
t.Errorf("matchRule with no rules = %+v, want nil", got)
}
}
func TestMatchRule_SubjectContains(t *testing.T) {
msg := &models.Message{FromEmail: "boss@work.com", Subject: "Re: Invoice #42", BodyText: "please pay"}
active := []models.Rule{
{ID: 1, Priority: 1, MatchType: "all", Action: "move_to_folder", ActionValue: "Finance",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}},
}
got := matchRule(msg, "me@example.com", active)
if got == nil {
t.Fatalf("matchRule = nil, want rule 1 to match")
}
if got.ID != 1 || got.Action != "move_to_folder" || got.ActionValue != "Finance" {
t.Errorf("matchRule = %+v", got)
}
}
func TestMatchRule_ReturnsFirstMatchByPriority(t *testing.T) {
msg := &models.Message{FromEmail: "newsletter@shop.com", Subject: "50% off everything"}
active := []models.Rule{
{ID: 2, Priority: 2, MatchType: "all", Action: "delete",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "off"}}},
{ID: 1, Priority: 1, MatchType: "all", Action: "mark_as_spam",
Conditions: []models.RuleCondition{{Field: "from", Op: "contains", Value: "shop.com"}}},
}
// Match() iterates in the slice's given order and returns the first hit — callers
// (ListActiveRules) are documented to pre-sort by priority ascending, so put rule 1
// (priority 1) first here to prove matchRule returns it, not rule 2.
active[0], active[1] = active[1], active[0]
got := matchRule(msg, "me@example.com", active)
if got == nil || got.ID != 1 {
t.Fatalf("matchRule = %+v, want rule with ID=1 (lower priority number, listed first)", got)
}
}
func TestMatchRule_NoConditionsMatch(t *testing.T) {
msg := &models.Message{FromEmail: "friend@example.com", Subject: "hi"}
active := []models.Rule{
{ID: 1, Priority: 1, MatchType: "all", Action: "delete",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}},
}
if got := matchRule(msg, "me@example.com", active); got != nil {
t.Errorf("matchRule = %+v, want nil (no condition matches)", got)
}
}
// ---- recipientType / containsAddress ----
func TestRecipientType(t *testing.T) {
cases := []struct {
name string
msg *models.Message
want string
}{
{"plain to", &models.Message{ToList: "me@example.com"}, "to"},
{"in cc", &models.Message{CCList: "me@example.com, other@example.com"}, "cc"},
{"in bcc", &models.Message{BCCList: "me@example.com"}, "bcc"},
{"cc checked before bcc", &models.Message{CCList: "me@example.com", BCCList: "me@example.com"}, "cc"},
{"not in cc/bcc falls back to to", &models.Message{CCList: "someoneelse@example.com"}, "to"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := recipientType(tc.msg, "me@example.com")
if got != tc.want {
t.Errorf("recipientType = %q, want %q", got, tc.want)
}
})
}
}
func TestContainsAddress(t *testing.T) {
cases := []struct {
list, addr string
want bool
}{
{"a@x.com, b@x.com", "b@x.com", true},
{"a@x.com,b@x.com", "b@x.com", true}, // no space after comma
{" a@x.com , b@x.com ", "b@x.com", true},
{"a@x.com, b@x.com", "c@x.com", false},
{"", "a@x.com", false},
{"User@Example.com", "user@example.com", true}, // case-insensitive match
}
for _, tc := range cases {
if got := containsAddress(tc.list, tc.addr); got != tc.want {
t.Errorf("containsAddress(%q, %q) = %v, want %v", tc.list, tc.addr, got, tc.want)
}
}
}
func TestSplitAndTrim(t *testing.T) {
got := splitAndTrim(" A@x.com , B@x.com,C@x.com ")
want := []string{"a@x.com", "b@x.com", "c@x.com"}
if len(got) != len(want) {
t.Fatalf("splitAndTrim = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("splitAndTrim[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestTrimLower(t *testing.T) {
if got := trimLower(" MiXeD Case\t"); got != "mixed case" {
t.Errorf("trimLower = %q", got)
}
}
func TestParseUID(t *testing.T) {
if got := parseUID("12345"); got != 12345 {
t.Errorf("parseUID(\"12345\") = %d, want 12345", got)
}
if got := parseUID("not-a-number"); got != 0 {
t.Errorf("parseUID(garbage) = %d, want 0", got)
}
}
+595 -9
View File
@@ -9,31 +9,53 @@ import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/caldav"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/graph"
"github.com/ghostersk/gowebmail/internal/jmap"
"github.com/ghostersk/gowebmail/internal/models"
)
// Scheduler coordinates all background sync activity.
type Scheduler struct {
db *db.DB
cfg *config.Config
stop chan struct{}
wg sync.WaitGroup
// push channels: accountID -> channel to signal "something changed on server"
pushMu sync.Mutex
pushCh map[int64]chan struct{}
// reconcileCh signals the main loop to immediately check for new/removed accounts.
reconcileCh chan struct{}
}
// New creates a new Scheduler.
func New(database *db.DB) *Scheduler {
func New(database *db.DB, cfg *config.Config) *Scheduler {
return &Scheduler{
db: database,
stop: make(chan struct{}),
pushCh: make(map[int64]chan struct{}),
db: database,
cfg: cfg,
stop: make(chan struct{}),
pushCh: make(map[int64]chan struct{}),
reconcileCh: make(chan struct{}, 1),
}
}
// TriggerReconcile asks the main loop to immediately check for new accounts.
// Safe to call from any goroutine; non-blocking.
func (s *Scheduler) TriggerReconcile() {
select {
case s.reconcileCh <- struct{}{}:
default:
}
}
@@ -123,6 +145,13 @@ func (s *Scheduler) mainLoop() {
stopWorker(id)
}
return
case <-s.reconcileCh:
// Immediately check for new/removed accounts (e.g. after OAuth connect)
activeIDs := make(map[int64]bool, len(workers))
for id := range workers {
activeIDs[id] = true
}
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
case <-ticker.C:
// Build active IDs map for reconciliation
activeIDs := make(map[int64]bool, len(workers))
@@ -178,6 +207,15 @@ func (s *Scheduler) reconcileWorkers(
func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
log.Printf("[sync] worker started for %s", account.EmailAddress)
// CalDAV/CardDAV sync is optional and independent of the mail provider above,
// so it runs for every account regardless of which branch below is taken.
// davWorker no-ops on each tick if neither URL is configured.
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.davWorker(account, stop)
}()
// Fresh account data function (interval can change at runtime)
getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID)
@@ -187,6 +225,18 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
return a
}
// Graph-based accounts (personal outlook.com) use a different sync path
if account.Provider == models.ProviderOutlookPersonal {
s.graphWorker(account, stop, push)
return
}
// JMAP accounts use a different sync path (REST/JSON, like Graph)
if account.Provider == models.ProviderJMAP {
s.jmapWorker(account, stop, push)
return
}
// Initial sync on startup
s.drainPendingOps(account)
s.deltaSync(getAccount())
@@ -258,6 +308,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
cancel()
if err != nil {
@@ -338,6 +389,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
@@ -349,7 +401,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
mailboxes, err := c.ListMailboxes()
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not connected") {
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
// Workaround: use a Microsoft 365 work/school account, or add this account as a
// standard IMAP account using an App Password from account.microsoft.com/security.
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
"server: outlook.office365.com, port: 993, with your email and the app password."
}
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, errMsg)
return
}
@@ -380,7 +445,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 {
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
}
}
@@ -389,6 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
return
@@ -405,7 +471,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
return
}
if n > 0 {
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
}
}
@@ -418,6 +484,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
storedValidity, lastSeenUID := s.db.GetFolderSyncState(dbFolder.ID)
newMessages := 0
// Fetched once per folder-sync, not per message — rules rarely change mid-sync.
activeRules, _ := s.db.ListActiveRules(account.ID)
// UIDVALIDITY changed = folder was recreated on server; wipe local and re-fetch all
if storedValidity != 0 && status.UIDValidity != storedValidity {
log.Printf("[sync] UIDVALIDITY changed for %s/%s — full re-sync", account.EmailAddress, dbFolder.FullPath)
@@ -446,6 +515,15 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
msg.FolderID = dbFolder.ID
if err := s.db.UpsertMessage(msg); err == nil {
newMessages++
// Save attachment metadata if any (enables download)
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
if dbFolder.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamIMAP(account, dbFolder, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleIMAP(c, account, dbFolder, msg, rule)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
@@ -471,11 +549,54 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
if purged > 0 {
log.Printf("[sync] purged %d server-deleted messages from %s/%s", purged, account.EmailAddress, dbFolder.FullPath)
}
// 4. Reconcile the other direction: any UID the server has that we don't (from any
// past cause of local data loss — a bug, a crash mid-write, manual intervention) is
// re-fetched here, so the local cache always self-heals back to matching the server
// instead of staying permanently drifted — the incremental fetch in step 1 alone can
// never recover these, since it only ever asks for UIDs newer than last_seen_uid.
if localUIDs, lerr := s.db.GetLocalUIDSet(dbFolder.ID); lerr == nil {
var missing []uint32
for _, uid := range serverUIDs {
if !localUIDs[fmt.Sprintf("%d", uid)] {
missing = append(missing, uid)
}
}
if len(missing) > 0 {
recovered, rerr := c.FetchByUIDs(dbFolder.FullPath, missing)
if rerr != nil {
log.Printf("[sync] recover missing %s/%s: %v", account.EmailAddress, dbFolder.FullPath, rerr)
} else {
n := 0
for _, msg := range recovered {
msg.FolderID = dbFolder.ID
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
}
if n > 0 {
log.Printf("[sync] recovered %d message(s) missing from local cache in %s/%s", n, account.EmailAddress, dbFolder.FullPath)
newMessages += n
}
}
}
}
}
// Save sync state
s.db.SetFolderSyncState(dbFolder.ID, status.UIDValidity, maxUID)
s.db.UpdateFolderCounts(dbFolder.ID)
// Use the server's real total/unread counts (STATUS), not just what's synced locally —
// with a limited sync_days window, the local messages table only holds a recent subset,
// which would otherwise undercount folders that have older mail sitting on the server.
if total, unread, cerr := c.GetFolderCounts(dbFolder.FullPath); cerr == nil {
s.db.UpdateFolderCountsDirect(dbFolder.ID, int(total), int(unread))
} else {
s.db.UpdateFolderCounts(dbFolder.ID)
}
return newMessages, nil
}
@@ -484,6 +605,11 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
// Graph/JMAP accounts don't use the IMAP ops queue — their mutations are
// applied synchronously in the API handlers instead (see api.go).
if account.Provider == models.ProviderOutlookPersonal || account.Provider == models.ProviderJMAP {
return
}
ops, err := s.db.DequeuePendingOps(account.ID, 50)
if err != nil || len(ops) == 0 {
return
@@ -492,6 +618,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
@@ -525,7 +652,10 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
if applyErr != nil {
log.Printf("[ops:%s] %s uid=%d folder=%s: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.IncrementPendingOpAttempts(op.ID)
if abandoned := s.db.IncrementPendingOpAttempts(op.ID); abandoned {
log.Printf("[ops:%s] giving up on %s uid=%d folder=%s after repeated failures: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.SetAccountError(account.ID, fmt.Sprintf("a %s operation failed repeatedly and was abandoned: %v", op.OpType, applyErr))
}
} else {
s.db.DeletePendingOp(op.ID)
}
@@ -536,6 +666,62 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
}
}
// ---- OAuth token refresh ----
// ensureFreshToken checks whether an OAuth account's access token is near
// expiry and, if so, exchanges the refresh token for a new one, persists it
// to the database, and returns a refreshed account pointer.
// For non-OAuth accounts (imap_smtp) it is a no-op.
func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.EmailAccount {
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
return account
}
// Force refresh if Outlook token is opaque (not a JWT — doesn't contain dots).
// Opaque tokens (EwAYBOl3... format) are v1.0 tokens that IMAP rejects.
// A valid IMAP token is a 3-part JWT: header.payload.signature
isOpaque := account.Provider == models.ProviderOutlook &&
strings.Count(account.AccessToken, ".") < 2
if !auth.IsTokenExpired(account.TokenExpiry) && !isOpaque {
return account
}
if isOpaque {
logger.Debug("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress)
}
if account.RefreshToken == "" {
logger.Debug("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress)
return account
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
ctx,
string(account.Provider),
account.RefreshToken,
s.cfg.BaseURL,
s.cfg.GoogleClientID, s.cfg.GoogleClientSecret,
s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID,
)
if err != nil {
logger.Debug("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error())
return account // return original; connect will fail and log the error
}
if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
logger.Debug("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
return account
}
// Re-fetch so the caller gets the updated access token from the DB.
refreshed, fetchErr := s.db.GetAccount(account.ID)
if fetchErr != nil || refreshed == nil {
return account
}
logger.Debug("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
return refreshed
}
// ---- Public API (called by HTTP handlers) ----
// SyncAccountNow performs an immediate delta sync of one account.
@@ -545,7 +731,15 @@ func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) {
return 0, fmt.Errorf("account %d not found", accountID)
}
s.drainPendingOps(account)
s.deltaSync(account)
switch account.Provider {
case models.ProviderOutlookPersonal:
s.graphDeltaSync(account)
case models.ProviderJMAP:
s.jmapDeltaSync(account)
default:
s.deltaSync(account)
}
s.davSync(account)
return 0, nil
}
@@ -560,8 +754,77 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return 0, fmt.Errorf("folder %d not found", folderID)
}
// Graph accounts use the Graph sync path, not IMAP
if account.Provider == models.ProviderOutlookPersonal {
account = s.ensureFreshToken(account)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
gc := graph.New(account)
// Force full resync of this folder by ignoring the since filter
msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100)
if err != nil {
return 0, fmt.Errorf("graph list messages: %w", err)
}
n := 0
for _, gm := range msgs {
msg := &models.Message{
AccountID: account.ID,
FolderID: folder.ID,
RemoteUID: gm.ID,
MessageID: gm.InternetMessageID,
Subject: gm.Subject,
FromName: gm.FromName(),
FromEmail: gm.FromEmail(),
ToList: gm.ToList(),
Date: gm.ReceivedDateTime,
IsRead: gm.IsRead,
IsStarred: gm.IsFlagged(),
HasAttachment: gm.HasAttachments,
}
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
}
}
// Update folder counts
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
return n, nil
}
// JMAP accounts use the JMAP sync path, not IMAP
if account.Provider == models.ProviderJMAP {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
msgs, err := jc.ListEmails(ctx, folder.FullPath, 100)
if err != nil {
return 0, fmt.Errorf("jmap list emails: %w", err)
}
n := 0
for _, jm := range msgs {
msg := &models.Message{
AccountID: account.ID,
FolderID: folder.ID,
RemoteUID: jm.ID,
Subject: jm.Subject,
FromName: jm.FromName(),
FromEmail: jm.FromEmail(),
ToList: jm.ToList(),
Date: jm.ReceivedAt,
IsRead: jm.IsRead(),
IsStarred: jm.IsFlagged(),
HasAttachment: jm.HasAttachment,
}
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
}
}
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
return n, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
return 0, err
@@ -570,3 +833,326 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return s.syncFolder(c, account, folder)
}
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
// It polls Graph API instead of using IMAP.
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
logger.Debug("[graph] worker started for %s", account.EmailAddress)
getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID)
if a == nil {
return account
}
return a
}
// Initial sync
s.graphDeltaSync(getAccount())
syncTicker := time.NewTicker(30 * time.Second)
defer syncTicker.Stop()
for {
select {
case <-stop:
logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
return
case <-push:
acc := getAccount()
s.graphDeltaSync(acc)
case <-syncTicker.C:
acc := getAccount()
// Respect sync interval
if !acc.LastSync.IsZero() {
interval := time.Duration(acc.SyncInterval) * time.Minute
if interval <= 0 {
interval = 15 * time.Minute
}
if time.Since(acc.LastSync) < interval {
continue
}
}
s.graphDeltaSync(acc)
}
}
}
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
// as the IMAP sync path, so the rest of the app works unchanged.
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
account = s.ensureFreshToken(account)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
gc := graph.New(account)
// Fetch folders
gFolders, err := gc.ListFolders(ctx)
if err != nil {
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
return
}
s.db.ClearAccountError(account.ID)
totalNew := 0
for _, gf := range gFolders {
folderType := graph.InferFolderType(gf.DisplayName)
dbFolder := &models.Folder{
AccountID: account.ID,
Name: gf.DisplayName,
FullPath: gf.ID, // Graph uses opaque IDs as folder path
FolderType: folderType,
UnreadCount: gf.UnreadCount,
TotalCount: gf.TotalCount,
SyncEnabled: true,
}
if err := s.db.UpsertFolder(dbFolder); err != nil {
continue
}
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
continue
}
// Fetch latest messages — no since filter, rely on upsert idempotency.
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
// making date-based filters unreliable across folder types.
// Fetching top 100 newest per folder per sync is efficient enough.
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
if err != nil {
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
continue
}
// Fetched once per folder-sync, not per message.
activeRules, _ := s.db.ListActiveRules(account.ID)
for _, gm := range msgs {
// Body is NOT included in list response — fetched lazily on first open via GetMessage.
msg := &models.Message{
AccountID: account.ID,
FolderID: dbFolderSaved.ID,
RemoteUID: gm.ID,
MessageID: gm.InternetMessageID,
Subject: gm.Subject,
FromName: gm.FromName(),
FromEmail: gm.FromEmail(),
ToList: gm.ToList(),
Date: gm.ReceivedDateTime,
IsRead: gm.IsRead,
IsStarred: gm.IsFlagged(),
HasAttachment: gm.HasAttachments,
}
if err := s.db.UpsertMessage(msg); err == nil {
totalNew++
// NOTE: msg.BodyText is never populated here (body is fetched lazily on open,
// by design, for perf) — a rule's "body" condition never matches on this path.
if dbFolderSaved.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamGraph(gc, account, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleGraph(gc, account, msg, rule)
}
}
}
// Update folder counts from Graph (more accurate than counting locally)
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, gf.TotalCount, gf.UnreadCount)
}
s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 {
logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
}
}
// ---- JMAP sync ----
// jmapWorker is the accountWorker equivalent for ProviderJMAP accounts. It
// polls the JMAP server instead of using IMAP — mirrors graphWorker, since
// both are REST/JSON providers with no IMAP-style IDLE connection to hold open.
func (s *Scheduler) jmapWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
logger.Debug("[jmap] worker started for %s", account.EmailAddress)
getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID)
if a == nil {
return account
}
return a
}
s.jmapDeltaSync(getAccount())
syncTicker := time.NewTicker(30 * time.Second)
defer syncTicker.Stop()
for {
select {
case <-stop:
logger.Debug("[jmap] worker stopped for %s", account.EmailAddress)
return
case <-push:
s.jmapDeltaSync(getAccount())
case <-syncTicker.C:
acc := getAccount()
if !acc.LastSync.IsZero() {
interval := time.Duration(acc.SyncInterval) * time.Minute
if interval <= 0 {
interval = 15 * time.Minute
}
if time.Since(acc.LastSync) < interval {
continue
}
}
s.jmapDeltaSync(acc)
}
}
}
// jmapDeltaSync fetches mail via JMAP and stores it in the same DB tables as
// the IMAP/Graph sync paths, so the rest of the app works unchanged.
// account.IMAPHost holds the JMAP server base URL (see models.EmailAccount).
func (s *Scheduler) jmapDeltaSync(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
boxes, err := jc.ListMailboxes(ctx)
if err != nil {
log.Printf("[jmap:%s] list mailboxes: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, "JMAP error: "+err.Error())
return
}
s.db.ClearAccountError(account.ID)
totalNew := 0
for _, mb := range boxes {
folderType := jmap.InferFolderType(mb.Role)
dbFolder := &models.Folder{
AccountID: account.ID,
Name: mb.Name,
FullPath: mb.ID, // JMAP uses opaque IDs as folder path, like Graph
FolderType: folderType,
UnreadCount: mb.UnreadEmails,
TotalCount: mb.TotalEmails,
SyncEnabled: true,
}
if err := s.db.UpsertFolder(dbFolder); err != nil {
continue
}
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, mb.ID)
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
continue
}
// Fetch latest messages — no since filter, rely on upsert idempotency,
// same approach as graphDeltaSync (JMAP's Email/query sort isn't
// documented as supported — see tests/jmap-client.md).
msgs, err := jc.ListEmails(ctx, mb.ID, 100)
if err != nil {
log.Printf("[jmap:%s] list emails in %s: %v", account.EmailAddress, mb.Name, err)
continue
}
for _, jm := range msgs {
// Body is NOT included in list response — fetched lazily on first
// open, same as Graph's lazy-body pattern.
msg := &models.Message{
AccountID: account.ID,
FolderID: dbFolderSaved.ID,
RemoteUID: jm.ID,
Subject: jm.Subject,
FromName: jm.FromName(),
FromEmail: jm.FromEmail(),
ToList: jm.ToList(),
Date: jm.ReceivedAt,
IsRead: jm.IsRead(),
IsStarred: jm.IsFlagged(),
HasAttachment: jm.HasAttachment,
}
if err := s.db.UpsertMessage(msg); err == nil {
totalNew++
}
}
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, mb.TotalEmails, mb.UnreadEmails)
}
s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 {
logger.Debug("[jmap:%s] %d new messages", account.EmailAddress, totalNew)
}
}
// ---- CalDAV/CardDAV sync ----
// Optional per-account add-on, independent of the mail provider (IMAP/JMAP/Graph).
// Pull-only: mirrors the remote calendar/address book into the local DB.
func (s *Scheduler) davWorker(account *models.EmailAccount, stop chan struct{}) {
getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID)
if a == nil {
return account
}
return a
}
s.davSync(getAccount())
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
s.davSync(getAccount())
}
}
}
func (s *Scheduler) davSync(account *models.EmailAccount) {
if account.CalDAVURL == "" && account.CardDAVURL == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if account.CalDAVURL != "" {
events, err := caldav.SyncCalendar(ctx, account.CalDAVURL, account.EmailAddress, account.AccessToken, account.ID)
if err != nil {
logger.Debug("[caldav:%s] sync: %v", account.EmailAddress, err)
} else {
uids := make([]string, 0, len(events))
for _, e := range events {
e.UserID = account.UserID
if err := s.db.UpsertCalendarEvent(e); err == nil {
uids = append(uids, e.UID)
}
}
s.db.DeleteCalendarEventsNotIn(account.ID, uids)
}
}
if account.CardDAVURL != "" {
contacts, err := caldav.SyncContacts(ctx, account.CardDAVURL, account.EmailAddress, account.AccessToken, account.ID)
if err != nil {
logger.Debug("[carddav:%s] sync: %v", account.EmailAddress, err)
} else {
uids := make([]string, 0, len(contacts))
for _, c := range contacts {
c.UserID = account.UserID
if err := s.db.UpsertContact(c); err == nil {
uids = append(uids, c.UID)
}
}
s.db.DeleteContactsNotIn(account.ID, uids)
}
}
}
+320 -60
View File
@@ -30,6 +30,10 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
.toast.error{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#fca5a5}
.toast.warn{border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.08);color:#fde68a}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.toast-undo{display:flex;align-items:center;gap:14px;max-width:none}
.toast-undo-btn{background:none;border:none;color:var(--accent);font-weight:700;font-size:13px;
cursor:pointer;flex-shrink:0;padding:0}
.toast-undo-btn:hover{text-decoration:underline}
/* ---- Context menu ---- */
.ctx-menu{position:fixed;z-index:200;background:var(--surface2);border:1px solid var(--border2);
@@ -47,13 +51,16 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
z-index:100;display:flex;align-items:center;justify-content:center;
opacity:0;pointer-events:none;transition:opacity .2s}
.modal-overlay.open{opacity:1;pointer-events:all}
/* Modals that open from inside the Settings modal must stack above it, regardless of DOM
order, so Settings stays visible (and reachable) underneath. */
#add-account-modal,#edit-account-modal,#login-history-modal,#spam-block-modal{z-index:110}
.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2);
border:1px solid var(--border2);border-radius:14px;padding:26px;
border:1px solid var(--border2);border-radius:10px;padding:22px;
transform:scale(.95);transition:transform .2s}
.modal-overlay.open .modal{transform:scale(1)}
.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px}
.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px}
.modal-field{margin-bottom:12px}
.modal h2{font-family:'DM Serif Display',serif;font-size:19px;font-weight:400;margin-bottom:6px}
.modal > p{font-size:13px;color:var(--muted);margin-bottom:16px}
.modal-field{margin-bottom:10px}
.modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase;
letter-spacing:.8px;color:var(--muted);margin-bottom:5px}
.modal-field input,.modal-field select,.modal-field textarea{
@@ -140,11 +147,57 @@ body.auth-page{display:flex;align-items:center;justify-content:center;min-height
body.app-page{overflow:hidden}
.app{display:flex;height:100vh}
/* Mail view wrapper (list + detail) lets the reading-pane position be
flipped from the right (default) to the bottom without touching the
sidebar column. */
.mail-view{display:flex;flex:1;min-width:0;overflow:hidden}
@media (min-width:701px){
#app-root[data-reading-pane="bottom"] .mail-view{flex-direction:column}
#app-root[data-reading-pane="bottom"] .mail-view .message-list-panel{
width:100%;height:38%;min-height:160px;border-right:none;border-bottom:1px solid var(--border)}
#app-root[data-reading-pane="bottom"] .mail-view .message-detail{flex:1;min-height:0}
}
/* Drag handle between the message list and reading pane desktop only (mobile
switches full-screen between the two, there's nothing to split). Direction
flips with reading-pane position; size is persisted via uiPrefs (server-side,
not a cookie, so it follows the user across browsers/devices). */
.panel-resize-handle{display:none}
@media (min-width:701px){
.panel-resize-handle{display:block;flex-shrink:0;width:5px;cursor:col-resize;
background:transparent;position:relative;z-index:5}
.panel-resize-handle::after{content:'';position:absolute;top:0;bottom:0;left:1px;right:1px;
background:var(--border2);transition:background .15s}
.panel-resize-handle:hover::after,.panel-resize-handle.dragging::after{background:var(--accent)}
#app-root[data-reading-pane="bottom"] .panel-resize-handle{width:100%;height:5px;cursor:row-resize}
#app-root[data-reading-pane="bottom"] .panel-resize-handle::after{top:1px;bottom:1px;left:0;right:0}
}
/* Sidebar collapse / auto-hide (desktop only mobile keeps its own drawer below).
#sidebar-expand-btn lives inline in .panel-header (before the folder name) so it
never overlaps content it's only shown while the sidebar itself is hidden. */
#sidebar-expand-btn{display:none}
@media (min-width:701px){
#app-root[data-sidebar="collapsed"] .sidebar,
#app-root[data-sidebar="auto"] .sidebar{width:0;min-width:0;border-right:none;padding:0}
#app-root[data-sidebar="auto"] .sidebar{
position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);
transform:translateX(-100%);transition:transform .15s ease;
z-index:60;box-shadow:4px 0 24px rgba(0,0,0,.4);border-right:1px solid var(--border)}
#app-root[data-sidebar="collapsed"] #sidebar-expand-btn,
#app-root[data-sidebar="auto"] #sidebar-expand-btn{display:flex}
/* Auto-hide: peek the sidebar in as an overlay while hovering the expand button
or the sidebar itself (once revealed), pure CSS via :has() no JS timers. */
#app-root[data-sidebar="auto"]:has(#sidebar-expand-btn:hover) .sidebar,
#app-root[data-sidebar="auto"] .sidebar:hover{transform:translateX(0)}
}
.sidebar-collapse-btn{flex-shrink:0}
/* Sidebar */
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface);
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between}
.sidebar-header{padding:12px 14px 10px;border-bottom:1px solid var(--border);
display:flex;flex-direction:column}
.sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)}
.logo{display:flex;align-items:center;gap:8px}
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
@@ -156,17 +209,28 @@ body.app-page{overflow:hidden}
.compose-btn:hover{opacity:.85}
/* ── Account dot (still used in popup) */
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.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;
.nav-section{padding:3px 6px;flex:1;overflow-y:auto}
.nav-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
.nav-item:hover{background:var(--surface3);color:var(--text)}
.nav-item.active{background:var(--accent-dim);color:var(--accent)}
.nav-item svg{width:15px;height:15px;flex-shrink:0}
.nav-item svg{width:14px;height:14px;flex-shrink:0}
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
.folder-count-group{margin-left:auto;display:flex;align-items:center;gap:2px;flex-shrink:0}
.folder-count-group .unread-badge{margin-left:0}
.folder-total-count{font-size:9px;color:var(--muted);font-weight:400}
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
color:var(--muted);padding:8px 8px 2px;display:flex;align-items:center;gap:6px;
cursor:pointer;user-select:none;border-radius:5px;transition:background .15s}
.nav-folder-header:hover{background:var(--surface3)}
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
.acc-drag-handle:hover{opacity:1}
.acc-chevron{flex-shrink:0;color:var(--muted);display:flex;align-items:center}
.nav-account-group{border-radius:6px;transition:background .15s}
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
.nav-account-group.acc-dragging{opacity:.4}
.sidebar-footer{padding:8px 12px;border-top:1px solid var(--border);display:flex;
align-items:center;justify-content:space-between;flex-shrink:0}
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
.user-name{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -175,11 +239,11 @@ body.app-page{overflow:hidden}
/* Message list panel */
.message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border);
display:flex;flex-direction:column;background:var(--surface)}
.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border);
.panel-header{padding:10px 12px 8px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.panel-title{font-family:'DM Serif Display',serif;font-size:17px}
.panel-title{font-family:'DM Serif Display',serif;font-size:16px}
.panel-count{font-size:12px;color:var(--muted)}
.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.search-bar{padding:6px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.search-wrap{position:relative}
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);
width:13px;height:13px;fill:var(--muted);pointer-events:none}
@@ -189,28 +253,79 @@ body.app-page{overflow:hidden}
.search-input:focus{border-color:var(--accent)}
.search-input::placeholder{color:var(--muted)}
.message-list{flex:1;overflow-y:auto}
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
.message-item{padding:6px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
.message-item:hover{background:var(--surface2)}
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
/* Unread: lighter background + bold sender so it pops clearly */
.message-item.unread{background:rgba(255,255,255,.035)}
.message-item.unread:hover{background:rgba(255,255,255,.055)}
.message-item.unread .msg-from{color:var(--text);font-weight:600}
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
/* Read messages: everything dimmed down so unread has something to stand out against */
.msg-from{font-size:13px;font-weight:500;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
/* Unread: accent-tinted background + a solid dot + bold bright sender/subject + left bar,
so it reads as unread at a glance instead of only on close inspection. */
.message-item.unread{background:rgba(91,141,239,.07)}
.message-item.unread:hover{background:rgba(91,141,239,.12)}
.message-item.unread .msg-from{color:var(--text);font-weight:700}
.message-item.unread .msg-subject{font-weight:700;color:var(--text)}
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
.message-item.unread.active{background:var(--accent-dim)}
.message-item.unread.active::before{display:none}
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.msg-unread-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:transparent}
.message-item.unread .msg-unread-dot,.thread-sibling-row.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
/* Compact 2-line row (default): sender+date, then subjectpreview with trailing icons */
.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px}
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
.msg-acct{font-size:10px;color:var(--muted)}
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
.msg-line2{display:flex;align-items:center;gap:6px}
.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4}
.msg-subject{color:var(--text2)}
.msg-thread-count{color:var(--muted);font-size:11px;font-weight:600}
.msg-preview{color:var(--muted)}
.msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.msg-account-name{font-size:10px;color:var(--muted);flex-shrink:0;max-width:110px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;background:var(--surface3);padding:1px 6px;border-radius:4px}
.msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0}
.msg-size{font-size:10px;color:var(--muted)}
.msg-star{color:var(--muted);font-size:15px;cursor:pointer}
.msg-star.on{color:var(--star)}
/* ── Labels ──────────────────────────────────────────────────────────────── */
.msg-label-dots{display:flex;align-items:center;gap:3px;flex-shrink:0}
.msg-label-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.nav-label-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
/* Labels dropdown (panel-header, next to Filter) */
.label-dropdown-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:5px}
.label-dropdown-row:hover{background:var(--surface3)}
.label-dropdown-name{flex:1;cursor:pointer;font-size:13px;color:var(--text2)}
.label-dropdown-row:hover .label-dropdown-name{color:var(--text)}
.label-dropdown-actions{display:flex;gap:2px;opacity:0;transition:opacity .1s;flex-shrink:0}
.label-dropdown-row:hover .label-dropdown-actions{opacity:1}
.label-dropdown-actions button{background:none;border:none;color:var(--muted);cursor:pointer;
font-size:11px;padding:3px 5px;border-radius:3px}
.label-dropdown-actions button:hover{background:var(--surface2);color:var(--text)}
.label-dropdown-new{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;color:var(--accent)}
.label-dropdown-new:hover{background:var(--surface3)}
.detail-labels{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px}
.label-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 4px 2px 8px;border-radius:12px;
font-size:11px;font-weight:500;border:1px solid transparent}
.label-chip-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.label-chip button{background:none;border:none;color:inherit;opacity:.6;cursor:pointer;font-size:13px;
line-height:1;padding:0 3px}
.label-chip button:hover{opacity:1}
.label-add-btn{font-size:11px;color:var(--muted);background:none;border:1px dashed var(--border2);
border-radius:12px;padding:2px 10px;cursor:pointer;transition:border-color .15s,color .15s}
.label-add-btn:hover{border-color:var(--accent);color:var(--accent)}
.label-picker-item{display:flex;align-items:center;gap:8px}
.label-swatches{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
.label-swatch{width:24px;height:24px;border-radius:50%;cursor:pointer;border:2px solid transparent;
transition:transform .1s,border-color .1s}
.label-swatch:hover{transform:scale(1.1)}
.label-swatch.selected{border-color:var(--text)}
/* Comfortable density (opt-in via #app-root[data-density="comfortable"]): restores the
roomier 4-line row with account email and larger padding. */
#app-root[data-density="comfortable"] .message-item{padding:10px 12px}
#app-root[data-density="comfortable"] .msg-top{margin-bottom:2px}
#app-root[data-density="comfortable"] .msg-line2{flex-wrap:wrap}
#app-root[data-density="comfortable"] .msg-text{white-space:normal;font-size:12px;flex-basis:100%}
#app-root[data-density="comfortable"] .msg-icons{margin-left:auto;margin-top:2px}
.load-more{padding:10px;text-align:center}
.load-more-btn{background:none;border:1px solid var(--border2);color:var(--accent);
padding:6px 18px;border-radius:6px;cursor:pointer;font-size:12px;transition:background .15s}
@@ -227,19 +342,19 @@ body.app-page{overflow:hidden}
.no-message svg{width:48px;height:48px;fill:var(--border2)}
.no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)}
.no-message p{font-size:13px}
.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px}
.detail-header{padding:12px 20px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.detail-subject{font-family:'DM Serif Display',serif;font-size:18px;margin-bottom:8px}
.detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.detail-from{font-size:13px}
.detail-from strong{color:var(--text)}
.detail-from span{color:var(--muted);font-size:12px}
.detail-date{font-size:12px;color:var(--muted);flex-shrink:0}
.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;
.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
.action-btn{padding:4px 10px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s}
.action-btn:hover{background:var(--surface3);color:var(--text)}
.action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)}
.detail-body{flex:1;overflow-y:auto;padding:20px}
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
.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}
@@ -248,13 +363,13 @@ body.app-page{overflow:hidden}
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);
border-radius:8px;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);
padding:8px 10px 8px 14px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
cursor:grab;flex-shrink:0;background:var(--surface2);
}
@@ -264,12 +379,12 @@ body.app-page{overflow:hidden}
.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:6px 14px;gap:10px;flex-shrink:0}
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:5px 12px;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-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
.compose-footer{padding:6px 12px;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}
@@ -329,7 +444,16 @@ body.admin-page{overflow:auto;background:var(--bg)}
padding:22px 24px;margin-bottom:20px}
.admin-card h3{font-size:14px;font-weight:500;margin-bottom:4px}
.admin-card .card-desc{font-size:12px;color:var(--muted);margin-bottom:16px}
.settings-group{margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)}
.settings-nav{width:160px;flex-shrink:0;padding:12px 8px;border-right:1px solid var(--border);
display:flex;flex-direction:column;gap:1px}
.settings-nav button{display:block;width:100%;text-align:left;padding:7px 10px;border:none;
background:transparent;color:var(--text2);border-radius:5px;cursor:pointer;font-family:'DM Sans',sans-serif;
font-size:13px;transition:background .1s}
.settings-nav button:hover{background:var(--surface3);color:var(--text)}
.settings-nav button.active{background:var(--accent-dim);color:var(--accent)}
.settings-panel{display:none}
.settings-panel.active{display:block}
.settings-group{margin-bottom:18px;padding-bottom:18px;border-bottom:1px solid var(--border)}
.settings-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.settings-group-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;
color:var(--accent);margin-bottom:14px}
@@ -346,11 +470,11 @@ body.admin-page{overflow:auto;background:var(--bg)}
.setting-control input[type=password]{font-family:monospace;letter-spacing:.1em}
/* ---- Rich text compose editor ---- */
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
.fmt-btn:hover{background:var(--border2);color:var(--text)}
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
.compose-editor{flex:1;overflow-y:auto;padding:10px 12px;
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
@@ -369,10 +493,26 @@ body.admin-page{overflow:auto;background:var(--bg)}
/* ---- Attachment chips ---- */
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
text-decoration:none;color:inherit}
.attachment-chip:hover{background:var(--border2)}
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
padding:8px 14px;border-bottom:1px solid var(--border)}
.thread-btn-wrap{position:relative;display:inline-flex;align-items:center}
.thread-dropdown{position:absolute;top:calc(100% + 6px);left:0;z-index:250;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:260px;max-width:360px;
max-height:320px;overflow-y:auto;padding:8px}
.thread-siblings-title{font-size:10px;text-transform:uppercase;letter-spacing:.6px;
color:var(--muted);margin-bottom:6px}
.thread-sibling-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;
cursor:pointer;font-size:12px;color:var(--text2)}
.thread-sibling-row:hover{background:var(--surface3)}
.thread-sibling-row.active{background:var(--accent-dim);color:var(--accent)}
.thread-sibling-from{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.thread-sibling-date{color:var(--muted);font-size:11px;flex-shrink:0}
/* Drag-and-drop compose overlay */
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
/* ── Email tag input ─────────────────────────────────────────── */
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
@@ -388,26 +528,32 @@ body.admin-page{overflow:auto;background:var(--bg)}
.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:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
.compose-tag-field{position:relative}
.contact-suggest{position:absolute;top:100%;left:12px;right:12px;z-index:50;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,.25);overflow:hidden;margin-top:2px}
.contact-suggest-row{display:flex;align-items:center;gap:8px;padding:7px 10px;
cursor:pointer;font-size:12px}
.contact-suggest-row:hover,.contact-suggest-row.active{background:var(--surface3)}
.contact-suggest-name{color:var(--text);flex-shrink:0;max-width:45%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.contact-suggest-email{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ── 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;
/* ── Date/time presets (snooze / send later) ─────────────────────── */
.datetime-presets{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}
.datetime-preset-btn{padding:5px 10px;background:var(--surface3);border:1px solid var(--border2);
border-radius:14px;color:var(--text2);font-size:12px;cursor:pointer;font-family:inherit}
.datetime-preset-btn:hover{background:var(--surface2);color:var(--text)}
#inline-datetime-input{width:100%;padding:7px 9px;background:var(--surface3);border:1px solid var(--border2);
border-radius:6px;color:var(--text);font-family:inherit;font-size:13px}
/* ── Settings: connected-accounts list (Accounts tab) ──────────── */
.acct-row{display:flex;align-items:center;gap:8px;padding:9px 8px;border-radius:6px;
transition:background .1s;border-bottom:1px solid var(--border)}
.acct-row:last-child{border-bottom:none}
.acct-row:hover{background:var(--surface3)}
.accounts-add-btn{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px 6px;
margin-top:10px;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)}
@@ -495,3 +641,117 @@ body.admin-page{overflow:auto;background:var(--bg)}
from{opacity:0;transform:translateY(16px) scale(.96)}
to{opacity:1;transform:translateY(0) scale(1)}
}
/* ── Mobile top bar (hidden on desktop) ───────────────────────────────── */
.mob-topbar{display:none}
/* ── Responsive layout ────────────────────────────────────────────────── */
@media (max-width:700px){
/* Show mobile top bar */
.mob-topbar{
display:flex;align-items:center;gap:8px;
position:fixed;top:0;left:0;right:0;height:50px;z-index:200;
background:var(--surface);border-bottom:1px solid var(--border);
padding:0 12px;
}
.mob-nav-btn,.mob-back-btn{
background:none;border:none;cursor:pointer;color:var(--text);
padding:6px;border-radius:6px;display:flex;align-items:center;justify-content:center;
flex-shrink:0;
}
.mob-nav-btn:hover,.mob-back-btn:hover{background:var(--surface3)}
.mob-nav-btn svg,.mob-back-btn svg{width:20px;height:20px;fill:currentColor}
.mob-title{font-family:'DM Serif Display',serif;font-size:15px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;flex:1}
/* Push content below topbar */
body.app-page{overflow:hidden}
.app{flex-direction:column;height:100dvh;height:100vh;padding-top:50px}
/* Sidebar becomes a drawer */
.sidebar{
position:fixed;top:50px;left:0;bottom:0;z-index:150;
transform:translateX(-100%);transition:transform .25s ease;
width:280px;max-width:85vw;
}
.sidebar.mob-open{transform:translateX(0)}
.mob-sidebar-backdrop{
display:none;position:fixed;inset:0;top:50px;z-index:140;
background:rgba(0,0,0,.45);
}
.mob-sidebar-backdrop.mob-open{display:block}
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
.sidebar-header .compose-btn{display:none}
/* Desktop-only sidebar collapse control — mobile already has the drawer/hamburger */
.sidebar-collapse-btn{display:none}
/* Message list panel: full width, shown/hidden by data-mob-view */
.message-list-panel{width:100%;border-right:none;flex-shrink:0}
.message-detail{width:100%}
/* View switching via data-mob-view on #app-root */
#app-root[data-mob-view="list"] .message-list-panel{display:flex}
#app-root[data-mob-view="list"] .message-detail{display:none}
#app-root[data-mob-view="detail"] .message-list-panel{display:none}
#app-root[data-mob-view="detail"] .message-detail{display:flex}
/* Compose dialog: full screen on mobile */
.compose-dialog{
position:fixed!important;
top:50px!important;left:0!important;right:0!important;bottom:0!important;
width:100%!important;height:calc(100dvh - 50px)!important;
border-radius:0!important;resize:none!important;
}
/* Hide floating minimised bar on mobile, use back button instead */
.compose-minimised{display:none!important}
}
/* ── Contacts ──────────────────────────────────────────────────────────── */
.contact-card{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:8px;
cursor:pointer;transition:background .1s;border-bottom:1px solid var(--border)}
.contact-card:hover{background:var(--surface3)}
.contact-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:15px;font-weight:600;color:white;flex-shrink:0}
.contact-info{flex:1;min-width:0}
.contact-name{font-size:14px;font-weight:500;color:var(--text)}
.contact-meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ── Calendar ──────────────────────────────────────────────────────────── */
.cal-grid-month{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid var(--border);border-top:1px solid var(--border)}
.cal-day-header{text-align:center;font-size:11px;font-weight:600;text-transform:uppercase;
letter-spacing:.5px;color:var(--muted);padding:6px 0;background:var(--surface);
border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
.cal-day{min-height:90px;padding:4px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);
vertical-align:top;background:var(--surface);transition:background .1s;position:relative}
.cal-day:hover{background:var(--surface3)}
.cal-day.today{background:var(--accent-dim)}
.cal-day.other-month{opacity:.45}
.cal-day-num{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:2px;cursor:pointer;
width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
.cal-day-num:hover{background:var(--border2)}
.cal-day.today .cal-day-num{background:var(--accent);color:white}
.cal-event{font-size:11px;padding:2px 5px;border-radius:3px;margin-bottom:2px;
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:white;
transition:opacity .1s}
.cal-event:hover{opacity:.85}
.cal-more{font-size:10px;color:var(--muted);cursor:pointer;padding:1px 4px}
.cal-more:hover{color:var(--accent)}
/* Week view */
.cal-week-grid{display:grid;grid-template-columns:52px repeat(7,1fr);border-left:1px solid var(--border)}
.cal-week-header{text-align:center;padding:6px 2px;font-size:12px;border-right:1px solid var(--border);
border-bottom:1px solid var(--border);background:var(--surface)}
.cal-week-header.today-col{color:var(--accent);font-weight:600}
.cal-time-col{font-size:10px;color:var(--muted);text-align:right;padding-right:4px;
border-right:1px solid var(--border);border-bottom:1px solid var(--border);height:40px;
display:flex;align-items:flex-start;justify-content:flex-end;padding-top:2px}
.cal-week-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border);
height:40px;position:relative;transition:background .1s}
.cal-week-cell:hover{background:var(--surface3)}
/* CalDAV token row */
.caldav-token-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.caldav-token-url{font-size:11px;font-family:monospace;color:var(--muted);overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;flex:1;cursor:pointer}
.caldav-token-url:hover{color:var(--text)}
+191 -11
View File
@@ -1,14 +1,15 @@
// GoMail Admin SPA
// GoWebMail Admin SPA
const adminRoutes = {
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin/security': renderSecurity,
};
function navigate(path) {
history.pushState({}, '', path);
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
document.querySelectorAll('.admin-nav a').forEach(a => { const on = a.getAttribute('href') === path; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current'); });
const fn = adminRoutes[path];
if (fn) fn();
}
@@ -26,7 +27,7 @@ async function renderUsers() {
el.innerHTML = `
<div class="admin-page-header">
<h1>Users</h1>
<p>Manage GoMail accounts and permissions.</p>
<p>Manage GoWebMail accounts and permissions.</p>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
@@ -34,7 +35,7 @@ async function renderUsers() {
</div>
<div id="users-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="user-modal">
<div class="modal-overlay" id="user-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="user-modal-title">
<div class="modal">
<h2 id="user-modal-title">New User</h2>
<input type="hidden" id="user-id">
@@ -67,16 +68,19 @@ async function loadUsersTable() {
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
el.innerHTML = `<table class="data-table">
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
<tbody>${r.map(u => `
<tr>
<td style="font-weight:500">${esc(u.username)}</td>
<td style="color:var(--muted)">${esc(u.email)}</td>
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
<td style="display:flex;gap:6px;justify-content:flex-end">
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
</td>
</tr>`).join('')}
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
else toast((r && r.error) || 'Delete failed', 'error');
}
async function disableMFA(userId, username) {
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
else toast((r && r.error) || 'Failed to disable MFA', 'error');
}
function openResetPassword(userId, username) {
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
if (!pw) return;
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
if (r && r.ok) toast('Password reset for ' + username, 'success');
else toast((r && r.error) || 'Failed to reset password', 'error');
});
}
// ============================================================
// Settings
// ============================================================
@@ -182,6 +203,34 @@ const SETTINGS_META = [
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
]
},
{
group: 'Security Notifications',
fields: [
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
]
},
{
group: 'Brute Force Protection',
fields: [
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
]
},
{
group: 'Geo Blocking',
fields: [
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
]
},
];
async function renderSettings() {
@@ -297,7 +346,7 @@ function eventBadge(evt) {
// Boot: detect current page from URL
(function() {
const path = location.pathname;
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
document.querySelectorAll('.admin-nav a').forEach(a => { const on = a.getAttribute('href') === path; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current'); });
const fn = adminRoutes[path];
if (fn) fn();
else renderUsers();
@@ -308,4 +357,135 @@ function eventBadge(evt) {
navigate(a.getAttribute('href'));
});
});
})();
})();
// ============================================================
// Security — IP Blocks & Login Attempts
// ============================================================
async function renderSecurity() {
const el = document.getElementById('admin-content');
el.innerHTML = `
<div class="admin-page-header">
<h1>Security</h1>
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
</div>
<div class="admin-card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
</div>
<div id="blocks-table"><div class="spinner"></div></div>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
<button class="btn-secondary" onclick="loadLoginAttempts()"> Refresh</button>
</div>
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-block-modal-title">
<div class="modal" style="max-width:420px">
<h2 id="add-block-modal-title">Block IP Address</h2>
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
</div>
</div>
</div>`;
loadIPBlocks();
loadLoginAttempts();
}
async function loadIPBlocks() {
const el = document.getElementById('blocks-table');
if (!el) return;
const r = await api('GET', '/admin/ip-blocks');
const blocks = r?.blocks || [];
if (!blocks.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
</tr></thead>
<tbody>
${blocks.map(b => `<tr>
<td><code>${esc(b.ip)}</code></td>
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
<td>${esc(b.reason)}</td>
<td>${b.attempts||0}</td>
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
async function loadLoginAttempts() {
const el = document.getElementById('attempts-table');
if (!el) return;
const r = await api('GET', '/admin/login-attempts');
const attempts = r?.attempts || [];
if (!attempts.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
</tr></thead>
<tbody>
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
<td><code>${esc(a.ip)}</code></td>
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
<td>${a.total}</td>
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
<td style="font-size:11px">${a.last_seen||'—'}</td>
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
function openAddBlock() { openModal('add-block-modal'); }
async function submitAddBlock() {
const ip = document.getElementById('block-ip').value.trim();
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
const hours = parseInt(document.getElementById('block-hours').value) || 0;
if (!ip) { toast('IP address required', 'error'); return; }
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
else toast(r?.error || 'Failed', 'error');
}
async function unblockIP(ip) {
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
const data = await r.json();
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
else toast(data?.error || 'Failed', 'error');
}
function blockFromAttempt(ip) {
document.getElementById('block-ip').value = ip;
document.getElementById('block-reason').value = 'Manual block from login attempts';
openModal('add-block-modal');
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
+2287 -230
View File
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
// ── Contacts & Calendar ─────────────────────────────────────────────────────
let _currentView = 'mail';
// ======== VIEW SWITCHING ========
// Uses data-view attribute on #app-root to switch panels via CSS,
// avoiding direct style manipulation of elements that may not exist.
function _setView(view) {
_currentView = view;
// Update nav item active states
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels — mail-view wraps the message list + reading pane together
// so they hide/show as one unit rather than two separately-toggled panels.
const mailView = document.getElementById('mail-view');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mailView) mailView.style.display = view === 'mail' ? '' : 'none';
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
}
function showMail() {
_setView('mail');
document.getElementById('nav-unified')?.classList.add('active');
}
function showContacts() {
_setView('contacts');
document.getElementById('nav-contacts')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
loadContacts();
}
function showCalendar() {
_setView('calendar');
document.getElementById('nav-calendar')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
calRender();
}
// Patch selectFolder — called from app.js sidebar click handlers.
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
// Avoids infinite recursion by checking _currentView before doing anything.
(function() {
const _orig = window.selectFolder;
window.selectFolder = function(folderId, folderName) {
if (_currentView !== 'mail') {
showMail();
// Give the DOM a tick to re-show the mail panels before loading
setTimeout(function() {
_orig && _orig(folderId, folderName);
}, 10);
return;
}
_orig && _orig(folderId, folderName);
};
})();
// ======== CONTACTS ========
let _contacts = [];
let _editingContactId = null;
async function loadContacts() {
const data = await api('GET', '/contacts');
_contacts = data || [];
renderContacts(_contacts);
}
function renderContacts(list) {
const el = document.getElementById('contacts-list');
if (!el) return;
if (!list || list.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
<p>No contacts yet. Click "+ New Contact" to add one.</p>
</div>`;
return;
}
el.innerHTML = list.map(c => {
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
const color = c.avatar_color || '#6b7280';
const meta = [c.email, c.company].filter(Boolean).join(' · ');
return `<div class="contact-card" onclick="openContactForm(${c.id})">
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
<div class="contact-info">
<div class="contact-name">${esc(c.display_name || c.email)}</div>
<div class="contact-meta">${esc(meta)}</div>
</div>
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
</div>`;
}).join('');
}
function filterContacts(q) {
if (!q) { renderContacts(_contacts); return; }
const lower = q.toLowerCase();
renderContacts(_contacts.filter(c =>
(c.display_name||'').toLowerCase().includes(lower) ||
(c.email||'').toLowerCase().includes(lower) ||
(c.company||'').toLowerCase().includes(lower)
));
}
function composeToContact(email) {
showMail();
setTimeout(() => {
if (typeof openCompose === 'function') openCompose();
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
}, 50);
}
function openContactForm(id) {
_editingContactId = id || null;
const delBtn = document.getElementById('cf-delete-btn');
if (id) {
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
if (delBtn) delBtn.style.display = '';
const c = _contacts.find(x => x.id === id);
if (c) {
document.getElementById('cf-name').value = c.display_name || '';
document.getElementById('cf-email').value = c.email || '';
document.getElementById('cf-phone').value = c.phone || '';
document.getElementById('cf-company').value = c.company || '';
document.getElementById('cf-notes').value = c.notes || '';
}
} else {
document.getElementById('contact-modal-title').textContent = 'New Contact';
if (delBtn) delBtn.style.display = 'none';
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
}
openModal('contact-modal');
}
async function saveContact() {
const body = {
display_name: document.getElementById('cf-name').value.trim(),
email: document.getElementById('cf-email').value.trim(),
phone: document.getElementById('cf-phone').value.trim(),
company: document.getElementById('cf-company').value.trim(),
notes: document.getElementById('cf-notes').value.trim(),
};
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
if (_editingContactId) {
await api('PUT', `/contacts/${_editingContactId}`, body);
} else {
await api('POST', '/contacts', body);
}
closeModal('contact-modal');
await loadContacts();
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
}
async function deleteContact() {
if (!_editingContactId) return;
if (!confirm('Delete this contact?')) return;
await api('DELETE', `/contacts/${_editingContactId}`);
closeModal('contact-modal');
await loadContacts();
toast('Contact deleted', 'success');
}
// ======== CALENDAR ========
const CAL = {
view: 'month',
cursor: new Date(),
events: [],
};
function calSetView(v) {
CAL.view = v;
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
calRender();
}
function calNav(dir) {
if (CAL.view === 'month') {
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
} else {
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
}
calRender();
}
function calGoToday() { CAL.cursor = new Date(); calRender(); }
async function calRender() {
const gridEl = document.getElementById('cal-grid');
if (!gridEl) return;
let from, to;
if (CAL.view === 'month') {
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
from = new Date(from.getTime() - from.getDay() * 86400000);
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
} else {
const dow = CAL.cursor.getDay();
from = new Date(CAL.cursor.getTime() - dow * 86400000);
to = new Date(from.getTime() + 6 * 86400000);
}
const fmt = d => d.toISOString().split('T')[0];
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
CAL.events = data || [];
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const titleEl = document.getElementById('cal-title');
if (CAL.view === 'month') {
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
calRenderMonth(from, to);
} else {
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
calRenderWeek(from);
}
}
function calRenderMonth(from, to) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-grid-month">`;
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
const cur = new Date(from);
const curMonth = CAL.cursor.getMonth();
while (cur <= to) {
const dateStr = cur.toISOString().split('T')[0];
const isToday = cur.getTime() === today.getTime();
const isOther = cur.getMonth() !== curMonth;
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const shown = dayEvents.slice(0, 3);
const more = dayEvents.length - 3;
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
</div>`;
cur.setDate(cur.getDate() + 1);
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
function calRenderWeek(weekStart) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-week-grid">`;
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
}
for (let h=0;h<24;h++) {
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
html += `<div class="cal-time-col">${label}</div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const dateStr = d.toISOString().split('T')[0];
const slotEvs = CAL.events.filter(ev => {
if (!ev.start_time) return false;
return ev.start_time.startsWith(dateStr) &&
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
});
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-cell${isT?' today':''}"
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
</div>`;
}
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
// ======== EVENT FORM ========
let _editingEventId = null;
let _selectedEvColor = '#0078D4';
function selectEvColor(el) {
_selectedEvColor = el.dataset.color;
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
el.style.borderColor = 'white';
}
function openEventForm(id, defaultStart) {
_editingEventId = id || null;
const delBtn = document.getElementById('ev-delete-btn');
_selectedEvColor = '#0078D4';
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
if (id) {
document.getElementById('event-modal-title').textContent = 'Edit Event';
if (delBtn) delBtn.style.display = '';
const ev = CAL.events.find(e => e.id === id);
if (ev) {
document.getElementById('ev-title').value = ev.title||'';
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-allday').checked = !!ev.all_day;
document.getElementById('ev-location').value = ev.location||'';
document.getElementById('ev-desc').value = ev.description||'';
_selectedEvColor = ev.color||'#0078D4';
document.querySelectorAll('#ev-colors span').forEach(s => {
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
});
}
} else {
document.getElementById('event-modal-title').textContent = 'New Event';
if (delBtn) delBtn.style.display = 'none';
document.getElementById('ev-title').value = '';
const start = defaultStart || new Date().toISOString().substring(0,16);
document.getElementById('ev-start').value = start;
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
document.getElementById('ev-allday').checked = false;
document.getElementById('ev-location').value = '';
document.getElementById('ev-desc').value = '';
}
openModal('event-modal');
}
async function saveEvent() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { toast('Title is required','error'); return; }
const body = {
title,
start_time: document.getElementById('ev-start').value.replace('T',' '),
end_time: document.getElementById('ev-end').value.replace('T',' '),
all_day: document.getElementById('ev-allday').checked,
location: document.getElementById('ev-location').value.trim(),
description:document.getElementById('ev-desc').value.trim(),
color: _selectedEvColor,
status: 'confirmed',
};
if (_editingEventId) {
await api('PUT', `/calendar/events/${_editingEventId}`, body);
} else {
await api('POST', '/calendar/events', body);
}
closeModal('event-modal');
await calRender();
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
}
async function deleteEvent() {
if (!_editingEventId) return;
if (!confirm('Delete this event?')) return;
await api('DELETE', `/calendar/events/${_editingEventId}`);
closeModal('event-modal');
await calRender();
toast('Event deleted', 'success');
}
// ======== CALDAV ========
async function showCalDAVSettings() {
openModal('caldav-modal');
await loadCalDAVTokens();
}
async function loadCalDAVTokens() {
const tokens = await api('GET', '/caldav/tokens') || [];
const el = document.getElementById('caldav-tokens-list');
if (!el) return;
if (!tokens.length) {
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
return;
}
el.innerHTML = tokens.map(t => {
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
return `<div class="caldav-token-row">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
</div>
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" aria-label="Revoke this CalDAV token" style="color:var(--danger);flex-shrink:0">
<svg viewBox="0 0 24 24" width="16" height="16" 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>`;
}).join('');
}
async function createCalDAVToken() {
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
await api('POST', '/caldav/tokens', { label });
document.getElementById('caldav-label').value = '';
await loadCalDAVTokens();
toast('Token created', 'success');
}
async function revokeCalDAVToken(id) {
if (!confirm('Revoke this token?')) return;
await api('DELETE', `/caldav/tokens/${id}`);
await loadCalDAVTokens();
toast('Token revoked', 'success');
}
function copyCalDAVUrl(url) {
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
}
+62 -10
View File
@@ -1,10 +1,13 @@
// GoMail shared utilities - loaded on every page
// GoWebMail shared utilities - loaded on every page
// ---- API helper ----
async function api(method, path, body) {
async function api(method, path, body, timeoutMs) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
try {
const controller = new AbortController();
if (timeoutMs) setTimeout(() => controller.abort(), timeoutMs);
opts.signal = controller.signal;
const r = await fetch('/api' + path, opts);
if (r.status === 401) { location.href = '/auth/login'; return null; }
return r.json().catch(() => null);
@@ -21,6 +24,9 @@ function toast(msg, type) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
document.body.appendChild(container);
}
const el = document.createElement('div');
@@ -66,6 +72,39 @@ function positionMenu(menu, x, y) {
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + 'px';
}
// ---- Long-press → right-click (touch devices have no right-click) ----
// Every context menu in the app is wired via oncontextmenu="...". Touch devices never fire
// that event, so a ~550ms press-and-hold synthesizes a real 'contextmenu' event at the
// touch point instead — every existing handler picks it up unchanged.
(function () {
let timer = null, fired = false, start = null;
function cancel() { clearTimeout(timer); timer = null; }
document.addEventListener('touchstart', e => {
if (e.touches.length !== 1) { cancel(); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY, target: e.target };
fired = false;
cancel();
timer = setTimeout(() => {
fired = true;
if (navigator.vibrate) navigator.vibrate(15);
start.target.dispatchEvent(new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: start.x, clientY: start.y, view: window,
}));
}, 550);
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!start || !timer) return;
const t = e.touches[0];
if (Math.abs(t.clientX - start.x) > 10 || Math.abs(t.clientY - start.y) > 10) cancel();
}, { passive: true });
document.addEventListener('touchend', e => {
cancel();
if (fired) { e.preventDefault(); fired = false; } // swallow the tap-through click
}, { passive: false });
document.addEventListener('touchcancel', cancel, { passive: true });
})();
// ---- Debounce ----
function debounce(fn, ms) {
let t;
@@ -75,11 +114,15 @@ function debounce(fn, ms) {
// ---- Modal helpers ----
function openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add('open');
if (!el) return;
el.classList.add('open');
el.setAttribute('aria-hidden', 'false');
const focusable = el.querySelector('input,button,select,textarea,[tabindex]');
if (focusable) setTimeout(() => focusable.focus(), 50);
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('open');
if (el) { el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); }
}
// Close modals on overlay click
@@ -101,12 +144,21 @@ document.addEventListener('keydown', e => {
});
// ---- Rich text compose helpers ----
function insertLink() {
const url = prompt('Enter URL:');
if (!url) return;
const text = window.getSelection().toString() || url;
document.getElementById('compose-editor').focus();
document.execCommand('createLink', false, url);
// editorId defaults to the main compose editor; the signature editor passes 'sig-content'.
// Uses inlinePrompt (not window.prompt) so the selection has to be saved/restored across the
// async gap — prompt() blocked synchronously and never lost it.
function insertLink(editorId) {
editorId = editorId || 'compose-editor';
const editor = document.getElementById(editorId);
if (!editor) return;
const sel = window.getSelection();
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
inlinePrompt('Enter URL:', url => {
if (!url) return;
editor.focus();
if (range) { sel.removeAllRanges(); sel.addRange(range); }
document.execCommand('createLink', false, url);
});
}
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
+6 -2
View File
@@ -23,6 +23,10 @@
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
Audit Log
</a>
<a href="/admin/security" id="nav-security">
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
Security
</a>
</div>
</nav>
@@ -30,10 +34,10 @@
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="ctx-menu" id="ctx-menu"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js"></script>
<script src="/static/js/admin.js?v=26"></script>
{{end}}
+749 -102
View File
@@ -3,15 +3,40 @@
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div class="app">
<div class="app" id="app-root" data-mob-view="list">
<!-- Mobile top bar (hidden on desktop) -->
<div class="mob-topbar" id="mob-topbar">
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu" aria-label="Open navigation menu">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
</button>
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" aria-label="Back" style="display:none">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<span class="mob-title" id="mob-title">GoWebMail</span>
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab" aria-label="Compose in new tab"></button>
</div>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<button class="icon-btn sidebar-collapse-btn" onclick="toggleSidebarCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
</div>
</div>
</div>
<div style="display:flex;margin-top:8px">
<button class="compose-btn" onclick="openCompose()" style="flex:1;border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options" aria-label="More compose options" aria-haspopup="true">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
</div>
<button class="compose-btn" onclick="openCompose()">+ New</button>
</div>
<div class="nav-section">
@@ -24,6 +49,22 @@
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
Starred
</div>
<div class="nav-item" id="nav-snoozed" onclick="selectFolder('snoozed','Snoozed')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
Snoozed
</div>
<div class="nav-item" id="nav-scheduled" onclick="showScheduledSends()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13zm-8-9h5v5h-5z"/></svg>
Scheduled
</div>
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
Contacts
</div>
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
Calendar
</div>
<div id="folders-by-account"></div>
</div>
@@ -33,52 +74,91 @@
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</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">
<button class="icon-btn" onclick="openSettings()" title="Settings" aria-label="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>
<button class="icon-btn" onclick="doLogout()" title="Sign out">
<button class="icon-btn" onclick="doLogout()" title="Sign out" aria-label="Sign out">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div>
</div>
</aside>
<!-- Mobile sidebar backdrop -->
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
<!-- Message list -->
<!-- Mail view: message list + reading pane (position/density configurable via View menu) -->
<div class="mail-view" id="mail-view">
<div class="message-list-panel">
<div class="panel-header">
<span class="panel-title" id="panel-title">Unified Inbox</span>
<div style="display:flex;align-items:center;gap:6px;min-width:0">
<button class="icon-btn" id="sidebar-expand-btn" onclick="toggleSidebarCollapse()" title="Show sidebar" aria-label="Show sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
</button>
<span class="panel-title" id="panel-title">Unified Inbox</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span class="panel-count" id="panel-count"></span>
<div class="filter-dropdown" id="view-dropdown">
<button class="filter-dropdown-btn" id="view-dropdown-btn" title="View settings" onclick="toggleViewDropdown(event)" aria-haspopup="true" aria-expanded="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<span>View</span>
</button>
<div class="filter-dropdown-menu" id="view-dropdown-menu" style="display:none;min-width:190px">
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Reading pane</div>
<div class="filter-opt" id="vopt-pane-right" onclick="setViewPref('readingPane','right');event.stopPropagation()">✓ Right</div>
<div class="filter-opt" id="vopt-pane-bottom" onclick="setViewPref('readingPane','bottom');event.stopPropagation()">○ Bottom</div>
<div class="filter-sep-line"></div>
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Density</div>
<div class="filter-opt" id="vopt-density-compact" onclick="setViewPref('density','compact');event.stopPropagation()">✓ Compact</div>
<div class="filter-opt" id="vopt-density-comfortable" onclick="setViewPref('density','comfortable');event.stopPropagation()">○ Comfortable</div>
<div class="filter-sep-line"></div>
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Sidebar</div>
<div class="filter-opt" id="vopt-sidebar-expanded" onclick="setViewPref('sidebarMode','expanded');event.stopPropagation()">✓ Pinned (always visible)</div>
<div class="filter-opt" id="vopt-sidebar-collapsed" onclick="setViewPref('sidebarMode','collapsed');event.stopPropagation()">○ Minimized</div>
<div class="filter-opt" id="vopt-sidebar-auto" onclick="setViewPref('sidebarMode','auto');event.stopPropagation()">○ Auto-hide (peek on hover)</div>
</div>
</div>
<div class="filter-dropdown" id="labels-dropdown">
<button class="filter-dropdown-btn" id="labels-dropdown-btn" title="Labels" onclick="toggleLabelsDropdown(event)" aria-haspopup="true" aria-expanded="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/></svg>
<span>Labels</span>
</button>
<div class="filter-dropdown-menu" id="labels-dropdown-menu" style="display:none;min-width:210px"></div>
</div>
<div class="filter-dropdown" id="filter-dropdown">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" aria-haspopup="true" aria-expanded="false" onclick="var m=document.getElementById('filter-dropdown-menu');var exp=m.style.display==='block';m.style.display=exp?'none':'block';this.setAttribute('aria-expanded',String(!exp));event.stopPropagation()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
<span id="filter-label">Filter</span>
</button>
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
</div>
</div>
</div>
</div>
<div class="search-bar">
<div class="search-wrap">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)">
<div style="display:flex;gap:6px;align-items:center">
<div class="search-wrap" style="flex:1">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input class="search-input" type="text" id="search-input" aria-label="Search emails" placeholder="Search emails..." oninput="handleSearch(this.value)" onkeydown="if(event.key==='Enter')applySearchFilters()">
</div>
<button class="filter-dropdown-btn" id="search-filters-btn" title="Search filters" aria-label="Search filters" aria-haspopup="true" onclick="toggleSearchFilters(event)" style="flex-shrink:0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
</button>
</div>
</div>
<div class="message-list" id="message-list">
<div class="spinner" style="margin-top:60px"></div>
</div>
</div>
<div class="panel-resize-handle" id="panel-resize-handle" title="Drag to resize"></div>
<!-- Message detail -->
<main class="message-detail" id="message-detail">
@@ -88,53 +168,135 @@
<p>Choose a message from the list to read it</p>
</div>
</main>
</div>
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div class="panel-header" style="padding:14px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
<span style="font-family:'DM Serif Display',serif;font-size:17px;flex:1">Contacts</span>
<input id="contacts-search" type="search" placeholder="Search contacts…" oninput="filterContacts(this.value)"
style="padding:5px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--surface3);color:var(--text);font-size:13px;width:200px">
<button class="btn-secondary" onclick="openContactForm()" style="font-size:12px">+ New Contact</button>
</div>
<div id="contacts-list" style="flex:1;overflow-y:auto;padding:12px"></div>
</div>
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
<button class="icon-btn" onclick="calNav(-1)" title="Previous" aria-label="Previous period">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next" aria-label="Next period">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing" aria-label="CalDAV and sharing settings">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
</button>
</div>
</div>
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
</div>
</div>
<!-- ── 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>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="contact-modal-title">
<div class="modal" style="max-width:480px">
<h2 id="contact-modal-title">New Contact</h2>
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
<button class="modal-submit" onclick="saveContact()">Save</button>
</div>
</div>
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="event-modal-title">
<div class="modal" style="max-width:520px">
<h2 id="event-modal-title">New Event</h2>
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
<div class="modal-row">
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
<input id="ev-allday" type="checkbox" style="width:auto">
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
</div>
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-field"><label>Color</label>
<div style="display:flex;gap:6px" id="ev-colors">
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
<button class="modal-submit" onclick="saveEvent()">Save</button>
</div>
</div>
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="caldav-modal-title">
<div class="modal" style="max-width:560px">
<h2 id="caldav-modal-title">CalDAV / Calendar Sharing</h2>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
</p>
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
<div style="display:flex;gap:8px;align-items:center">
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
</div>
<div class="modal-actions" style="margin-top:16px">
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</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">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close">&#215;</button>
<button class="compose-close" onclick="minimizeCompose()" title="Minimise" aria-label="Minimise compose window">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close" aria-label="Close compose window">&#215;</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>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label>BCC</label><div id="compose-bcc-tags" class="tag-container"></div></div>
<div class="compose-field"><label>Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar">
<button class="fmt-btn" title="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" onclick="execFmt('underline')"><u>U</u></button>
<div class="compose-field"><label for="compose-from">From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
<div class="compose-field compose-tag-field"><label id="compose-to-label">To</label><div id="compose-to" class="tag-container" role="group" aria-labelledby="compose-to-label"></div></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label id="compose-cc-label">CC</label><div id="compose-cc-tags" class="tag-container" role="group" aria-labelledby="compose-cc-label"></div></div>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label id="compose-bcc-label">BCC</label><div id="compose-bcc-tags" class="tag-container" role="group" aria-labelledby="compose-bcc-label"></div></div>
<div class="compose-field"><label for="compose-subject">Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" onclick="execFmt('removeFormat')">T&#x20D7;</button>
<button class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
</div>
<div id="compose-editor" contenteditable="true" class="compose-editor" placeholder="Write your message..."></div>
<div id="compose-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" class="compose-editor" placeholder="Write your message..."></div>
<div id="compose-attach-list" class="compose-attach-list"></div>
<div class="compose-footer">
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
@@ -143,6 +305,7 @@
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; Draft</button>
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">&#128339; Send later</button>
</div>
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
</div>
@@ -163,7 +326,7 @@
</div>
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
<div class="inline-confirm" id="inline-confirm">
<div class="inline-confirm" id="inline-confirm" role="alertdialog" aria-modal="true" aria-describedby="inline-confirm-msg">
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></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>
@@ -171,19 +334,155 @@
</div>
</div>
<!-- ── Inline prompt (replaces browser prompt()) ─────────────────────────── -->
<div class="inline-confirm" id="inline-prompt" role="dialog" aria-modal="true" aria-describedby="inline-prompt-msg">
<p id="inline-prompt-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="modal-field">
<input type="text" id="inline-prompt-input" aria-labelledby="inline-prompt-msg"
onkeydown="if(event.key==='Enter'){document.getElementById('inline-prompt-ok').click();}else if(event.key==='Escape'){document.getElementById('inline-prompt-cancel').click();}">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-prompt-cancel">Cancel</button>
<button class="btn-primary" style="font-size:12px" id="inline-prompt-ok">Create</button>
</div>
</div>
<!-- ── Inline date/time prompt (snooze / send later) ──────────────────────── -->
<div class="inline-confirm" id="inline-datetime" role="dialog" aria-modal="true" aria-describedby="inline-datetime-msg">
<p id="inline-datetime-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="datetime-presets" id="inline-datetime-presets" role="group" aria-label="Quick presets"></div>
<div class="modal-field">
<input type="datetime-local" id="inline-datetime-input" aria-labelledby="inline-datetime-msg">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-datetime-cancel">Cancel</button>
<button class="btn-primary" style="font-size:12px" id="inline-datetime-ok">Set</button>
</div>
</div>
<!-- ── Draft close confirm (save / delete / keep editing) ─────────────────── -->
<div class="inline-confirm" id="draft-close-confirm" role="alertdialog" aria-modal="true" aria-describedby="draft-close-confirm-msg">
<p id="draft-close-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5">Save this message as a draft before closing?</p>
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="font-size:12px" id="draft-close-cancel">Keep editing</button>
<button class="btn-danger" style="font-size:12px" id="draft-close-delete">Delete draft</button>
<button class="btn-primary" style="font-size:12px" id="draft-close-save">Save draft</button>
</div>
</div>
<!-- ── Scheduled Sends Modal ───────────────────────────────────────────────── -->
<div class="modal-overlay" id="scheduled-sends-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="scheduled-sends-modal-title">
<div class="modal" style="max-width:520px">
<h2 id="scheduled-sends-modal-title">Scheduled sends</h2>
<div id="scheduled-sends-list"></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('scheduled-sends-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Login History modal ────────────────────────────────────────────────── -->
<div class="modal-overlay" id="login-history-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="login-history-modal-title">
<div class="modal" style="width:min(1000px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="login-history-modal-title">Login History</h2>
<p>Login attempts for your account only.</p>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:14px">
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-from">From</label>
<input type="date" id="lh-date-from" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-to">To</label>
<input type="date" id="lh-date-to" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-status">Status</label>
<select id="lh-status" onchange="loadLoginHistory(1)">
<option value="">All</option>
<option value="true">Success</option>
<option value="false">Failed</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:0;flex:1;min-width:160px">
<label for="lh-ip">IP contains</label>
<input type="text" id="lh-ip" placeholder="e.g. 192.168" oninput="debouncedLoadLoginHistory()">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-sort">Sort by date</label>
<select id="lh-sort" onchange="loadLoginHistory(1)">
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Time</th><th>Status</th><th>IP Address</th><th>Detail</th></tr></thead>
<tbody id="lh-table-body"></tbody>
</table>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px">
<span id="lh-page-info" style="font-size:12px;color:var(--muted)"></span>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="lh-prev-btn" onclick="loginHistoryPrevPage()">Previous</button>
<button class="btn-secondary" id="lh-next-btn" onclick="loginHistoryNextPage()">Next</button>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('login-history-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Spam Block modal ───────────────────────────────────────────────────── -->
<div class="modal-overlay" id="spam-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="spam-block-modal-title">
<div class="modal" style="width:min(700px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="spam-block-modal-title">Spam Block</h2>
<p>Mail from these senders is automatically moved to Spam when it arrives — no notification is shown for it. Enter either a full email address, or just a domain (e.g. "example.com") to block every address at that domain and its subdomains.</p>
<div style="display:flex;gap:8px;margin-bottom:14px">
<input type="text" id="sb-add-input" placeholder="Email address or domain (e.g. example.com)…" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<button class="btn-primary" onclick="addSpamBlockEntry()">Block</button>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Sender</th><th>Blocked since</th><th></th></tr></thead>
<tbody id="sb-table-body"></tbody>
</table>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('spam-block-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="add-account-modal">
<div class="modal-overlay" id="add-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-account-modal-title">
<div class="modal">
<h2>Connect an account</h2>
<h2 id="add-account-modal-title">Connect an account</h2>
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
<div class="provider-btns">
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Gmail
</button>
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
Outlook
<!-- Microsoft 365 icon -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
</svg>
Microsoft 365
</button>
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
<!-- Outlook icon (blue envelope) -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="3" fill="#0078D4"/>
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
</svg>
Outlook Personal
</button>
</div>
<div class="modal-divider"><span>or add IMAP account</span></div>
@@ -195,18 +494,25 @@
</div>
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
<div style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
<div class="modal-field" style="display:flex;align-items:center;gap:8px;flex-direction:row">
<input type="checkbox" id="use-jmap" onchange="toggleJMAPFields()" style="width:auto;flex:none">
<label for="use-jmap" style="margin:0;font-weight:400">Connect via JMAP instead of IMAP/SMTP</label>
</div>
<div id="imap-hint" style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
Common ports — IMAP: <strong>993</strong> TLS/SSL, <strong>143</strong> STARTTLS/Plain &nbsp;·&nbsp;
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
</div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
<div class="modal-field"><label id="imap-host-label">IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
<div class="modal-field" id="imap-port-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
</div>
<div class="modal-row">
<div class="modal-row" id="smtp-fields">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
</div>
<div class="modal-divider"><span>optional — sync calendar &amp; contacts</span></div>
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="imap-caldav-url" placeholder="https://mail.example.com/dav/calendars/user@example.com/default"></div>
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="imap-carddav-url" placeholder="https://mail.example.com/dav/addressbooks/user@example.com/default"></div>
<div class="test-result" id="test-result"></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
@@ -216,22 +522,60 @@
</div>
</div>
<!-- ── Label Editor Modal (create/rename/recolor) ────────────────────────────── -->
<div class="modal-overlay" id="label-editor-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="label-editor-title">
<div class="modal" style="max-width:360px">
<h2 id="label-editor-title">New Label</h2>
<input type="hidden" id="label-editor-id">
<div class="modal-field"><label>Name</label><input type="text" id="label-editor-name" maxlength="40"></div>
<div class="modal-field">
<label>Color</label>
<div class="label-swatches" id="label-editor-swatches"></div>
<input type="color" id="label-editor-custom-color" onchange="pickLabelColor(this.value)" style="width:40px;height:28px;padding:0;border:1px solid var(--border);border-radius:6px;background:none;cursor:pointer">
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('label-editor-modal')">Cancel</button>
<button class="modal-submit" onclick="saveLabelEditor()">Save</button>
</div>
</div>
</div>
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="edit-account-modal">
<div class="modal-overlay" id="edit-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="edit-account-modal-title">
<div class="modal">
<h2>Account Settings</h2>
<h2 id="edit-account-modal-title">Account Settings</h2>
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
<input type="hidden" id="edit-account-id">
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
<div id="edit-oauth-section" style="display:none">
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
⚠️ Access token has expired — sync and send will fail until you reconnect.
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
</div>
</div>
<div class="modal-row">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
<div id="edit-creds-section">
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label id="edit-imap-host-label">IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field" id="edit-imap-port-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
</div>
<div class="modal-row" id="edit-smtp-fields">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
</div>
</div>
<div class="settings-group-title" style="margin:16px 0 8px">Calendar &amp; Contacts (optional)</div>
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="edit-caldav-url" placeholder="leave blank to disable"></div>
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="edit-carddav-url" placeholder="leave blank to disable"></div>
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
<div class="modal-field">
<label>Email history to sync</label>
@@ -262,51 +606,354 @@
</div>
<!-- ── 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">
<h2 style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn"><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 class="modal-overlay" id="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settings-modal-title">
<div class="modal" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px">
<h2 id="settings-modal-title" style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn" aria-label="Close settings"><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 style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
<div class="settings-nav" role="tablist" aria-label="Settings sections">
<button data-tab="accounts" class="active" role="tab" aria-selected="true" onclick="showSettingsTab('accounts')">Accounts</button>
<button data-tab="general" role="tab" aria-selected="false" onclick="showSettingsTab('general')">General</button>
<button data-tab="security" role="tab" aria-selected="false" onclick="showSettingsTab('security')">Security</button>
<button data-tab="account" role="tab" aria-selected="false" onclick="showSettingsTab('account')">Profile</button>
<button data-tab="rules" role="tab" aria-selected="false" onclick="showSettingsTab('rules')">Rules</button>
<button data-tab="signatures" role="tab" aria-selected="false" onclick="showSettingsTab('signatures')">Signatures</button>
<button data-tab="certs" role="tab" aria-selected="false" onclick="showSettingsTab('certs')">Certificates</button>
</div>
<div style="flex:1;min-width:0;overflow-y:auto;padding:20px 24px">
<div class="settings-panel active" data-tab="accounts" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Connected mailboxes</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Manage sync, credentials, CalDAV/CardDAV and per-account settings for each connected mailbox.</div>
<div id="settings-accounts-list"></div>
<button class="accounts-add-btn" onclick="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="settings-panel" data-tab="general" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Remote Images</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Control when images and other remote content in emails load automatically. Blocking prevents senders from using tracking pixels to detect that you've opened a message.</div>
<div class="modal-field">
<label for="remote-image-policy-select">Policy</label>
<select id="remote-image-policy-select" onchange="saveRemoteImagePolicy()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="always">Always render images</option>
<option value="contacts">Only from Contacts</option>
<option value="never">Never</option>
<option value="manual">Manually (needs allowing)</option>
</select>
</div>
<div class="modal-field">
<label>Allowed senders</label>
<div id="remote-whitelist-list" style="font-size:12px;color:var(--muted)">Loading…</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Notifications</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Show a browser notification when new mail arrives, even while GoWebMail is in a background tab.</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
<input type="checkbox" id="notifications-toggle" onchange="toggleNotifications(this.checked)" style="width:auto">
Enable desktop notifications
</label>
<div id="notifications-status" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
</div>
</div>
<div class="settings-panel" data-tab="security" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Login History</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">View login attempts for your account only — successful and failed, with timestamps and source IPs.</div>
<button class="btn-secondary" onclick="openLoginHistory()">View Login History</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Spam Block</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Senders blocked here are automatically moved to Spam as soon as new mail from them arrives, across all your connected accounts — no notification is shown for it.</div>
<button class="btn-secondary" onclick="openSpamBlock()">Manage Spam Block List</button>
</div>
</div>
<div class="settings-panel" data-tab="account" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</div>
</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>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
</div>
<div id="mfa-panel">Loading...</div>
</div>
</div>
<div class="settings-panel" data-tab="rules" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Rules apply to</div>
<select id="rules-account-select" onchange="loadRules()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
</div>
<div class="settings-group">
<div class="settings-group-title">Add Rule</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">Rules run in priority order (lowest first) against newly-synced mail; the first match wins.</div>
<div class="modal-field"><label>Rule name</label><input type="text" id="rule-name" placeholder="e.g. Invoices to Accounting"></div>
<div style="display:flex;gap:8px">
<div class="modal-field" style="flex:1"><label>Priority</label><input type="number" id="rule-priority" value="0"></div>
<div class="modal-field" style="flex:1"><label>Match</label>
<select id="rule-match-type"><option value="all">ALL of the following (AND)</option><option value="any">ANY of the following (OR)</option></select>
</div>
</div>
<div id="rule-conditions"></div>
<button class="btn-secondary" style="margin-bottom:14px" onclick="addRuleConditionRow()">+ Add condition</button>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
<div class="modal-field" style="flex:1;min-width:140px"><label>Then</label>
<select id="rule-action" onchange="updateRuleActionFields()">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Junk</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
<option value="forward">Forward to...</option>
<option value="auto_reply">Send auto-reply</option>
</select>
</div>
<div class="modal-field" style="flex:1;min-width:160px" id="rule-action-value-field"><label>Folder name</label><input type="text" id="rule-action-value" placeholder="folder name"></div>
</div>
<div class="modal-field" id="rule-autoreply-body-field" style="display:none"><label>Auto-reply body</label><textarea id="rule-autoreply-body" rows="3" style="width:100%"></textarea></div>
<button class="btn-primary" onclick="saveRule()">Add Rule</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Existing Rules</div>
<div id="rules-list"><p style="color:var(--muted);font-size:13px">No rules yet.</p></div>
</div>
</div>
<div class="settings-panel" data-tab="signatures" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title" id="sig-form-title">Add Signature</div>
<div class="modal-field"><label for="sig-name">Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
<div class="modal-field">
<label for="sig-content">Content</label>
<div class="compose-toolbar" role="toolbar" aria-label="Signature formatting" style="margin-bottom:6px">
<button type="button" class="fmt-btn" title="Bold" aria-label="Bold" onclick="execSigFmt('bold')"><b>B</b></button>
<button type="button" class="fmt-btn" title="Italic" aria-label="Italic" onclick="execSigFmt('italic')"><i>I</i></button>
<button type="button" class="fmt-btn" title="Underline" aria-label="Underline" onclick="execSigFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<label class="fmt-btn" title="Text color" aria-label="Text color" style="cursor:pointer;position:relative">
🎨<input type="color" id="sig-color-input" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer" onchange="execSigFmt('foreColor', this.value)">
</label>
<span class="fmt-sep"></span>
<button type="button" class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink('sig-content')">&#128279;</button>
<button type="button" class="fmt-btn" title="Image" aria-label="Insert image" onclick="document.getElementById('sig-image-input').click()">&#128247;</button>
<button type="button" class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execSigFmt('removeFormat')">T&#x20D7;</button>
<input type="file" id="sig-image-input" accept="image/*" style="display:none" onchange="insertSigImage(this)">
</div>
<div id="sig-content" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Signature content"
style="width:100%;min-height:110px;padding:10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.5;overflow-y:auto"></div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" id="sig-save-btn" onclick="saveSignature()">Add Signature</button>
<button class="btn-secondary" id="sig-cancel-btn" onclick="cancelSignatureEdit()" style="display:none">Cancel</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Your Signatures</div>
<div id="signatures-list"><p style="color:var(--muted);font-size:13px">No signatures yet.</p></div>
</div>
<div class="settings-group">
<div class="settings-group-title">Defaults per account</div>
<select id="sig-defaults-account-select" onchange="renderSignatureDefaultsForm()" style="width:100%;margin-bottom:10px;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
<div id="sig-defaults-form"></div>
</div>
</div>
<div class="settings-panel" data-tab="certs" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Certificates apply to</div>
<select id="certs-account-select" onchange="loadCerts()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
<div style="font-size:12px;color:var(--muted);margin-top:10px">S/MIME certificates <b>sign</b> outgoing mail. PGP keys <b>encrypt</b> it. A message can use either, both, or neither.</div>
</div>
<div class="settings-group">
<div class="settings-group-title">S/MIME — for signing</div>
<div id="smime-identity-list"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
<button class="btn-primary" onclick="smimeGenerate()">Generate Self-Signed Certificate</button>
</div>
<div class="modal-field"><label>Import existing (.p12/.pfx)</label>
<input type="file" id="smime-import-file" accept=".p12,.pfx">
<input type="password" id="smime-import-password" placeholder=".p12 export password (if any)" style="margin-top:6px">
<button class="btn-secondary" style="margin-top:6px" onclick="smimeImport()">Import</button>
</div>
<div class="settings-group-title" style="margin-top:16px;font-size:13px">S/MIME contact certificates</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="email" id="smime-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
<input type="file" id="smime-contact-file" accept=".pem,.crt,.cer">
<button class="btn-secondary" onclick="smimeAddContact()">Add Contact</button>
</div>
<div id="smime-contacts-list"></div>
</div>
<div class="settings-group">
<div class="settings-group-title">PGP — for encryption</div>
<div id="pgp-identity-list"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
<input type="text" id="pgp-gen-label" placeholder="Label (optional)" style="flex:1;min-width:120px">
<input type="password" id="pgp-gen-pass" placeholder="Passphrase (min 8 chars)" style="flex:1;min-width:140px">
<input type="password" id="pgp-gen-pass2" placeholder="Confirm passphrase" style="flex:1;min-width:140px">
<button class="btn-primary" onclick="pgpGenerate()">Generate PGP Key</button>
</div>
<div class="modal-field"><label>Import existing (.asc)</label>
<input type="file" id="pgp-import-file" accept=".asc">
<input type="password" id="pgp-import-pass" placeholder="The key's passphrase" style="margin-top:6px">
<button class="btn-secondary" style="margin-top:6px" onclick="pgpImport()">Import</button>
</div>
<div class="settings-group-title" style="margin-top:16px;font-size:13px">PGP contact keys</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="email" id="pgp-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
<input type="text" id="pgp-contact-label" placeholder="Label (optional)" style="flex:1;min-width:100px">
<input type="file" id="pgp-contact-file" accept=".asc">
<button class="btn-secondary" onclick="pgpAddContact()">Add Contact</button>
</div>
<div id="pgp-contacts-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
</div>
</div>
</div>
</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>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<!-- Compose split-button dropdown — fixed-position, JS-placed (see toggleComposeDropdown);
lives outside .sidebar so its overflow:hidden can't clip it -->
<div id="compose-dropdown" style="display:none;position:fixed;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:210;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
</div>
<div id="mfa-panel">Loading...</div>
<!-- Search filters popover — fixed-position, JS-placed under the search bar (see
toggleSearchFilters); same reasoning as #compose-dropdown above. -->
<div id="search-filters-panel" style="display:none;position:fixed;padding:10px;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.5);z-index:210;max-height:80vh;overflow-y:auto">
<div class="modal-field" style="margin-bottom:8px">
<label>Search in mailbox</label>
<select id="sf-mailbox-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="">All mailboxes</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Search in</label>
<select id="sf-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="all">All fields</option>
<option value="subject">Subject only</option>
<option value="body">Body only</option>
<option value="subject_body">Subject + Body</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Attachment</label>
<select id="sf-attachment" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="">Any</option>
<option value="1">Has attachment</option>
<option value="0">No attachment</option>
</select>
</div>
<div class="modal-row" style="margin-bottom:8px;gap:8px">
<div class="modal-field" style="flex:1;margin-bottom:0"><label>From date</label>
<input type="date" id="sf-date-from" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-field" style="flex:1;margin-bottom:0"><label>To date</label>
<input type="date" id="sf-date-to" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Older than (days) <span style="color:var(--muted);font-size:10px">— fills in "To date"</span></label>
<input type="number" id="sf-older-days" min="0" placeholder="e.g. 30" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-row" style="margin-bottom:10px;gap:8px">
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Min size (KB)</label>
<input type="number" id="sf-min-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Max size (KB)</label>
<input type="number" id="sf-max-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
</div>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" onclick="clearSearchFilters()">Clear</button>
<button class="btn-primary" style="font-size:12px" onclick="applySearchFilters()">Apply</button>
</div>
</div>
<!-- Context menu -->
<div class="ctx-menu" id="ctx-menu"></div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=12"></script>
<script src="/static/js/app.js?v=91"></script>
<script src="/static/js/contacts_calendar.js?v=79"></script>
{{end}}
+2 -2
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=79">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=12"></script>
<script src="/static/js/gowebmail.js?v=79"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
+448
View File
@@ -0,0 +1,448 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="compose-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border);flex-wrap:wrap">
<a href="/" id="cp-back-link" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button class="btn-secondary" id="discard-draft-btn" style="display:none;font-size:12px;color:var(--danger)" onclick="discardDraftAndReset()">Discard draft</button>
<button type="button" id="cc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowCC()">+CC</button>
<button type="button" id="bcc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowBCC()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">💾 Save Draft</button>
<button class="btn-secondary" id="sendlater-btn" style="font-size:12px" onclick="toggleSendLaterPanel()">🕐 Send later</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
<input type="file" id="cp-file-input" multiple style="display:none" onchange="addPageAttachments(this.files)">
</div>
</div>
<div id="cp-leave-confirm" class="remote-content-banner" style="display:none;margin-bottom:14px">
You have a draft in progress.
<button class="rcb-btn" id="cp-leave-keep">Keep editing</button>
<button class="rcb-btn" id="cp-leave-save">Save &amp; leave</button>
<button class="rcb-btn" id="cp-leave-discard">Discard &amp; leave</button>
</div>
<div id="cp-sendlater-panel" class="remote-content-banner" style="display:none;margin-bottom:14px">
Send at:
<input type="datetime-local" id="cp-sendlater-input" style="background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:4px 8px;font-size:13px">
<button class="rcb-btn" onclick="confirmSendLater()">Schedule</button>
<button class="rcb-btn" onclick="document.getElementById('cp-sendlater-panel').style.display='none'">Cancel</button>
</div>
<div id="compose-page-form">
<!-- From -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<label for="cp-from" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</label>
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
</div>
<!-- To -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-to-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-container" role="group" aria-labelledby="cp-to-label" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div id="cc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-cc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-container" role="group" aria-labelledby="cp-cc-label" style="flex:1;min-height:30px"></div>
</div>
<!-- BCC -->
<div id="bcc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-bcc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">BCC</span>
<div id="cp-bcc-tags" class="tag-container" role="group" aria-labelledby="cp-bcc-label" style="flex:1;min-height:30px"></div>
</div>
<!-- Subject -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<label for="cp-subject" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</label>
<input id="cp-subject" type="text" placeholder="Subject" oninput="markDirty()" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Formatting toolbar -->
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" oninput="markDirty()" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments (added via the "Attach" button in the header) -->
<div id="cp-att-list" style="border-top:1px solid var(--border);padding:10px 0;display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
<div id="cp-status" role="status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Parse URL params
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const editDraftId = parseInt(params.get('edit_draft_id') || '0');
const cpAttachments = [];
let draftId = '', dirty = false, draftTimer = null;
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
if (body instanceof FormData) { opts.body = body; }
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function markDirty() { dirty = true; }
function setStatus(msg, isError) {
const el = document.getElementById('cp-status');
el.textContent = msg;
el.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
// ── Remote-image policy — same rules as the reading pane (app.js / message.html) ──
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
function quotedBodyHTML(msg) {
if (!msg.body_html) return '<pre>'+esc(msg.body_text||'')+'</pre>';
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!isRemoteContentAllowed(msg.from_email)) html = stripRemoteImages(html);
return html;
}
function restoreBlockedImages(html) {
return html.replace(/src=""\s+data-blocked-src="([^"]*)"/gi, 'src="$1"');
}
// ── Tag fields (To/Cc/Bcc) — same visual style as the main compose modal ──
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
const inp = el.querySelector('input');
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
e.preventDefault();
const v = inp.value.trim().replace(/,$/, '');
if (v) addTagTo(id, v);
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value) {
const tags = el.querySelectorAll('.email-tag');
if (tags.length) { tags[tags.length-1].remove(); markDirty(); }
}
});
inp.addEventListener('blur', () => {
const v = inp.value.trim().replace(/,$/, '');
if (v) { addTagTo(id, v); inp.value = ''; }
});
}
function addTagTo(fieldId, email) {
if (!email) return;
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const tag = document.createElement('span');
tag.className = 'email-tag';
const label = document.createElement('span');
label.textContent = email;
const remove = document.createElement('button');
remove.innerHTML = '×'; remove.className = 'tag-remove'; remove.type = 'button';
remove.onclick = e => { e.stopPropagation(); tag.remove(); markDirty(); };
tag.appendChild(label); tag.appendChild(remove);
el.insertBefore(tag, inp || null);
markDirty();
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.email-tag')).map(c => c.firstChild.textContent.trim()).filter(Boolean);
}
function cpShowCC() { document.getElementById('cc-row').style.display = 'flex'; document.getElementById('cc-toggle').style.display = 'none'; }
function cpShowBCC() { document.getElementById('bcc-row').style.display = 'flex'; document.getElementById('bcc-toggle').style.display = 'none'; }
function execFmt(cmd, val) { document.getElementById('cp-editor').focus(); document.execCommand(cmd, false, val || null); }
function triggerAttach() { document.getElementById('cp-file-input').click(); }
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
const chip = document.createElement('span');
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
markDirty();
}
async function loadAccounts() {
const accounts = await apiCall('GET', '/accounts') || [];
const sel = document.getElementById('cp-from');
accounts.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
sel.appendChild(opt);
});
}
async function loadRemoteImagePrefs() {
const [uiPrefs, wl] = await Promise.all([apiCall('GET', '/ui-prefs'), apiCall('GET', '/remote-content-whitelist')]);
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await apiCall('GET', '/contacts') || [];
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
const msg = await apiCall('GET', '/messages/' + replyId);
if (!msg) return;
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
addTagTo('cp-to-tags', msg.from_email || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
<div style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</div>`;
// Set from to same account
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
dirty = false;
}
async function prefillForward() {
if (!forwardId) return;
document.getElementById('compose-page-title').textContent = 'Forward';
const msg = await apiCall('GET', '/messages/' + forwardId);
if (!msg) return;
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
<div style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</div>`;
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
dirty = false;
}
// Resuming a saved draft: unlike reply/forward, fields are populated directly (no quoting
// wrapper) and draftId is seeded from the draft's own id so the next autosave/send/discard
// replaces this exact draft in place instead of creating a second copy.
async function prefillEditDraft() {
if (!editDraftId) return;
document.getElementById('compose-page-title').textContent = 'Edit Draft';
const msg = await apiCall('GET', '/messages/' + editDraftId);
if (!msg) return;
document.title = 'Edit Draft — GoWebMail';
document.getElementById('cp-subject').value = msg.subject || '';
(msg.to || '').split(',').map(s => s.trim()).filter(Boolean).forEach(a => addTagTo('cp-to-tags', a));
const ccList = (msg.cc || '').split(',').map(s => s.trim()).filter(Boolean);
if (ccList.length) { cpShowCC(); ccList.forEach(a => addTagTo('cp-cc-tags', a)); }
const bccList = (msg.bcc || '').split(',').map(s => s.trim()).filter(Boolean);
if (bccList.length) { cpShowBCC(); bccList.forEach(a => addTagTo('cp-bcc-tags', a)); }
document.getElementById('cp-editor').innerHTML = quotedBodyHTML(msg);
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
draftId = msg.remote_uid || '';
updateDraftUI();
dirty = false;
}
async function sendFromPage() {
const btn = document.getElementById('send-page-btn');
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
};
let r;
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
if (cpAttachments.length) {
const fd = new FormData();
fd.append('meta', JSON.stringify(meta));
cpAttachments.forEach(f => fd.append('file', f, f.name));
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
r = await resp.json().catch(() => null);
} else {
r = await apiCall('POST', endpoint, meta);
}
btn.disabled = false; btn.textContent = 'Send';
if (r?.ok) {
stopAutosave();
dirty = false;
await discardDraftReq(); // the autosaved Drafts-folder copy is now redundant — it's been sent
setStatus('✓ Message sent!');
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
setStatus(r?.error || 'Send failed.', true);
}
}
// ── Send later ───────────────────────────────────────────────────────────────
function toLocalInput(d) {
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function toggleSendLaterPanel() {
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
if (cpAttachments.length) { setStatus("Send later doesn't support file attachments yet — forwarded messages are fine", true); return; }
const panel = document.getElementById('cp-sendlater-panel');
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'flex';
if (!isOpen) {
const input = document.getElementById('cp-sendlater-input');
input.min = toLocalInput(new Date(Date.now() + 60000));
input.value = toLocalInput(new Date(Date.now() + 3600000));
}
}
async function confirmSendLater() {
const input = document.getElementById('cp-sendlater-input');
const d = new Date(input.value);
if (!input.value || isNaN(d.getTime()) || d <= new Date()) { setStatus('Pick a time in the future.', true); return; }
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
const meta = {
account_id: accountId, to,
cc: getTagValues('cp-cc-tags'), bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
send_at: d.toISOString(),
};
const r = await apiCall('POST', '/send-later', meta);
if (r?.ok) {
stopAutosave();
dirty = false;
await discardDraftReq();
setStatus('✓ Message scheduled!');
document.getElementById('cp-sendlater-panel').style.display = 'none';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
setStatus(r?.error || 'Failed to schedule.', true);
}
}
// ── Draft autosave ──────────────────────────────────────────────────────────
function startAutosave() { stopAutosave(); draftTimer = setInterval(() => { if (dirty) saveDraft(true); }, 60000); }
function stopAutosave() { if (draftTimer) { clearInterval(draftTimer); draftTimer = null; } }
function updateDraftUI() {
document.getElementById('discard-draft-btn').style.display = draftId ? 'inline-block' : 'none';
}
async function saveDraft(silent) {
dirty = false;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) { if (!silent) setStatus('Add a From account first.', true); return; }
const editor = document.getElementById('cp-editor');
const meta = {
account_id: accountId,
to: getTagValues('cp-to-tags'),
cc: getTagValues('cp-cc-tags'),
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(editor.innerHTML.trim()),
body_text: editor.innerText.trim(),
draft_id: draftId,
};
const r = await apiCall('POST', '/draft', meta);
if (r?.ok) { draftId = r.draft_id || draftId; updateDraftUI(); setStatus(silent ? 'Draft auto-saved' : 'Draft saved'); }
else if (!silent) setStatus(r?.error || 'Draft save failed', true);
}
// Deletes the draft that autosave already wrote to the server for this compose session.
async function discardDraftReq() {
if (!draftId) return;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) return;
await apiCall('POST', '/draft/discard', { account_id: accountId, draft_id: draftId });
draftId = ''; updateDraftUI();
}
async function discardDraftAndReset() {
await discardDraftReq();
setStatus('Draft discarded');
}
// ── Leaving the page with unsent work ───────────────────────────────────────
document.getElementById('cp-back-link').addEventListener('click', e => {
if (dirty || draftId) {
e.preventDefault();
document.getElementById('cp-leave-confirm').style.display = 'flex';
}
});
document.getElementById('cp-leave-keep').onclick = () => { document.getElementById('cp-leave-confirm').style.display = 'none'; };
document.getElementById('cp-leave-discard').onclick = async () => { await discardDraftReq(); location.href = '/'; };
document.getElementById('cp-leave-save').onclick = async () => { await saveDraft(true); location.href = '/'; };
window.addEventListener('beforeunload', e => {
if (dirty || draftId) { e.preventDefault(); e.returnValue = ''; }
});
// Init
async function boot() {
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
initTagField('cp-bcc-tags');
await Promise.all([loadAccounts(), loadRemoteImagePrefs()]);
if (replyId) await prefillReply();
else if (forwardId) await prefillForward();
else if (editDraftId) await prefillEditDraft();
startAutosave();
}
boot();
</script>
{{end}}
+4 -4
View File
@@ -9,17 +9,17 @@
</div>
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your Web Mail Client</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/login">
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<div class="field"><label for="login-username">Username or Email</label><input id="login-username" type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label for="login-password">Password</label><input id="login-password" type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
</form>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
const k=new URLSearchParams(location.search).get('error');
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
</script>
+193
View File
@@ -0,0 +1,193 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="msg-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<div id="msg-actions" style="display:flex;gap:8px"></div>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
</div>
</div>
<div id="msg-content">
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── Remote-image policy — same rules as the main reading pane (app.js) ──
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
async function whitelistSender(sender) {
const r = await api('POST', '/remote-content-whitelist', { sender });
if (r?.ok) { remoteWhitelist.add(sender); render(window._msg, true); }
}
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
// Content-aware height report (leaf elements only — see app.js renderMessageDetail for why
// document.documentElement.scrollHeight is wrong: it counts trailing structural dead space
// some email templates leave behind) + link-click interception.
const heightScript = `<script>
function _reportH(){
try{
var maxBottom=0;
var all=document.body?document.body.getElementsByTagName('*'):[];
for(var i=0;i<all.length;i++){
var el=all[i];
if(el.children.length>0) continue;
var cs=getComputedStyle(el);
if(cs.display==='none'||cs.visibility==='hidden'||parseFloat(cs.opacity||'1')===0) continue;
var hasText=(el.textContent||'').replace(/[\\s\\u00A0]/g,'').length>0;
if(!hasText && el.tagName!=='IMG') continue;
var r=el.getBoundingClientRect();
if(r.bottom>maxBottom) maxBottom=r.bottom;
}
var h=maxBottom>0?maxBottom:document.documentElement.scrollHeight;
parent.postMessage({type:'gomail-frame-h',h:h},'*');
}catch(ex){parent.postMessage({type:'gomail-frame-h',h:0},'*');}
}
document.addEventListener('DOMContentLoaded',_reportH);
window.addEventListener('load',_reportH);
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
if(window.ResizeObserver) new ResizeObserver(_reportH).observe(document.documentElement);
[50,150,400,900,1800,3000].forEach(function(ms){ setTimeout(_reportH, ms); });
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
window.addEventListener('message', e => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const frame = document.getElementById('msg-frame');
if (frame) frame.style.height = (e.data.h + 24) + 'px';
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
window.open(e.data.url, '_blank', 'noopener,noreferrer');
}
});
function render(msg, showRemoteContent) {
window._msg = msg;
const allowed = showRemoteContent || isRemoteContentAllowed(msg.from_email);
const atts = msg.attachments || [];
const attHtml = atts.length ? `
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
let bodyHtml = '';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!allowed) {
const alwaysAllowBtn = remoteImagePolicy === 'never' ? '' :
`<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>`;
bodyHtml = `<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="render(window._msg,true)">Load images</button>
${alwaysAllowBtn}
</div>`;
html = stripRemoteImages(html);
}
const srcdoc = (cssReset + heightScript + html).replace(/"/g,'&quot;');
bodyHtml += `<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-frame" title="Message content" sandbox="${sandboxAttr}" style="width:100%;border:none;min-height:200px;display:block" srcdoc="${srcdoc}"></iframe>
</div>`;
} else {
bodyHtml = `<div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px;white-space:pre-wrap">${esc(msg.body_text||'(empty)')}</div>`;
}
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)">&lt;${esc(msg.from_email)}&gt;</span>` : ''}
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
${bodyHtml}
${attHtml}`;
}
async function load() {
const [msg, folders, uiPrefs, wl] = await Promise.all([
api('GET', '/messages/' + msgId), api('GET', '/folders'),
api('GET', '/ui-prefs'), api('GET', '/remote-content-whitelist'),
]);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// A draft opened here (bookmark, typed URL, old link) should open editable, not read-only.
const folder = (folders||[]).find(f=>f.id===msg.folder_id);
if (folder?.folder_type === 'drafts') { location.replace('/compose?edit_draft_id=' + msgId); return; }
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await api('GET', '/contacts') || [];
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
render(msg, false);
}
function replyFromPage() {
window.location = '/compose?reply_id=' + msgId;
}
function forwardFromPage() {
window.location = '/compose?forward_id=' + msgId;
}
load();
</script>
{{end}}
+3 -3
View File
@@ -9,10 +9,10 @@
</div>
<h1>Two-Factor Auth</h1>
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/mfa/verify">
<div class="field"><label>Verification Code</label>
<input type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
<div class="field"><label for="mfa-code">Verification Code</label>
<input id="mfa-code" type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
style="font-size:22px;letter-spacing:.3em;text-align:center">
</div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Verify</button>