Compare commits

...

9 Commits

Author SHA1 Message Date
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
32 changed files with 5315 additions and 177 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ data/gowebmail.conf
data/*.txt data/*.txt
gowebmail-devplan.md gowebmail-devplan.md
testrun/ testrun/
webmail.code-workspace

View File

@@ -1,13 +1,16 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -15,6 +18,7 @@ import (
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/handlers" "github.com/ghostersk/gowebmail/internal/handlers"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/syncer" "github.com/ghostersk/gowebmail/internal/syncer"
@@ -47,6 +51,16 @@ func main() {
} }
runDisableMFA(args[1]) runDisableMFA(args[1])
return 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": case "--help", "-h":
printHelp() printHelp()
return return
@@ -62,6 +76,18 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("config load: %v", err) 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) database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil { if err != nil {
@@ -73,7 +99,7 @@ func main() {
log.Fatalf("migrations: %v", err) log.Fatalf("migrations: %v", err)
} }
sc := syncer.New(database) sc := syncer.New(database, cfg)
sc.Start() sc.Start()
defer sc.Stop() defer sc.Stop()
@@ -85,10 +111,27 @@ func main() {
r.Use(middleware.CORS) r.Use(middleware.CORS)
r.Use(cfg.HostCheckMiddleware) 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 // Static files
r.PathPrefix("/static/").Handler( r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))), 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) { r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png") data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
if err != nil { if err != nil {
@@ -103,7 +146,7 @@ func main() {
// Public auth routes // Public auth routes
auth := r.PathPrefix("/auth").Subrouter() auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") 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") auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
// MFA (session exists but mfa_verified=0) // MFA (session exists but mfa_verified=0)
@@ -119,11 +162,15 @@ func main() {
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET") oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET") oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).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
app := r.PathPrefix("").Subrouter() app := r.PathPrefix("").Subrouter()
app.Use(middleware.RequireAuth(database, cfg)) app.Use(middleware.RequireAuth(database, cfg))
app.HandleFunc("/", h.App.Index).Methods("GET") 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 // Admin UI
adminUI := r.PathPrefix("/admin").Subrouter() adminUI := r.PathPrefix("/admin").Subrouter()
@@ -133,6 +180,7 @@ func main() {
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET") adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET") adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET") adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
// API // API
api := r.PathPrefix("/api").Subrouter() api := r.PathPrefix("/api").Subrouter()
@@ -141,10 +189,13 @@ func main() {
// Profile / auth // Profile / auth
api.HandleFunc("/me", h.Auth.Me).Methods("GET") 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("/change-password", h.Auth.ChangePassword).Methods("POST")
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST") api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST") api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).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) // Providers (which OAuth providers are configured)
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET") api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
@@ -202,10 +253,35 @@ func main() {
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET") api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT") api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
api.HandleFunc("/compose-popup", h.API.SetComposePopup).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")
// Search // Search
api.HandleFunc("/search", h.API.Search).Methods("GET") 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")
// Admin API // Admin API
adminAPI := r.PathPrefix("/api/admin").Subrouter() adminAPI := r.PathPrefix("/api/admin").Subrouter()
adminAPI.Use(middleware.RequireAuth(database, cfg)) adminAPI.Use(middleware.RequireAuth(database, cfg))
@@ -218,6 +294,19 @@ func main() {
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET") adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET") adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT") 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()
}
}()
srv := &http.Server{ srv := &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
@@ -310,6 +399,69 @@ func runDisableMFA(username string) {
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username) 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() { func printHelp() {
fmt.Print(`GoWebMail — Admin CLI fmt.Print(`GoWebMail — Admin CLI
@@ -318,14 +470,35 @@ Usage:
gowebmail --list-admin List all admin accounts (username, email, MFA status) gowebmail --list-admin List all admin accounts (username, email, MFA status)
gowebmail --pw <username> <pass> Reset password for an admin account gowebmail --pw <username> <pass> Reset password for an admin account
gowebmail --mfa-off <username> Disable MFA 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: Examples:
./gowebmail --list-admin ./gowebmail --list-admin
./gowebmail --pw admin "NewSecurePass123" ./gowebmail --pw admin "NewSecurePass123"
./gowebmail --mfa-off admin ./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. Regular user management is done through the web UI.
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc). 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)
}

View File

@@ -21,6 +21,9 @@ type Config struct {
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Debug
Debug bool // set DEBUG=true in config to enable verbose logging
// Security // Security
EncryptionKey []byte // 32 bytes / AES-256 EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte SessionSecret []byte
@@ -28,6 +31,23 @@ type Config struct {
SessionMaxAge int SessionMaxAge int
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers 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 // Storage
DBPath string DBPath string
@@ -118,6 +138,108 @@ var allFields = []configField{
" NOTE: Do not add untrusted IPs — clients could spoof their source address.", " 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", key: "DB_PATH",
defVal: "./data/gowebmail.db", defVal: "./data/gowebmail.db",
@@ -184,10 +306,15 @@ var allFields = []configField{
}, },
{ {
key: "MICROSOFT_TENANT_ID", key: "MICROSOFT_TENANT_ID",
defVal: "common", defVal: "consumers",
comments: []string{ comments: []string{
"Use 'common' to allow any Microsoft account,", "Tenant endpoint to use for Microsoft OAuth2.",
"or your Azure tenant ID to restrict to one organisation.", " 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).",
}, },
}, },
{ {
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
Hostname: hostname, Hostname: hostname,
BaseURL: baseURL, BaseURL: baseURL,
DBPath: get("DB_PATH"), DBPath: get("DB_PATH"),
Debug: atobool(get("DEBUG"), false),
EncryptionKey: encKey, EncryptionKey: encKey,
SessionSecret: []byte(sessSecret), SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false), SecureCookie: atobool(get("SECURE_COOKIE"), false),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800), SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
TrustedProxies: trustedProxies, 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"), GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"), GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: googleRedirect, GoogleRedirectURL: googleRedirect,
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"), MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"), MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"), MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
MicrosoftRedirectURL: outlookRedirect, 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. // 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). // Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
func (c *Config) IsAllowedHost(requestHost string) bool { func (c *Config) IsAllowedHost(requestHost string) bool {
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
} }
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", ")) 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 { func mustHex(n int) string {

9
go.mod
View File

@@ -5,14 +5,13 @@ go 1.26
require ( require (
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap v1.2.1
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.34
golang.org/x/crypto v0.24.0 golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.21.0 golang.org/x/oauth2 v0.36.0
) )
require ( require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // 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.35.0 // indirect
golang.org/x/text v0.16.0 // indirect
) )

18
go.sum
View File

@@ -6,18 +6,16 @@ github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwd
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= 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-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/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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -3,11 +3,15 @@ package auth
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft" "golang.org/x/oauth2/microsoft"
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
// ---- Microsoft / Outlook OAuth2 ---- // ---- Microsoft / Outlook OAuth2 ----
// OutlookScopes are required for Outlook/Microsoft 365 mail access. // OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
var OutlookScopes = []string{ // 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/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send", "https://outlook.office.com/SMTP.Send",
"offline_access", "offline_access",
"openid", "openid",
"profile",
"email", "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 { 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{ return &oauth2.Config{
ClientID: clientID, ClientID: clientID,
ClientSecret: clientSecret, ClientSecret: clientSecret,
RedirectURL: redirectURL, RedirectURL: redirectURL,
Scopes: OutlookScopes, Scopes: OutlookAuthScopes,
Endpoint: microsoft.AzureADEndpoint(tenantID), 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 { type MicrosoftUserInfo struct {
ID string `json:"id"` 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"` Mail string `json:"mail"`
EmailClaim string `json:"email"` // ID token claim
UserPrincipalName string `json:"userPrincipalName"` UserPrincipalName string `json:"userPrincipalName"`
PreferredUsername string `json:"preferred_username"` // ID token claim
} }
// Email returns the best available email address. // Email returns the best available email address.
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
if m.Mail != "" { if m.Mail != "" {
return m.Mail return m.Mail
} }
if m.EmailClaim != "" {
return m.EmailClaim
}
if m.PreferredUsername != "" {
return m.PreferredUsername
}
return m.UserPrincipalName 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) { func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
client := cfg.Client(ctx, token) idToken, _ := token.Extra("id_token").(string)
resp, err := client.Get("https://graph.microsoft.com/v1.0/me") 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 { if err != nil {
return nil, fmt.Errorf("graph /me request: %w", err) return nil, fmt.Errorf("id_token base64 decode: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
} }
var info MicrosoftUserInfo var info MicrosoftUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { if err := json.Unmarshal(decoded, &info); err != nil {
return nil, err 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 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 ---- // ---- Token refresh helpers ----
// IsTokenExpired reports whether the token expires within a 60-second buffer. // 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}) ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
return ts.Token() 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)
}
}

View File

@@ -2,7 +2,9 @@
package db package db
import ( import (
"crypto/rand"
"database/sql" "database/sql"
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -170,7 +172,6 @@ func (d *DB) Migrate() error {
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`, `ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync. // Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
// Default: primary folder types sync by default, others don't.
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`, `ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search. // Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
@@ -178,6 +179,10 @@ func (d *DB) Migrate() error {
// Per-folder IMAP sync state for incremental/delta sync. // Per-folder IMAP sync state for incremental/delta sync.
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
// Account display order for sidebar drag-and-drop reordering.
`ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`,
// UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices.
`ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`,
} }
for _, stmt := range alterStmts { for _, stmt := range alterStmts {
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
@@ -199,6 +204,108 @@ func (d *DB) Migrate() error {
return fmt.Errorf("create pending_imap_ops: %w", err) return fmt.Errorf("create pending_imap_ops: %w", err)
} }
// Login attempt tracking for brute-force protection.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
username TEXT NOT NULL DEFAULT '',
success INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
country_code TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create login_attempts: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_time ON login_attempts(ip, created_at)`); err != nil {
return fmt.Errorf("create login_attempts index: %w", err)
}
// IP block list — manually added or auto-created by brute force protection.
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS ip_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
reason TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
country_code TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
blocked_at DATETIME DEFAULT (datetime('now')),
expires_at DATETIME,
is_permanent INTEGER NOT NULL DEFAULT 0
)`); err != nil {
return fmt.Errorf("create ip_blocks: %w", err)
}
// Per-user IP access rules.
// mode: "brute_skip" = skip brute force check for this user from listed IPs
// "allow_only" = only allow login from listed IPs (all others get 403)
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS user_ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL DEFAULT 'brute_skip',
ip_list TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id)
)`); err != nil {
return fmt.Errorf("create user_ip_rules: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
company TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
avatar_color TEXT NOT NULL DEFAULT '#6b7280',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create contacts: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil {
return fmt.Errorf("index contacts_user: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL,
uid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'confirmed',
organizer_email TEXT NOT NULL DEFAULT '',
attendees TEXT NOT NULL DEFAULT '',
ical_source TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, uid)
)`); err != nil {
return fmt.Errorf("create calendar_events: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_calendar_user_time ON calendar_events(user_id, start_time)`); err != nil {
return fmt.Errorf("index calendar_user_time: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT 'CalDAV token',
created_at DATETIME DEFAULT (datetime('now')),
last_used DATETIME
)`); err != nil {
return fmt.Errorf("create caldav_tokens: %w", err)
}
// Bootstrap admin account if no users exist // Bootstrap admin account if no users exist
return d.bootstrapAdmin() return d.bootstrapAdmin()
} }
@@ -577,14 +684,14 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
access_token, refresh_token, token_expiry, access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port, imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at, last_error, color, is_active, last_sync, created_at,
COALESCE(sync_days,30), COALESCE(sync_mode,'days') COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0)
FROM email_accounts WHERE id=?`, accountID, FROM email_accounts WHERE id=?`, accountID,
).Scan( ).Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry, &accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SyncDays, &a.SyncMode, &a.SyncDays, &a.SyncMode, &a.SortOrder,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -717,8 +824,10 @@ func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) {
SELECT id, user_id, provider, email_address, display_name, SELECT id, user_id, provider, email_address, display_name,
access_token, refresh_token, token_expiry, access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port, imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at last_error, color, is_active, last_sync, created_at,
FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID) COALESCE(sort_order,0)
FROM email_accounts WHERE user_id=? AND is_active=1
ORDER BY COALESCE(sort_order,0), created_at`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -737,6 +846,7 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
&accessEnc, &refreshEnc, &a.TokenExpiry, &accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SortOrder,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -759,6 +869,111 @@ func (d *DB) DeleteAccount(accountID, userID int64) error {
return err return err
} }
// UpsertOAuthAccount inserts a new OAuth account or updates tokens/display name
// if an account with the same (user_id, provider, email_address) already exists.
// Used by OAuth callbacks so that re-connecting updates rather than duplicates.
func (d *DB) UpsertOAuthAccount(a *models.EmailAccount) (created bool, err error) {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
// Check for existing account with same user + provider + email
var existingID int64
row := d.sql.QueryRow(
`SELECT id FROM email_accounts WHERE user_id=? AND provider=? AND email_address=?`,
a.UserID, a.Provider, a.EmailAddress,
)
scanErr := row.Scan(&existingID)
if scanErr == sql.ErrNoRows {
// New account — insert with next sort_order
var maxOrder int
d.sql.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM email_accounts WHERE user_id=?`, a.UserID).Scan(&maxOrder)
res, insertErr := d.sql.Exec(`
INSERT INTO email_accounts
(user_id, provider, email_address, display_name, access_token, refresh_token,
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
accessEnc, refreshEnc, a.TokenExpiry,
"", a.IMAPPort, "", a.SMTPPort,
a.Color, maxOrder+1,
)
if insertErr != nil {
return false, insertErr
}
id, _ := res.LastInsertId()
a.ID = id
return true, nil
}
if scanErr != nil {
return false, scanErr
}
// Existing account — update tokens and display name only.
// If refresh token is empty (Microsoft omits it after first auth),
// keep the existing one to avoid losing the ability to auto-refresh.
if a.RefreshToken != "" {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, refresh_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, existingID,
)
} else {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, a.TokenExpiry, existingID,
)
}
a.ID = existingID
return false, err
}
// UpdateAccountSortOrder sets sort_order for a batch of accounts for a user.
// accountIDs is ordered from first to last in the desired display order.
func (d *DB) UpdateAccountSortOrder(userID int64, accountIDs []int64) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
for i, id := range accountIDs {
if _, err := tx.Exec(
`UPDATE email_accounts SET sort_order=? WHERE id=? AND user_id=?`,
i, id, userID,
); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetUIPrefs returns the JSON ui_prefs string for a user.
func (d *DB) GetUIPrefs(userID int64) (string, error) {
var prefs string
err := d.sql.QueryRow(`SELECT COALESCE(ui_prefs,'{}') FROM users WHERE id=?`, userID).Scan(&prefs)
if err != nil {
return "{}", err
}
return prefs, nil
}
// SetUIPrefs stores the JSON ui_prefs string for a user.
func (d *DB) SetUIPrefs(userID int64, prefs string) error {
_, err := d.sql.Exec(`UPDATE users SET ui_prefs=? WHERE id=?`, prefs, userID)
return err
}
// UpdateFolderCountsDirect sets folder counts directly (used by Graph sync where
// the server provides accurate counts without needing a local recount).
func (d *DB) UpdateFolderCountsDirect(folderID int64, total, unread int) {
d.sql.Exec(`UPDATE folders SET total_count=?, unread_count=? WHERE id=?`,
total, unread, folderID)
}
// UpdateFolderCounts refreshes the unread/total counts for a folder.
func (d *DB) UpdateFolderCounts(folderID int64) { func (d *DB) UpdateFolderCounts(folderID int64) {
d.sql.Exec(` d.sql.Exec(`
UPDATE folders SET UPDATE folders SET
@@ -1070,6 +1285,22 @@ func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error {
return err return err
} }
// UpdateMessageBody persists body text/html for a message (used by Graph lazy fetch).
func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) {
bodyTextEnc, _ := d.enc.Encrypt(bodyText)
bodyHTMLEnc, _ := d.enc.Encrypt(bodyHTML)
d.sql.Exec(`UPDATE messages SET body_text=?, body_html=? WHERE id=?`,
bodyTextEnc, bodyHTMLEnc, messageID)
}
// GetNewestMessageDate returns the date of the most recent message in a folder.
// Returns zero time if the folder is empty.
func (d *DB) GetNewestMessageDate(folderID int64) time.Time {
var t time.Time
d.sql.QueryRow(`SELECT MAX(date) FROM messages WHERE folder_id=?`, folderID).Scan(&t)
return t
}
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) { func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
var current bool var current bool
err := d.sql.QueryRow(` err := d.sql.QueryRow(`
@@ -1265,6 +1496,28 @@ func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, fold
return remoteUID, folder.FullPath, account, err return remoteUID, folder.FullPath, account, err
} }
// GetMessageGraphInfo returns the Graph message ID (remote_uid as string), folder ID string,
// and account for a Graph-backed message. Used by handlers for outlook_personal accounts.
func (d *DB) GetMessageGraphInfo(messageID, userID int64) (graphMsgID string, folderGraphID string, account *models.EmailAccount, err error) {
var accountID int64
var folderID int64
err = d.sql.QueryRow(`
SELECT m.remote_uid, m.account_id, m.folder_id
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(&graphMsgID, &accountID, &folderID)
if err != nil {
return "", "", nil, err
}
folder, err := d.GetFolderByID(folderID)
if err != nil || folder == nil {
return graphMsgID, "", nil, fmt.Errorf("folder not found")
}
account, err = d.GetAccount(accountID)
return graphMsgID, folder.FullPath, account, err
}
// ListStarredMessages returns all starred messages for a user, newest first. // ListStarredMessages returns all starred messages for a user, newest first.
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) { func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
@@ -1693,3 +1946,564 @@ func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
WHERE id=?`, targetUserID) WHERE id=?`, targetUserID)
return err return err
} }
// ---- Brute Force / IP Block ----
// IPBlock represents a blocked IP entry.
type IPBlock struct {
ID int64 `json:"id"`
IP string `json:"ip"`
Reason string `json:"reason"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Attempts int `json:"attempts"`
BlockedAt time.Time `json:"blocked_at"`
ExpiresAt *time.Time `json:"expires_at"`
IsPermanent bool `json:"is_permanent"`
}
// LoginAttemptStat is used for summary display.
type LoginAttemptStat struct {
IP string `json:"ip"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Total int `json:"total"`
Failures int `json:"failures"`
LastSeen string `json:"last_seen"`
}
// RecordLoginAttempt saves a login attempt for an IP.
func (d *DB) RecordLoginAttempt(ip, username, country, countryCode string, success bool) {
suc := 0
if success {
suc = 1
}
d.sql.Exec(`INSERT INTO login_attempts (ip, username, success, country, country_code) VALUES (?,?,?,?,?)`,
ip, username, suc, country, countryCode)
}
// CountRecentFailures returns the number of failed logins from an IP in the last windowMinutes.
func (d *DB) CountRecentFailures(ip string, windowMinutes int) int {
var count int
d.sql.QueryRow(`
SELECT COUNT(*) FROM login_attempts
WHERE ip=? AND success=0 AND created_at >= datetime('now', ? || ' minutes')`,
ip, fmt.Sprintf("-%d", windowMinutes),
).Scan(&count)
return count
}
// IsIPBlocked returns true if the IP is currently blocked (non-expired entry).
func (d *DB) IsIPBlocked(ip string) bool {
var count int
d.sql.QueryRow(`
SELECT COUNT(*) FROM ip_blocks
WHERE ip=? AND (is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now'))`,
ip,
).Scan(&count)
return count > 0
}
// BlockIP adds or updates a block entry for an IP.
// banHours=0 means permanent block (admin must remove manually).
func (d *DB) BlockIP(ip, reason, country, countryCode string, attempts int, banHours int) {
isPermanent := 0
var expiresExpr string
if banHours == 0 {
isPermanent = 1
expiresExpr = "NULL"
} else {
expiresExpr = fmt.Sprintf("datetime('now', '+%d hours')", banHours)
}
d.sql.Exec(fmt.Sprintf(`
INSERT INTO ip_blocks (ip, reason, country, country_code, attempts, is_permanent, expires_at)
VALUES (?,?,?,?,?,%d,%s)
ON CONFLICT(ip) DO UPDATE SET
reason=excluded.reason, attempts=excluded.attempts,
blocked_at=datetime('now'), is_permanent=%d, expires_at=%s`,
isPermanent, expiresExpr, isPermanent, expiresExpr,
), ip, reason, country, countryCode, attempts)
}
// UnblockIP removes a block entry.
func (d *DB) UnblockIP(ip string) error {
_, err := d.sql.Exec(`DELETE FROM ip_blocks WHERE ip=?`, ip)
return err
}
// ListIPBlocks returns all current (non-expired or permanent) blocked IPs.
func (d *DB) ListIPBlocks() ([]IPBlock, error) {
rows, err := d.sql.Query(`
SELECT id, ip, reason, country, country_code, attempts, blocked_at, expires_at, is_permanent
FROM ip_blocks
WHERE is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now')
ORDER BY blocked_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []IPBlock
for rows.Next() {
var b IPBlock
var expiresAt sql.NullTime
rows.Scan(&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent)
if expiresAt.Valid {
t := expiresAt.Time
b.ExpiresAt = &t
}
result = append(result, b)
}
return result, rows.Err()
}
// ListLoginAttemptStats returns per-IP attempt summaries for display.
func (d *DB) ListLoginAttemptStats(limitHours int) ([]LoginAttemptStat, error) {
rows, err := d.sql.Query(`
SELECT ip, country, country_code,
COUNT(*) as total,
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) as failures,
MAX(created_at) as last_seen
FROM login_attempts
WHERE created_at >= datetime('now', ? || ' hours')
GROUP BY ip ORDER BY failures DESC LIMIT 100`,
fmt.Sprintf("-%d", limitHours),
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []LoginAttemptStat
for rows.Next() {
var s LoginAttemptStat
rows.Scan(&s.IP, &s.Country, &s.CountryCode, &s.Total, &s.Failures, &s.LastSeen)
result = append(result, s)
}
return result, rows.Err()
}
// PurgeExpiredBlocks removes expired (non-permanent) blocks from the table.
func (d *DB) PurgeExpiredBlocks() {
d.sql.Exec(`DELETE FROM ip_blocks WHERE is_permanent=0 AND expires_at IS NOT NULL AND expires_at <= datetime('now')`)
}
// LookupIPCountry returns cached country info for an IP from recent login_attempts.
func (d *DB) LookupCachedCountry(ip string) (country, countryCode string) {
d.sql.QueryRow(`
SELECT country, country_code FROM login_attempts
WHERE ip=? AND country != '' ORDER BY created_at DESC LIMIT 1`, ip,
).Scan(&country, &countryCode)
return
}
// ---- Profile Updates ----
// UpdateUserEmail changes a user's email address. Returns error if already taken.
func (d *DB) UpdateUserEmail(userID int64, newEmail string) error {
_, err := d.sql.Exec(
`UPDATE users SET email=?, updated_at=datetime('now') WHERE id=?`,
newEmail, userID)
return err
}
// UpdateUserUsername changes a user's display username. Returns error if already taken.
func (d *DB) UpdateUserUsername(userID int64, newUsername string) error {
_, err := d.sql.Exec(
`UPDATE users SET username=?, updated_at=datetime('now') WHERE id=?`,
newUsername, userID)
return err
}
// ---- Per-User IP Rules ----
// UserIPRule holds per-user IP access settings.
type UserIPRule struct {
UserID int64 `json:"user_id"`
Mode string `json:"mode"` // "brute_skip" | "allow_only" | "disabled"
IPList string `json:"ip_list"` // comma-separated IPs
}
// GetUserIPRule returns the IP rule for a user, or nil if none set.
func (d *DB) GetUserIPRule(userID int64) (*UserIPRule, error) {
row := d.sql.QueryRow(`SELECT user_id, mode, ip_list FROM user_ip_rules WHERE user_id=?`, userID)
r := &UserIPRule{}
if err := row.Scan(&r.UserID, &r.Mode, &r.IPList); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return r, nil
}
// SetUserIPRule upserts the IP rule for a user.
func (d *DB) SetUserIPRule(userID int64, mode, ipList string) error {
_, err := d.sql.Exec(`
INSERT INTO user_ip_rules (user_id, mode, ip_list, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
mode=excluded.mode,
ip_list=excluded.ip_list,
updated_at=datetime('now')`,
userID, mode, ipList)
return err
}
// DeleteUserIPRule removes IP rules for a user (disables the feature).
func (d *DB) DeleteUserIPRule(userID int64) error {
_, err := d.sql.Exec(`DELETE FROM user_ip_rules WHERE user_id=?`, userID)
return err
}
// CheckUserIPAccess evaluates per-user IP rules against a connecting IP.
// Returns:
// "allow" — rule says allow (brute_skip match or allow_only match)
// "deny" — allow_only mode and IP is not in list
// "skip_brute" — brute_skip mode and IP is in list (skip brute force check)
// "default" — no rule exists, fall through to global rules
func (d *DB) CheckUserIPAccess(userID int64, ip string) string {
rule, err := d.GetUserIPRule(userID)
if err != nil || rule == nil || rule.Mode == "disabled" || rule.IPList == "" {
return "default"
}
for _, listed := range splitIPs(rule.IPList) {
if listed == ip {
if rule.Mode == "allow_only" {
return "allow"
}
return "skip_brute"
}
}
// IP not in list
if rule.Mode == "allow_only" {
return "deny"
}
return "default"
}
// SplitIPList splits a comma-separated IP string into trimmed, non-empty entries.
func SplitIPList(s string) []string {
return splitIPs(s)
}
func splitIPs(s string) []string {
var result []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// IPBlockWithUsername extends IPBlock with the last username attempted from that IP.
type IPBlockWithUsername struct {
IPBlock
LastUsername string
}
// ListIPBlocksWithUsername returns active blocks enriched with the most recent
// username that was attempted from each IP (from login_attempts history).
func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
rows, err := d.sql.Query(`
SELECT
b.id, b.ip, b.reason, b.country, b.country_code,
b.attempts, b.blocked_at, b.expires_at, b.is_permanent,
COALESCE(
(SELECT username FROM login_attempts
WHERE ip=b.ip AND username != ''
ORDER BY created_at DESC LIMIT 1),
''
) AS last_username
FROM ip_blocks b
WHERE b.is_permanent=1 OR b.expires_at IS NULL OR b.expires_at > datetime('now')
ORDER BY b.blocked_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []IPBlockWithUsername
for rows.Next() {
var b IPBlockWithUsername
var expiresAt sql.NullTime
err := rows.Scan(
&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent,
&b.LastUsername,
)
if err != nil {
return nil, err
}
if expiresAt.Valid {
t := expiresAt.Time
b.ExpiresAt = &t
}
result = append(result, b)
}
return result, rows.Err()
}
// ======== Contacts ========
func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.Contact
for rows.Next() {
var c models.Contact
var dn, em, ph, co, no, av []byte
rows.Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
out = append(out, &c)
}
return out, nil
}
func (d *DB) GetContact(id, userID int64) (*models.Contact, error) {
var c models.Contact
var dn, em, ph, co, no, av []byte
err := d.sql.QueryRow(`
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE id=? AND user_id=?`, id, userID).
Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
return &c, nil
}
func (d *DB) CreateContact(c *models.Contact) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
res, err := d.sql.Exec(`
INSERT INTO contacts (user_id, display_name, email, phone, company, notes, avatar_color)
VALUES (?,?,?,?,?,?,?)`, c.UserID, dn, em, ph, co, no, av)
if err != nil {
return err
}
c.ID, _ = res.LastInsertId()
return nil
}
func (d *DB) UpdateContact(c *models.Contact, userID int64) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
_, err := d.sql.Exec(`
UPDATE contacts SET display_name=?, email=?, phone=?, company=?, notes=?, avatar_color=?,
updated_at=datetime('now') WHERE id=? AND user_id=?`,
dn, em, ph, co, no, av, c.ID, userID)
return err
}
func (d *DB) DeleteContact(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) SearchContacts(userID int64, q string) ([]*models.Contact, error) {
all, err := d.ListContacts(userID)
if err != nil {
return nil, err
}
q = strings.ToLower(q)
var out []*models.Contact
for _, c := range all {
if strings.Contains(strings.ToLower(c.DisplayName), q) ||
strings.Contains(strings.ToLower(c.Email), q) ||
strings.Contains(strings.ToLower(c.Company), q) {
out = append(out, c)
}
}
return out, nil
}
// ======== Calendar Events ========
func (d *DB) ListCalendarEvents(userID int64, from, to string) ([]*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.user_id=? AND e.start_time >= ? AND e.start_time <= ?
ORDER BY e.start_time`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCalendarEvents(d, rows)
}
func (d *DB) GetCalendarEvent(id, userID int64) (*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.id=? AND e.user_id=?`, id, userID)
if err != nil {
return nil, err
}
defer rows.Close()
evs, err := scanCalendarEvents(d, rows)
if err != nil || len(evs) == 0 {
return nil, err
}
return evs[0], nil
}
func scanCalendarEvents(d *DB, rows interface{ Next() bool; Scan(...interface{}) error }) ([]*models.CalendarEvent, error) {
var out []*models.CalendarEvent
for rows.Next() {
var e models.CalendarEvent
var accountID *int64
var ti, de, lo, rc, co, st, oe, at []byte
err := rows.Scan(
&e.ID, &e.UserID, &accountID, &e.UID,
&ti, &de, &lo,
&e.StartTime, &e.EndTime, &e.AllDay, &rc, &co,
&st, &oe, &at,
&e.AccountColor, &e.AccountEmail,
)
if err != nil {
return nil, err
}
e.AccountID = accountID
e.Title, _ = d.enc.Decrypt(string(ti))
e.Description, _ = d.enc.Decrypt(string(de))
e.Location, _ = d.enc.Decrypt(string(lo))
e.RecurrenceRule, _ = d.enc.Decrypt(string(rc))
e.Color, _ = d.enc.Decrypt(string(co))
e.Status, _ = d.enc.Decrypt(string(st))
e.OrganizerEmail, _ = d.enc.Decrypt(string(oe))
e.Attendees, _ = d.enc.Decrypt(string(at))
if e.Color == "" && e.AccountColor != "" {
e.Color = e.AccountColor
}
out = append(out, &e)
}
return out, nil
}
func (d *DB) UpsertCalendarEvent(e *models.CalendarEvent) error {
ti, _ := d.enc.Encrypt(e.Title)
de, _ := d.enc.Encrypt(e.Description)
lo, _ := d.enc.Encrypt(e.Location)
rc, _ := d.enc.Encrypt(e.RecurrenceRule)
co, _ := d.enc.Encrypt(e.Color)
st, _ := d.enc.Encrypt(e.Status)
oe, _ := d.enc.Encrypt(e.OrganizerEmail)
at, _ := d.enc.Encrypt(e.Attendees)
allDay := 0
if e.AllDay {
allDay = 1
}
if e.UID == "" {
e.UID = fmt.Sprintf("gwm-%d-%d", e.UserID, time.Now().UnixNano())
}
res, err := d.sql.Exec(`
INSERT INTO calendar_events
(user_id, account_id, uid, title, description, location,
start_time, end_time, all_day, recurrence_rule, color,
status, organizer_email, attendees)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, uid) DO UPDATE SET
title=excluded.title, description=excluded.description,
location=excluded.location, start_time=excluded.start_time,
end_time=excluded.end_time, all_day=excluded.all_day,
recurrence_rule=excluded.recurrence_rule, color=excluded.color,
status=excluded.status, organizer_email=excluded.organizer_email,
attendees=excluded.attendees,
updated_at=datetime('now')`,
e.UserID, e.AccountID, e.UID, ti, de, lo,
e.StartTime, e.EndTime, allDay, rc, co, st, oe, at)
if err != nil {
return err
}
if e.ID == 0 {
e.ID, _ = res.LastInsertId()
}
return nil
}
func (d *DB) DeleteCalendarEvent(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM calendar_events WHERE id=? AND user_id=?`, id, userID)
return err
}
// ======== CalDAV Tokens ========
func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
token := base64.URLEncoding.EncodeToString(raw)
_, err := d.sql.Exec(`INSERT INTO caldav_tokens (user_id, token, label) VALUES (?,?,?)`,
userID, token, label)
if err != nil {
return nil, err
}
return &models.CalDAVToken{UserID: userID, Token: token, Label: label}, nil
}
func (d *DB) ListCalDAVTokens(userID int64) ([]*models.CalDAVToken, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, token, label, created_at, COALESCE(last_used,'')
FROM caldav_tokens WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.CalDAVToken
for rows.Next() {
var t models.CalDAVToken
rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Label, &t.CreatedAt, &t.LastUsed)
out = append(out, &t)
}
return out, nil
}
func (d *DB) DeleteCalDAVToken(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM caldav_tokens WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) GetUserByCalDAVToken(token string) (int64, error) {
var userID int64
err := d.sql.QueryRow(`SELECT user_id FROM caldav_tokens WHERE token=?`, token).Scan(&userID)
if err != nil {
return 0, err
}
d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token)
return userID, nil
}

View File

@@ -6,6 +6,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -18,6 +19,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap/client"
@@ -54,7 +56,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token) payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
return "XOAUTH2", []byte(payload), nil 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 } type xoauth2SMTP struct{ user, token string }
@@ -80,6 +99,9 @@ type Client struct {
} }
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) { 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) host, port := imapHostFor(account.Provider)
if account.IMAPHost != "" { if account.IMAPHost != "" {
host = account.IMAPHost host = account.IMAPHost
@@ -108,6 +130,33 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
switch account.Provider { switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook: 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} sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
if err := c.Authenticate(sasl); err != nil { if err := c.Authenticate(sasl); err != nil {
c.Logout() c.Logout()
@@ -852,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
rawMsg := buf.Bytes() rawMsg := buf.Bytes()
addr := fmt.Sprintf("%s:%d", host, port) 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)
var c *smtp.Client var c *smtp.Client
var err error var err error
@@ -893,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
if err := authSMTP(c, account, host); err != nil { if err := authSMTP(c, account, host); err != nil {
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err) 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 { if err := c.Mail(account.EmailAddress); err != nil {
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err) return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
@@ -923,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
// DATA close is where the server accepts or rejects the message // DATA close is where the server accepts or rejects the message
return fmt.Errorf("SMTP server rejected message: %w", err) 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() _ = c.Quit()
// Append to Sent folder via IMAP (best-effort, don't fail the send) // Append to Sent folder via IMAP (best-effort, don't fail the send)

97
internal/geo/geo.go Normal file
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
}

426
internal/graph/graph.go Normal file
View File

@@ -0,0 +1,426 @@
// 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 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
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -225,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
"changed": changed, "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})
}

View File

@@ -13,8 +13,10 @@ import (
"time" "time"
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email" "github.com/ghostersk/gowebmail/internal/email"
graphpkg "github.com/ghostersk/gowebmail/internal/graph"
"github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/syncer" "github.com/ghostersk/gowebmail/internal/syncer"
@@ -46,6 +48,7 @@ func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{ h.writeJSON(w, map[string]bool{
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "", "gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "", "outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
}) })
} }
@@ -60,9 +63,13 @@ type safeAccount struct {
IMAPPort int `json:"imap_port,omitempty"` IMAPPort int `json:"imap_port,omitempty"`
SMTPHost string `json:"smtp_host,omitempty"` SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"` SMTPPort int `json:"smtp_port,omitempty"`
SyncDays int `json:"sync_days"`
SyncMode string `json:"sync_mode"`
SortOrder int `json:"sort_order"`
LastError string `json:"last_error,omitempty"` LastError string `json:"last_error,omitempty"`
Color string `json:"color"` Color string `json:"color"`
LastSync string `json:"last_sync"` LastSync string `json:"last_sync"`
TokenExpired bool `json:"token_expired,omitempty"`
} }
func toSafeAccount(a *models.EmailAccount) safeAccount { func toSafeAccount(a *models.EmailAccount) safeAccount {
@@ -70,11 +77,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
if !a.LastSync.IsZero() { if !a.LastSync.IsZero() {
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z") lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
} }
tokenExpired := false
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook || a.Provider == models.ProviderOutlookPersonal) && auth.IsTokenExpired(a.TokenExpiry) {
tokenExpired = true
}
return safeAccount{ return safeAccount{
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress, ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort, DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort, SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
LastError: a.LastError, Color: a.Color, LastSync: lastSync, LastError: a.LastError, Color: a.Color, LastSync: lastSync,
TokenExpired: tokenExpired,
} }
} }
@@ -476,6 +489,52 @@ func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true}) h.writeJSON(w, map[string]bool{"ok": true})
} }
func (h *APIHandler) SetAccountSortOrder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Order []int64 `json:"order"` // account IDs in desired display order
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Order) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.UpdateAccountSortOrder(userID, req.Order); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save order")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) GetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
prefs, err := h.db.GetUIPrefs(userID)
if err != nil {
prefs = "{}"
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(prefs))
}
func (h *APIHandler) SetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil || len(body) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// Validate it's valid JSON before storing
var check map[string]interface{}
if err := json.Unmarshal(body, &check); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.db.SetUIPrefs(userID, string(body)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Messages ---- // ---- Messages ----
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) { func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
@@ -534,6 +593,22 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
} }
h.db.MarkMessageRead(messageID, userID, true) h.db.MarkMessageRead(messageID, userID, true)
// For Graph accounts: fetch body lazily on open (not stored during list sync)
if msg.BodyHTML == "" && msg.BodyText == "" {
if graphMsgID, _, account, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil &&
account != nil && account.Provider == models.ProviderOutlookPersonal {
if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), graphMsgID); gErr == nil {
if gMsg.Body.ContentType == "html" {
msg.BodyHTML = gMsg.Body.Content
} else {
msg.BodyText = gMsg.Body.Content
}
// Persist so next open is instant
h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML)
}
}
}
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table // Lazy attachment backfill: if has_attachment=true but no rows in attachments table
// (message was synced before attachment parsing was added), fetch from IMAP now and save. // (message was synced before attachment parsing was added), fetch from IMAP now and save.
if msg.HasAttachment && len(msg.Attachments) == 0 { if msg.HasAttachment && len(msg.Attachments) == 0 {
@@ -563,22 +638,22 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
Read bool `json:"read"` Read bool `json:"read"`
} }
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
// Update local DB first
h.db.MarkMessageRead(messageID, userID, req.Read) h.db.MarkMessageRead(messageID, userID, req.Read)
// Enqueue IMAP op — drained by background worker with retry if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID) account.Provider == models.ProviderOutlookPersonal {
if err == nil && uid != 0 && account != nil { go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read)
} else {
uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID)
if err2 == nil && uid != 0 && acc != nil {
val := "0" val := "0"
if req.Read { if req.Read { val = "1" }
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_read", AccountID: acc.ID, OpType: "flag_read",
RemoteUID: uid, FolderPath: folderPath, Extra: val, RemoteUID: uid, FolderPath: folderPath, Extra: val,
}) })
h.syncer.TriggerAccountSync(account.ID) h.syncer.TriggerAccountSync(acc.ID)
}
} }
h.writeJSON(w, map[string]bool{"ok": true}) h.writeJSON(w, map[string]bool{"ok": true})
} }
@@ -591,17 +666,20 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
h.writeError(w, http.StatusInternalServerError, "failed to toggle star") h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
return return
} }
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID) if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil &&
if ierr == nil && uid != 0 && account != nil { account.Provider == models.ProviderOutlookPersonal {
go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred)
} else {
uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
if ierr == nil && uid != 0 && acc != nil {
val := "0" val := "0"
if starred { if starred { val = "1" }
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_star", AccountID: acc.ID, OpType: "flag_star",
RemoteUID: uid, FolderPath: folderPath, Extra: val, RemoteUID: uid, FolderPath: folderPath, Extra: val,
}) })
h.syncer.TriggerAccountSync(account.ID) h.syncer.TriggerAccountSync(acc.ID)
}
} }
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred}) h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
} }
@@ -627,8 +705,11 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
return return
} }
// Enqueue IMAP move // Route to Graph or IMAP
if imapErr == nil && uid != 0 && account != nil && destFolder != nil { if graphMsgID, _, graphAcc, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && graphAcc != nil &&
graphAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil {
go graphpkg.New(graphAcc).MoveMessage(context.Background(), graphMsgID, destFolder.FullPath)
} else if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "move", AccountID: account.ID, OpType: "move",
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath, RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
@@ -642,17 +723,18 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r) userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id") messageID := pathInt64(r, "id")
// Get IMAP info before deleting from DB // Get message info before deleting from DB
graphMsgID, _, graphAcc, graphErr := h.db.GetMessageGraphInfo(messageID, userID)
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID) uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
// Delete from local DB
if err := h.db.DeleteMessage(messageID, userID); err != nil { if err := h.db.DeleteMessage(messageID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed") h.writeError(w, http.StatusInternalServerError, "delete failed")
return return
} }
// Enqueue IMAP delete if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal {
if imapErr == nil && uid != 0 && account != nil { go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID)
} else if imapErr == nil && uid != 0 && account != nil {
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "delete", AccountID: account.ID, OpType: "delete",
RemoteUID: uid, FolderPath: folderPath, RemoteUID: uid, FolderPath: folderPath,
@@ -753,6 +835,24 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
} }
} }
account = h.ensureAccountTokenFresh(account)
// Graph accounts (personal outlook.com) send via Graph API, not SMTP
if account.Provider == models.ProviderOutlookPersonal {
if err := graphpkg.New(account).SendMail(context.Background(), &req); err != nil {
log.Printf("Graph send failed account=%d user=%d: %v", req.AccountID, userID, err)
h.writeError(w, http.StatusBadGateway, err.Error())
return
}
// Delay slightly so Microsoft has time to save to Sent Items before we sync
go func() {
time.Sleep(3 * time.Second)
h.syncer.TriggerAccountSync(account.ID)
}()
h.writeJSON(w, map[string]bool{"ok": true})
return
}
if err := email.SendMessageFull(context.Background(), account, &req); err != nil { if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err) log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
h.db.WriteAudit(&userID, models.AuditAppError, h.db.WriteAudit(&userID, models.AuditAppError,
@@ -761,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
h.writeError(w, http.StatusBadGateway, err.Error()) h.writeError(w, http.StatusBadGateway, err.Error())
return return
} }
// Trigger immediate sync so the sent message appears in Sent Items
h.syncer.TriggerAccountSync(account.ID)
h.writeJSON(w, map[string]bool{"ok": true}) h.writeJSON(w, map[string]bool{"ok": true})
} }
@@ -1333,3 +1435,44 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true}) h.writeJSON(w, map[string]bool{"ok": true})
} }
// ensureAccountTokenFresh refreshes the OAuth access token for a Gmail/Outlook
// account if it is near expiry. Returns a pointer to the (possibly updated)
// account, or the original if no refresh was needed / possible.
func (h *APIHandler) ensureAccountTokenFresh(account *models.EmailAccount) *models.EmailAccount {
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
return account
}
if !auth.IsTokenExpired(account.TokenExpiry) {
return account
}
if account.RefreshToken == "" {
log.Printf("[oauth:%s] token expired, no refresh token stored", 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,
h.cfg.BaseURL,
h.cfg.GoogleClientID, h.cfg.GoogleClientSecret,
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, h.cfg.MicrosoftTenantID,
)
if err != nil {
log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
return account
}
if err := h.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
return account
}
refreshed, err := h.db.GetAccount(account.ID)
if err != nil || refreshed == nil {
return account
}
log.Printf("[oauth:%s] access token refreshed for send (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
return refreshed
}

View File

@@ -17,3 +17,13 @@ type AppHandler struct {
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) { func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil) 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)
}

View File

@@ -4,9 +4,15 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"html"
"log"
"net"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth" goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto" "github.com/ghostersk/gowebmail/internal/crypto"
@@ -23,6 +29,7 @@ type AuthHandler struct {
db *db.DB db *db.DB
cfg *config.Config cfg *config.Config
renderer *Renderer renderer *Renderer
syncer interface{ TriggerReconcile() }
} }
// ---- Login ---- // ---- Login ----
@@ -53,6 +60,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return 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 { if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
uid := user.ID uid := user.ID
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua) h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
@@ -299,12 +317,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true, 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) http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return return
} }
uid := userID 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) http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
} }
@@ -319,13 +345,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
state := encodeOAuthState(userID, "outlook") state := encodeOAuthState(userID, "outlook")
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL) 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) http.Redirect(w, r, url, http.StatusFound)
} }
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code") 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) userID, provider := decodeOAuthState(state)
if userID == 0 || provider != "outlook" { if userID == 0 || provider != "outlook" {
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound) http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
@@ -335,29 +399,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL) h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
token, err := oauthCfg.Exchange(r.Context(), code) token, err := oauthCfg.Exchange(r.Context(), code)
if err != nil { 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 return
} }
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg) userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
if err != nil { if err != nil {
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound) http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return 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) accounts, _ := h.db.ListAccountsByUser(userID)
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"} colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
color := colors[len(accounts)%len(colors)] color := colors[len(accounts)%len(colors)]
account := &models.EmailAccount{ account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderOutlook, UserID: userID, Provider: models.ProviderOutlook,
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName, EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true, 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) http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return return
} }
uid := userID 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) http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
} }
@@ -403,3 +518,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg}) 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)
}

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")
}

View File

@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
} }
return &Handlers{ return &Handlers{
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer}, Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
App: &AppHandler{db: database, cfg: cfg, renderer: renderer}, App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
API: &APIHandler{db: database, cfg: cfg, syncer: sc}, API: &APIHandler{db: database, cfg: cfg, syncer: sc},
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer}, Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},

View File

@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
"login.html", "login.html",
"mfa.html", "mfa.html",
"admin.html", "admin.html",
"message.html",
"compose.html",
} }
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates") templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
if err != nil { if err != nil {

24
internal/logger/logger.go Normal file
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 }

View File

@@ -3,6 +3,8 @@ package middleware
import ( import (
"context" "context"
"fmt"
"html/template"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -11,7 +13,9 @@ import (
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/notify"
) )
type contextKey string type contextKey string
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
role, _ := r.Context().Value(UserRoleKey).(models.UserRole) role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
if role != models.RoleAdmin { if role != models.RoleAdmin {
if isAPIPath(r) { 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 { } 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 return
} }
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
} }
return r.RemoteAddr 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>`))

View File

@@ -85,6 +85,7 @@ type AccountProvider string
const ( const (
ProviderGmail AccountProvider = "gmail" ProviderGmail AccountProvider = "gmail"
ProviderOutlook AccountProvider = "outlook" ProviderOutlook AccountProvider = "outlook"
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
ProviderIMAPSMTP AccountProvider = "imap_smtp" ProviderIMAPSMTP AccountProvider = "imap_smtp"
) )
@@ -113,6 +114,7 @@ type EmailAccount struct {
// Display // Display
Color string `json:"color"` Color string `json:"color"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
LastSync time.Time `json:"last_sync"` LastSync time.Time `json:"last_sync"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -241,3 +243,49 @@ type PagedMessages struct {
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
// ---- Contacts ----
type Contact struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
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"`
}

201
internal/notify/notify.go Normal file
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()
}

View File

@@ -9,31 +9,51 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strings"
"sync" "sync"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email" "github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/graph"
"github.com/ghostersk/gowebmail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
) )
// Scheduler coordinates all background sync activity. // Scheduler coordinates all background sync activity.
type Scheduler struct { type Scheduler struct {
db *db.DB db *db.DB
cfg *config.Config
stop chan struct{} stop chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
// push channels: accountID -> channel to signal "something changed on server" // push channels: accountID -> channel to signal "something changed on server"
pushMu sync.Mutex pushMu sync.Mutex
pushCh map[int64]chan struct{} 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. // New creates a new Scheduler.
func New(database *db.DB) *Scheduler { func New(database *db.DB, cfg *config.Config) *Scheduler {
return &Scheduler{ return &Scheduler{
db: database, db: database,
cfg: cfg,
stop: make(chan struct{}), stop: make(chan struct{}),
pushCh: make(map[int64]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 +143,13 @@ func (s *Scheduler) mainLoop() {
stopWorker(id) stopWorker(id)
} }
return 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: case <-ticker.C:
// Build active IDs map for reconciliation // Build active IDs map for reconciliation
activeIDs := make(map[int64]bool, len(workers)) activeIDs := make(map[int64]bool, len(workers))
@@ -187,6 +214,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
return a return a
} }
// Graph-based accounts (personal outlook.com) use a different sync path
if account.Provider == models.ProviderOutlookPersonal {
s.graphWorker(account, stop, push)
return
}
// Initial sync on startup // Initial sync on startup
s.drainPendingOps(account) s.drainPendingOps(account)
s.deltaSync(getAccount()) s.deltaSync(getAccount())
@@ -258,6 +291,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account) c, err := email.Connect(ctx, account)
cancel() cancel()
if err != nil { if err != nil {
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account) c, err := email.Connect(ctx, account)
if err != nil { if err != nil {
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err) log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
@@ -349,7 +384,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
mailboxes, err := c.ListMailboxes() mailboxes, err := c.ListMailboxes()
if err != nil { 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) log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, errMsg)
return return
} }
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
s.db.UpdateAccountLastSync(account.ID) s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 { 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 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account) c, err := email.Connect(ctx, account)
if err != nil { if err != nil {
return return
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
return return
} }
if n > 0 { 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)
} }
} }
@@ -488,6 +537,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
// Applies queued IMAP write operations (delete/move/flag) with retry logic. // Applies queued IMAP write operations (delete/move/flag) with retry logic.
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
// Graph accounts don't use the IMAP ops queue
if account.Provider == models.ProviderOutlookPersonal {
return
}
ops, err := s.db.DequeuePendingOps(account.ID, 50) ops, err := s.db.DequeuePendingOps(account.ID, 50)
if err != nil || len(ops) == 0 { if err != nil || len(ops) == 0 {
return return
@@ -496,6 +549,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account) c, err := email.Connect(ctx, account)
if err != nil { if err != nil {
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err) log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
@@ -540,6 +594,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) ---- // ---- Public API (called by HTTP handlers) ----
// SyncAccountNow performs an immediate delta sync of one account. // SyncAccountNow performs an immediate delta sync of one account.
@@ -564,8 +674,45 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return 0, fmt.Errorf("folder %d not found", folderID) 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() 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
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account) c, err := email.Connect(ctx, account)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -574,3 +721,129 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return s.syncFolder(c, account, folder) 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
}
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++
}
}
// 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)
}
}

View File

@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px; .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} font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px; .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} color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
cursor:pointer;user-select:none;border-radius:6px;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:10px 14px;border-top:1px solid var(--border);display:flex; .sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
align-items:center;justify-content:space-between;flex-shrink:0} align-items:center;justify-content:space-between;flex-shrink:0}
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0} .user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
@@ -498,3 +506,115 @@ body.admin-page{overflow:auto;background:var(--bg)}
from{opacity:0;transform:translateY(16px) scale(.96)} from{opacity:0;transform:translateY(16px) scale(.96)}
to{opacity:1;transform:translateY(0) scale(1)} 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}
/* 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)}

View File

@@ -4,6 +4,7 @@ const adminRoutes = {
'/admin': renderUsers, '/admin': renderUsers,
'/admin/settings': renderSettings, '/admin/settings': renderSettings,
'/admin/audit': renderAudit, '/admin/audit': renderAudit,
'/admin/security': renderSecurity,
}; };
function navigate(path) { function navigate(path) {
@@ -202,6 +203,34 @@ const SETTINGS_META = [
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' }, { 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() { async function renderSettings() {
@@ -329,3 +358,134 @@ function eventBadge(evt) {
}); });
}); });
})(); })();
// ============================================================
// 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">
<div class="modal" style="max-width:420px">
<h2>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;');
}

View File

@@ -9,12 +9,27 @@ const S = {
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null, searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false, filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc' sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
}; };
// ── UI Preferences (server-persisted, cross-device) ─────────────────────────
let _uiPrefsSaveTimer = null;
function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; }
function uiPrefsSet(key, val) {
S.uiPrefs[key] = val;
clearTimeout(_uiPrefsSaveTimer);
_uiPrefsSaveTimer = setTimeout(() => {
api('PUT', '/ui-prefs', S.uiPrefs);
}, 600); // debounce 600ms
}
function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); }
function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); }
// ── Boot ─────────────────────────────────────────────────────────────────── // ── Boot ───────────────────────────────────────────────────────────────────
async function init() { async function init() {
const [me, providers, wl] = await Promise.all([ const [me, providers, wl, uiPrefsRaw] = await Promise.all([
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'), api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
api('GET','/ui-prefs'),
]); ]);
if (me) { if (me) {
S.me = me; S.me = me;
@@ -23,6 +38,7 @@ async function init() {
} }
if (providers) { S.providers = providers; updateProviderButtons(); } if (providers) { S.providers = providers; updateProviderButtons(); }
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist); if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
await loadAccounts(); await loadAccounts();
await loadFolders(); await loadFolders();
@@ -33,9 +49,47 @@ async function init() {
} }
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); } if (p.get('connected')) {
toast('Account connected! Loading…', 'success');
history.replaceState({},'','/');
// Reload accounts immediately — new account may already be in DB
await loadAccounts();
await loadFolders();
// Poll for folder population (syncer takes a moment after account creation)
let tries = 0;
const poll = setInterval(async () => {
tries++;
await loadAccounts();
await loadFolders();
// Stop when at least one account now has folders, or after ~30s
const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id));
if (hasFolders || tries >= 12) {
clearInterval(poll);
toast('Account ready!', 'success');
}
}, 2500);
}
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); } if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
// Handle actions from full-page message/compose views
if (p.get('action') === 'reply' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
// Load the message then open reply
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openReplyTo(id); }
}, 500);
}
if (p.get('action') === 'forward' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openForward(); }
}, 500);
}
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return; if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.target.contentEditable === 'true') return; if (e.target.contentEditable === 'true') return;
@@ -45,11 +99,12 @@ async function init() {
initComposeDragResize(); initComposeDragResize();
startPoller(); startPoller();
mobSetView('list'); // initialise mobile view state
} }
// ── Providers ────────────────────────────────────────────────────────────── // ── Providers ──────────────────────────────────────────────────────────────
function updateProviderButtons() { function updateProviderButtons() {
['gmail','outlook'].forEach(p => { ['gmail','outlook','outlook_personal'].forEach(p => {
const btn = document.getElementById('btn-'+p); const btn = document.getElementById('btn-'+p);
if (!btn) return; if (!btn) return;
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; } if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
@@ -79,12 +134,16 @@ function renderAccountsPopup() {
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>'; el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
return; return;
} }
el.innerHTML = S.accounts.map(a => ` el.innerHTML = S.accounts.map(a => {
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}"> const hasWarning = a.last_error || a.token_expired;
const warningTitle = a.token_expired ? 'OAuth token expired — click Settings to reconnect' : (a.last_error ? '⚠ '+a.last_error : '');
return `
<div class="acct-popup-item" title="${esc(a.email_address)}${hasWarning?' — '+warningTitle:''}">
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0"> <div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span> <span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span> <span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''} ${a.token_expired?'<span style="color:var(--danger);font-size:11px" title="OAuth token expired">🔑</span>':
a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
</div> </div>
<div style="display:flex;gap:4px;flex-shrink:0"> <div style="display:flex;gap:4px;flex-shrink:0">
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)"> <button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
@@ -97,7 +156,8 @@ function renderAccountsPopup() {
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button> </button>
</div> </div>
</div>`).join(''); </div>`;
}).join('');
} }
// ── Accounts ─────────────────────────────────────────────────────────────── // ── Accounts ───────────────────────────────────────────────────────────────
@@ -109,7 +169,13 @@ async function loadAccounts() {
populateComposeFrom(); populateComposeFrom();
} }
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; } function connectOAuth(p) {
if (p === 'outlook_personal') {
location.href = '/auth/outlook-personal/connect';
} else {
location.href = '/auth/' + p + '/connect';
}
}
function openAddAccountModal() { function openAddAccountModal() {
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); ['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
@@ -184,13 +250,38 @@ async function openEditAccount(id) {
document.getElementById('edit-account-id').value=id; document.getElementById('edit-account-id').value=id;
document.getElementById('edit-account-email').textContent=r.email_address; document.getElementById('edit-account-email').textContent=r.email_address;
document.getElementById('edit-name').value=r.display_name||''; document.getElementById('edit-name').value=r.display_name||'';
const isOAuth = r.provider==='gmail' || r.provider==='outlook' || r.provider==='outlook_personal';
// Show/hide credential section and test button based on provider type
document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : '';
document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : '';
const oauthSection = document.getElementById('edit-oauth-section');
if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none';
if (isOAuth) {
const providerLabel = r.provider==='gmail' ? 'Google' : r.provider==='outlook_personal' ? 'Microsoft (Personal)' : 'Microsoft';
const lbl = document.getElementById('edit-oauth-provider-label');
const lblBtn = document.getElementById('edit-oauth-provider-label-btn');
const expWarn = document.getElementById('edit-oauth-expired-warning');
if (lbl) lbl.textContent = providerLabel;
if (lblBtn) lblBtn.textContent = providerLabel;
if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none';
const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn');
if (reconnectBtn) reconnectBtn.onclick = () => {
closeModal('edit-account-modal');
connectOAuth(r.provider);
};
}
if (!isOAuth) {
document.getElementById('edit-password').value=''; document.getElementById('edit-password').value='';
document.getElementById('edit-imap-host').value=r.imap_host||''; document.getElementById('edit-imap-host').value=r.imap_host||'';
document.getElementById('edit-imap-port').value=r.imap_port||993; document.getElementById('edit-imap-port').value=r.imap_port||993;
document.getElementById('edit-smtp-host').value=r.smtp_host||''; document.getElementById('edit-smtp-host').value=r.smtp_host||'';
document.getElementById('edit-smtp-port').value=r.smtp_port||587; document.getElementById('edit-smtp-port').value=r.smtp_port||587;
}
document.getElementById('edit-sync-days').value=r.sync_days||30; document.getElementById('edit-sync-days').value=r.sync_days||30;
// Restore sync mode select: map stored days/mode back to a preset option
const sel = document.getElementById('edit-sync-mode'); const sel = document.getElementById('edit-sync-mode');
if (r.sync_mode==='all' || !r.sync_days) { if (r.sync_mode==='all' || !r.sync_days) {
sel.value='all'; sel.value='all';
@@ -199,12 +290,12 @@ async function openEditAccount(id) {
sel.value = presetMap[r.sync_days] || 'days'; sel.value = presetMap[r.sync_days] || 'days';
} }
toggleSyncDaysField(); toggleSyncDaysField();
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result'); const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
connEl.style.display='none'; connEl.style.display='none';
errEl.style.display=r.last_error?'block':'none'; errEl.style.display=r.last_error?'block':'none';
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error; if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
// Load hidden folders for this account
const hiddenEl = document.getElementById('edit-hidden-folders'); const hiddenEl = document.getElementById('edit-hidden-folders');
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden); const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
if (!hidden.length) { if (!hidden.length) {
@@ -249,6 +340,10 @@ function toggleSyncDaysField() {
} }
async function testEditConnection() { async function testEditConnection() {
// Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button
if (document.getElementById('edit-creds-section').style.display === 'none') {
return;
}
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result'); const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim(); const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;} if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
@@ -263,11 +358,16 @@ async function testEditConnection() {
async function saveAccountEdit() { async function saveAccountEdit() {
const id=document.getElementById('edit-account-id').value; const id=document.getElementById('edit-account-id').value;
const body={display_name:document.getElementById('edit-name').value.trim(), const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993, const body={display_name:document.getElementById('edit-name').value.trim()};
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587}; if (!isOAuth) {
body.imap_host=document.getElementById('edit-imap-host').value.trim();
body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993;
body.smtp_host=document.getElementById('edit-smtp-host').value.trim();
body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587;
const pw=document.getElementById('edit-password').value; const pw=document.getElementById('edit-password').value;
if (pw) body.password=pw; if (pw) body.password=pw;
}
const modeVal = document.getElementById('edit-sync-mode').value; const modeVal = document.getElementById('edit-sync-mode').value;
let syncMode='all', syncDays=0; let syncMode='all', syncDays=0;
if (modeVal==='days') { if (modeVal==='days') {
@@ -334,29 +434,132 @@ function renderFolders() {
const el = document.getElementById('folders-by-account'); const el = document.getElementById('folders-by-account');
const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a); const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a);
const byAcc = {}; const byAcc = {};
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);}); S.folders.filter(f => !f.is_hidden).forEach(f => {
(byAcc[f.account_id] = byAcc[f.account_id] || []).push(f);
});
const prio = ['inbox','sent','drafts','trash','spam','archive']; const prio = ['inbox','sent','drafts','trash','spam','archive'];
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{ const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
const acc=accMap[parseInt(accId)];
const accColor = acc?.color || '#888'; el.innerHTML = orderedAccounts.map(acc => {
const accEmail = acc?.email_address || 'Account '+accId; const folders = byAcc[acc.id];
if(!folders?.length) return ''; // Show account even if no folders yet — it was just added and syncer hasn't run
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')]; if (!folders?.length) {
return `<div class="nav-folder-header"> const statusHtml = acc.last_error
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span> ? `<div style="padding:6px 10px 8px;font-size:11px;color:var(--danger);background:rgba(239,68,68,.08);border-radius:0 0 6px 6px;line-height:1.4">
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span> ${esc(acc.last_error)}
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px"> </div>`
: `<div style="padding:6px 12px 8px;font-size:11px;color:var(--muted)">⏳ Syncing folders…</div>`;
return `<div class="nav-account-group" data-acc-id="${acc.id}">
<div class="nav-folder-header" style="cursor:default">
<span class="acc-drag-handle">&#8942;</span>
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
<button class="icon-sync-btn" title="Retry sync" onclick="syncNow(${acc.id},event)" style="flex-shrink:0">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
</button> </button>
</div>`+sorted.map(f=>` </div>
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')" ${statusHtml}
</div>`;
}
const accId = acc.id;
const collapsed = isAccountCollapsed(accId);
const sorted = [
...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean),
...folders.filter(f => f.folder_type==='custom')
];
const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0);
const chevron = collapsed
? '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>';
const folderRows = collapsed ? '' : sorted.map(f => `
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}"
data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu="showFolderMenu(event,${f.id})"> oncontextmenu="showFolderMenu(event,${f.id})">
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg> <svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
${esc(f.name)} ${esc(f.name)}
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''} ${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled"></span>':''} ${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">\u29b8</span>':''}
</div>`).join(''); </div>`).join('');
return `<div class="nav-account-group" data-acc-id="${accId}"
draggable="true"
ondragstart="accDragStart(event,${accId})"
ondragover="accDragOver(event)"
ondragleave="accDragLeave(event)"
ondrop="accDrop(event,${accId})">
<div class="nav-folder-header" onclick="toggleAccountCollapse(${accId})">
<span class="acc-drag-handle" title="Drag to reorder" onclick="event.stopPropagation()">&#8942;</span>
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
${totalUnread>0&&collapsed?`<span class="unread-badge" style="margin-left:auto">${totalUnread}</span>`:''}
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${accId},event)" style="flex-shrink:0">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
</button>
<span class="acc-chevron">${chevron}</span>
</div>
${folderRows}
</div>`;
}).join(''); }).join('');
// Re-wire drag-drop onto folder rows for message-to-folder moves
el.querySelectorAll('.nav-item[data-fid]').forEach(item => {
item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); };
item.ondragleave = () => item.classList.remove('drag-over');
item.ondrop = e => {
e.preventDefault(); item.classList.remove('drag-over');
const fid = parseInt(item.dataset.fid);
const mid = parseInt(e.dataTransfer.getData('text/plain'));
if (mid && fid) moveMessage(mid, fid);
};
});
}
function toggleAccountCollapse(accId) {
setAccountCollapsed(accId, !isAccountCollapsed(accId));
renderFolders();
}
// ── Account drag-to-reorder ─────────────────────────────────────────────────
let _dragSrcAccId = null;
function accDragStart(e, accId) {
_dragSrcAccId = accId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(accId));
setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0);
}
function accDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const g = e.currentTarget;
if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target');
}
function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); }
async function accDrop(e, targetAccId) {
e.preventDefault();
e.currentTarget?.classList.remove('acc-drag-target');
document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging'));
if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; }
const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0));
const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId);
const dstIdx = ordered.findIndex(a => a.id === targetAccId);
if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; }
const [moved] = ordered.splice(srcIdx, 1);
ordered.splice(dstIdx, 0, moved);
ordered.forEach((a, i) => { a.sort_order = i; });
S.accounts = ordered;
_dragSrcAccId = null;
renderFolders();
await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) });
} }
function showFolderMenu(e, folderId) { function showFolderMenu(e, folderId) {
@@ -512,6 +715,8 @@ function selectFolder(folderId, folderName) {
:folderId==='starred'?document.getElementById('nav-starred') :folderId==='starred'?document.getElementById('nav-starred')
:document.getElementById('nav-f'+folderId); :document.getElementById('nav-f'+folderId);
if (navEl) navEl.classList.add('active'); if (navEl) navEl.classList.add('active');
mobCloseNav();
mobSetView('list');
loadMessages(); loadMessages();
} }
@@ -708,6 +913,7 @@ function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
async function openMessage(id) { async function openMessage(id) {
S.selectedMessageId=id; renderMessageList(); S.selectedMessageId=id; renderMessageList();
mobSetView('detail');
const detail=document.getElementById('message-detail'); const detail=document.getElementById('message-detail');
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>'; detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
const msg=await api('GET','/messages/'+id); const msg=await api('GET','/messages/'+id);
@@ -941,6 +1147,8 @@ function showMessageMenu(e, id) {
<div class="ctx-submenu">${moveItems}</div> <div class="ctx-submenu">${moveItems}</div>
</div>` : ''; </div>` : '';
showCtxMenu(e,` showCtxMenu(e,`
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()">↗ Open in new tab</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div> <div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div> <div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div> <div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
@@ -998,10 +1206,17 @@ function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)retur
// ── Compose ──────────────────────────────────────────────────────────────── // ── Compose ────────────────────────────────────────────────────────────────
let composeAttachments=[]; let composeAttachments=[];
function populateComposeFrom() { function populateComposeFrom(preferAccountId) {
const sel=document.getElementById('compose-from'); const sel=document.getElementById('compose-from');
if(!sel) return; if(!sel) return;
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} &lt;${esc(a.email_address)}&gt;</option>`).join(''); sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} &lt;${esc(a.email_address)}&gt;</option>`).join('');
// Default to the account of the currently viewed folder, or explicitly passed account
if (preferAccountId) {
sel.value = String(preferAccountId);
} else if (S.currentFolder && S.currentFolder !== 'unified' && S.currentFolder !== 'starred') {
const folder = S.folders.find(f => f.id === S.currentFolder);
if (folder) sel.value = String(folder.account_id);
}
} }
function openCompose(opts={}) { function openCompose(opts={}) {
@@ -1021,6 +1236,7 @@ function openCompose(opts={}) {
editor.innerHTML=opts.body||''; editor.innerHTML=opts.body||'';
S.draftDirty=false; S.draftDirty=false;
updateAttachList(); updateAttachList();
populateComposeFrom(opts.accountId||null);
showCompose(); showCompose();
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80); setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
startDraftAutosave(); startDraftAutosave();
@@ -1073,6 +1289,7 @@ function openReplyTo(msgId) {
if (!msg) return; if (!msg) return;
openCompose({ openCompose({
mode:'reply', replyId:msgId, title:'Reply', mode:'reply', replyId:msgId, title:'Reply',
accountId: msg.account_id||null,
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''), subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`, body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
}); });
@@ -1085,6 +1302,7 @@ function openForward() {
S.composeForwardFromId=msg.id; S.composeForwardFromId=msg.id;
openCompose({ openCompose({
mode:'forward', forwardId:msg.id, title:'Forward', mode:'forward', forwardId:msg.id, title:'Forward',
accountId: msg.account_id||null,
subject:'Fwd: '+(msg.subject||''), subject:'Fwd: '+(msg.subject||''),
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`, body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
}); });
@@ -1096,6 +1314,7 @@ function openForwardAsAttachment() {
S.composeForwardFromId=msg.id; S.composeForwardFromId=msg.id;
openCompose({ openCompose({
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment', mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
accountId: msg.account_id||null,
subject:'Fwd: '+(msg.subject||''), subject:'Fwd: '+(msg.subject||''),
body:'', body:'',
}); });
@@ -1286,7 +1505,13 @@ async function sendMessage() {
} }
btn.disabled=false; btn.textContent='Send'; btn.disabled=false; btn.textContent='Send';
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); } if(r?.ok){
toast('Message sent!','success');
clearDraftAutosave();
_closeCompose();
// Refresh after a short delay so the syncer has time to pick up the sent message
setTimeout(async () => { await loadFolders(); await loadMessages(); }, 2500);
}
else toast(r?.error||'Send failed','error'); else toast(r?.error||'Send failed','error');
} }
@@ -1379,6 +1604,28 @@ async function openSettings() {
openModal('settings-modal'); openModal('settings-modal');
loadSyncInterval(); loadSyncInterval();
renderMFAPanel(); renderMFAPanel();
loadIPRules();
// Pre-fill profile fields with current values
const me = await api('GET', '/me');
if (me) {
document.getElementById('profile-username').placeholder = me.username || 'New username';
document.getElementById('profile-email').placeholder = me.email || 'New email';
}
}
async function updateProfile(field) {
const value = document.getElementById('profile-' + field).value.trim();
const password = document.getElementById('profile-confirm-pw').value;
if (!value) { toast('Please enter a new ' + field, 'error'); return; }
if (!password) { toast('Current password required to confirm changes', 'error'); return; }
const r = await api('PUT', '/profile', { field, value, password });
if (r?.ok) {
toast(field.charAt(0).toUpperCase() + field.slice(1) + ' updated', 'success');
document.getElementById('profile-' + field).value = '';
document.getElementById('profile-confirm-pw').value = '';
} else {
toast(r?.error || 'Update failed', 'error');
}
} }
async function loadSyncInterval() { async function loadSyncInterval() {
@@ -1432,6 +1679,39 @@ async function disableMFA() {
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error'); if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
} }
async function loadIPRules() {
const r = await api('GET', '/ip-rules');
if (!r) return;
document.getElementById('ip-rule-mode').value = r.mode || 'disabled';
document.getElementById('ip-rule-list').value = r.ip_list || '';
toggleIPRuleHelp();
}
function toggleIPRuleHelp() {
const mode = document.getElementById('ip-rule-mode').value;
const helpEl = document.getElementById('ip-rule-help');
const listField = document.getElementById('ip-rule-list-field');
const helps = {
disabled: '',
brute_skip: 'IPs in the list below will never be locked out of your account, even after many failed attempts. All other IPs are subject to global brute-force protection.',
allow_only: '⚠ Only IPs in the list below will be able to log into your account. All other IPs will see an "Access not authorized" error. Make sure to include your current IP before saving.',
};
helpEl.textContent = helps[mode] || '';
helpEl.style.display = mode !== 'disabled' ? 'block' : 'none';
listField.style.display = mode !== 'disabled' ? 'block' : 'none';
}
async function saveIPRules() {
const mode = document.getElementById('ip-rule-mode').value;
const ip_list = document.getElementById('ip-rule-list').value.trim();
if (mode !== 'disabled' && !ip_list) {
toast('Please enter at least one IP address', 'error'); return;
}
const r = await api('PUT', '/ip-rules', { mode, ip_list });
if (r?.ok) toast('IP rules saved', 'success');
else toast(r?.error || 'Save failed', 'error');
}
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; } async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
// ── Context menu helper ──────────────────────────────────────────────────── // ── Context menu helper ────────────────────────────────────────────────────
@@ -1504,7 +1784,7 @@ async function startPoller() {
function schedulePoll() { function schedulePoll() {
if (!POLLER.active) return; if (!POLLER.active) return;
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval
} }
async function runPoll() { async function runPoll() {
@@ -1530,15 +1810,9 @@ async function runPoll() {
sendOSNotification(newMsgs); sendOSNotification(newMsgs);
} }
// Refresh current view if we're looking at inbox/unified // Always refresh the message list and folder counts when new mail arrives
const isInboxView = S.currentFolder === 'unified' ||
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox');
if (isInboxView) {
await loadMessages();
await loadFolders(); await loadFolders();
} else { await loadMessages();
await loadFolders(); // update counts in sidebar
}
} }
} catch(e) { } catch(e) {
// Network error — silent, retry next cycle // Network error — silent, retry next cycle
@@ -1641,3 +1915,76 @@ function sendOSNotification(msgs) {
// Some browsers block even with granted permission in certain contexts // Some browsers block even with granted permission in certain contexts
} }
} }
// ── Mobile navigation ────────────────────────────────────────────────────────
function isMobile() { return window.innerWidth <= 700; }
function mobSetView(view) {
if (!isMobile()) return;
const app = document.getElementById('app-root');
if (!app) return;
app.dataset.mobView = view;
const navBtn = document.getElementById('mob-nav-btn');
const backBtn = document.getElementById('mob-back-btn');
const titleEl = document.getElementById('mob-title');
if (view === 'detail') {
if (navBtn) navBtn.style.display = 'none';
if (backBtn) backBtn.style.display = 'flex';
if (titleEl) titleEl.textContent = S.currentMessage?.subject || 'Message';
} else {
if (navBtn) navBtn.style.display = 'flex';
if (backBtn) backBtn.style.display = 'none';
if (titleEl) titleEl.textContent = S.currentFolderName || 'GoWebMail';
}
}
function mobBack() {
if (!isMobile()) return;
const app = document.getElementById('app-root');
if (!app) return;
if (app.dataset.mobView === 'detail') {
mobSetView('list');
}
}
function mobShowNav() {
document.querySelector('.sidebar')?.classList.add('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.add('mob-open');
}
function mobCloseNav() {
document.querySelector('.sidebar')?.classList.remove('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
}
// Update mob title when folder changes
const _origSelectFolder = selectFolder;
// (selectFolder already calls mobSetView/mobCloseNav inline)
// On resize between mobile/desktop, reset any leftover mobile state
window.addEventListener('resize', () => {
if (!isMobile()) {
const app = document.getElementById('app-root');
if (app) app.dataset.mobView = 'list';
document.querySelector('.sidebar')?.classList.remove('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
}
});
// ── Compose dropdown ────────────────────────────────────────────────────────
function toggleComposeDropdown(e) {
e.stopPropagation();
const dd = document.getElementById('compose-dropdown');
if (!dd) return;
const isOpen = dd.style.display !== 'none';
dd.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
// Close on next outside click
setTimeout(() => document.addEventListener('click', closeComposeDropdown, { once: true }), 0);
}
}
function closeComposeDropdown() {
const dd = document.getElementById('compose-dropdown');
if (dd) dd.style.display = 'none';
}

View File

@@ -0,0 +1,411 @@
// ── 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
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.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" 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'));
}

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> <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 Audit Log
</a> </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> </div>
</nav> </nav>
@@ -35,5 +39,5 @@
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script src="/static/js/admin.js?v=16"></script> <script src="/static/js/admin.js?v=25"></script>
{{end}} {{end}}

View File

@@ -3,7 +3,20 @@
{{define "body_class"}}app-page{{end}} {{define "body_class"}}app-page{{end}}
{{define "body"}} {{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">
<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" 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"></button>
</div>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
@@ -11,7 +24,16 @@
<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> <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> <span class="logo-text"><a href="/">GoWebMail</a></span>
</div> </div>
<button class="compose-btn" onclick="openCompose()">+ New</button> <div style="position:relative;display:inline-flex">
<button class="compose-btn" onclick="openCompose()" style="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">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;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>
</div> </div>
<div class="nav-section"> <div class="nav-section">
@@ -24,6 +46,14 @@
<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> <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 Starred
</div> </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 id="folders-by-account"></div>
</div> </div>
@@ -45,6 +75,8 @@
</div> </div>
</div> </div>
</aside> </aside>
<!-- Mobile sidebar backdrop -->
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
<!-- Message list --> <!-- Message list -->
<div class="message-list-panel"> <div class="message-list-panel">
@@ -89,6 +121,105 @@
<p>Choose a message from the list to read it</p> <p>Choose a message from the list to read it</p>
</div> </div>
</main> </main>
<!-- ── 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">&#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">&#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">
<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>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<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">
<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">
<div class="modal" style="max-width:560px">
<h2>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>
</div> </div>
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── --> <!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
@@ -179,12 +310,27 @@
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p> <p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
<div class="provider-btns"> <div class="provider-btns">
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')"> <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 Gmail
</button> </button>
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')"> <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> <!-- Microsoft 365 icon -->
Outlook <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> </button>
</div> </div>
<div class="modal-divider"><span>or add IMAP account</span></div> <div class="modal-divider"><span>or add IMAP account</span></div>
@@ -224,6 +370,20 @@
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p> <p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
<input type="hidden" id="edit-account-id"> <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>Display Name</label><input type="text" id="edit-name"></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>
<!-- 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-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row"> <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 Host</label><input type="text" id="edit-imap-host"></div>
@@ -233,6 +393,8 @@
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div> <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 class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
</div> </div>
</div>
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div> <div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
<div class="modal-field"> <div class="modal-field">
<label>Email history to sync</label> <label>Email history to sync</label>
@@ -264,12 +426,34 @@
<!-- ── Settings Modal ─────────────────────────────────────────────────────── --> <!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="settings-modal"> <div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:520px"> <div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
<h2 style="margin-bottom:0">Settings</h2> <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> <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> </div>
<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">
<div class="settings-group-title">Email Sync</div> <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="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
@@ -300,6 +484,27 @@
</div> </div>
<div id="mfa-panel">Loading...</div> <div id="mfa-panel">Loading...</div>
</div> </div>
<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> </div>
</div> </div>
@@ -309,5 +514,6 @@
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script src="/static/js/app.js?v=16"></script> <script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
{{end}} {{end}}

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title> <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 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=16"> <link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
{{block "head_extra" .}}{{end}} {{block "head_extra" .}}{{end}}
</head> </head>
<body class="{{block "body_class" .}}{{end}}"> <body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}} {{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=16"></script> <script src="/static/js/gowebmail.js?v=58"></script>
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
</body> </body>
</html> </html>

220
web/templates/compose.html Normal file
View File

@@ -0,0 +1,220 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;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>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
</div>
</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">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
<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 style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-field" 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">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" 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 -->
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
Attach file
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
</label>
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
</div>
<div id="cp-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 cpAttachments = [];
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;'); }
// Tag field (simple comma/enter separated)
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('.tag-chip');
if (tags.length) tags[tags.length-1].remove();
}
});
inp.addEventListener('blur', () => {
const v = inp.value.trim().replace(/,$/, '');
if (v) { addTagTo(id, v); inp.value = ''; }
});
}
function addTagTo(fieldId, email) {
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
el.insertBefore(chip, inp);
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
}
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);
}
}
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 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>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</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; } }
}
}
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>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
}
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) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 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) {
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
}
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
</script>
{{end}}

View File

@@ -19,7 +19,7 @@
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script> <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'); 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';} if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
</script> </script>

View File

@@ -0,0 +1,95 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;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());
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;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
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>` : '';
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>
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
</div>
${attHtml}`;
// Write body into sandboxed iframe
const iframe = document.getElementById('msg-iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><style>
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
img{max-width:100%;height:auto}a{color:#0078D4}
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
doc.close();
// Auto-resize iframe
setTimeout(() => {
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
}, 200);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
}
load();
</script>
{{end}}