mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
Compare commits
9 Commits
948e111cc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d470c8b71f | ||
|
|
9e7e87d11b | ||
|
|
a9c7f4c575 | ||
|
|
1e08d5f50f | ||
|
|
015c00251b | ||
|
|
68c81ebaed | ||
|
|
f122d29282 | ||
|
|
d6e987f66c | ||
|
|
ef85246806 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ data/gowebmail.conf
|
||||
data/*.txt
|
||||
gowebmail-devplan.md
|
||||
testrun/
|
||||
webmail.code-workspace
|
||||
@@ -1,13 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/handlers"
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
|
||||
@@ -47,6 +51,16 @@ func main() {
|
||||
}
|
||||
runDisableMFA(args[1])
|
||||
return
|
||||
case "--blocklist":
|
||||
runBlockList()
|
||||
return
|
||||
case "--unblock":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||
os.Exit(1)
|
||||
}
|
||||
runUnblock(args[1])
|
||||
return
|
||||
case "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
@@ -62,6 +76,18 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("config load: %v", err)
|
||||
}
|
||||
logger.Init(cfg.Debug)
|
||||
|
||||
// Install a filtered log writer that suppresses harmless go-imap v1 parser
|
||||
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
|
||||
// on Gmail connections due to non-standard server responses. These don't affect
|
||||
// functionality — go-imap recovers and continues syncing correctly.
|
||||
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
|
||||
"imap/client:",
|
||||
"atom contains forbidden",
|
||||
"atom contains bad",
|
||||
"bad brackets nesting",
|
||||
}})
|
||||
|
||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
@@ -73,7 +99,7 @@ func main() {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
sc := syncer.New(database)
|
||||
sc := syncer.New(database, cfg)
|
||||
sc.Start()
|
||||
defer sc.Stop()
|
||||
|
||||
@@ -85,10 +111,27 @@ func main() {
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(cfg.HostCheckMiddleware)
|
||||
|
||||
// Custom error handlers for non-API paths
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||
})
|
||||
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||
})
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(
|
||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||
)
|
||||
// Legacy /app path redirect — some browsers bookmark this; redirect to root
|
||||
// which RequireAuth will then forward to login if not signed in.
|
||||
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
|
||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||
if err != nil {
|
||||
@@ -103,7 +146,7 @@ func main() {
|
||||
// Public auth routes
|
||||
auth := r.PathPrefix("/auth").Subrouter()
|
||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
||||
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||
|
||||
// MFA (session exists but mfa_verified=0)
|
||||
@@ -119,11 +162,15 @@ func main() {
|
||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||
|
||||
// App
|
||||
app := r.PathPrefix("").Subrouter()
|
||||
app.Use(middleware.RequireAuth(database, cfg))
|
||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
||||
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
|
||||
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
|
||||
|
||||
// Admin UI
|
||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||
@@ -133,6 +180,7 @@ func main() {
|
||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||
|
||||
// API
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
@@ -141,10 +189,13 @@ func main() {
|
||||
|
||||
// Profile / auth
|
||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||
|
||||
// Providers (which OAuth providers are configured)
|
||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||
@@ -202,10 +253,35 @@ func main() {
|
||||
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
||||
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
|
||||
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
|
||||
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
|
||||
|
||||
// Search
|
||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
||||
|
||||
// Contacts
|
||||
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
|
||||
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
|
||||
|
||||
// Calendar events
|
||||
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
|
||||
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
|
||||
|
||||
// CalDAV API tokens
|
||||
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
|
||||
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
|
||||
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
|
||||
|
||||
// CalDAV public feed — token-authenticated, no session needed
|
||||
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
|
||||
|
||||
// Admin API
|
||||
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
||||
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
||||
@@ -218,6 +294,19 @@ func main() {
|
||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
|
||||
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
|
||||
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
|
||||
|
||||
// Periodically purge expired IP blocks
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
database.PurgeExpiredBlocks()
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
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)
|
||||
}
|
||||
|
||||
func runBlockList() {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
blocks, err := database.ListIPBlocksWithUsername()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
fmt.Println("No blocked IPs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
|
||||
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||
"--", "-------------", "-----", "----------", "-------", "---------")
|
||||
for _, b := range blocks {
|
||||
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
var expires, remaining string
|
||||
if b.IsPermanent || b.ExpiresAt == nil {
|
||||
expires = "permanent"
|
||||
remaining = "∞ (manual unblock)"
|
||||
} else {
|
||||
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||
left := time.Until(*b.ExpiresAt)
|
||||
if left <= 0 {
|
||||
remaining = "expired (purge pending)"
|
||||
} else {
|
||||
h := int(left.Hours())
|
||||
m := int(left.Minutes()) % 60
|
||||
s := int(left.Seconds()) % 60
|
||||
if h > 0 {
|
||||
remaining = fmt.Sprintf("%dh %dm", h, m)
|
||||
} else if m > 0 {
|
||||
remaining = fmt.Sprintf("%dm %ds", m, s)
|
||||
} else {
|
||||
remaining = fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
username := b.LastUsername
|
||||
if username == "" {
|
||||
username = "(unknown)"
|
||||
}
|
||||
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
|
||||
b.IP, username, b.Attempts, blockedAt, expires, remaining)
|
||||
}
|
||||
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
|
||||
}
|
||||
|
||||
func runUnblock(ip string) {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
if err := database.UnblockIP(ip); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("IP %s has been unblocked.\n", ip)
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`GoWebMail — Admin CLI
|
||||
|
||||
@@ -318,14 +470,35 @@ Usage:
|
||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||
gowebmail --mfa-off <username> Disable MFA for an admin account
|
||||
gowebmail --blocklist List all currently blocked IP addresses
|
||||
gowebmail --unblock <ip> Remove block for a specific IP address
|
||||
|
||||
Examples:
|
||||
./gowebmail --list-admin
|
||||
./gowebmail --pw admin "NewSecurePass123"
|
||||
./gowebmail --mfa-off admin
|
||||
./gowebmail --blocklist
|
||||
./gowebmail --unblock 1.2.3.4
|
||||
|
||||
Note: These commands only work on admin accounts.
|
||||
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
||||
Regular user management is done through the web UI.
|
||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||
`)
|
||||
}
|
||||
|
||||
// filteredWriter wraps an io.Writer and drops log lines containing any of the
|
||||
// suppress substrings. Used to silence harmless go-imap internal parser errors.
|
||||
type filteredWriter struct {
|
||||
w io.Writer
|
||||
suppress []string
|
||||
}
|
||||
|
||||
func (f *filteredWriter) Write(p []byte) (n int, err error) {
|
||||
line := string(bytes.TrimSpace(p))
|
||||
for _, s := range f.suppress {
|
||||
if strings.Contains(line, s) {
|
||||
return len(p), nil // silently drop
|
||||
}
|
||||
}
|
||||
return f.w.Write(p)
|
||||
}
|
||||
|
||||
219
config/config.go
219
config/config.go
@@ -21,6 +21,9 @@ type Config struct {
|
||||
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
||||
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
||||
|
||||
// Debug
|
||||
Debug bool // set DEBUG=true in config to enable verbose logging
|
||||
|
||||
// Security
|
||||
EncryptionKey []byte // 32 bytes / AES-256
|
||||
SessionSecret []byte
|
||||
@@ -28,6 +31,23 @@ type Config struct {
|
||||
SessionMaxAge int
|
||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
||||
|
||||
// Notification SMTP (outbound alerts — separate from user mail accounts)
|
||||
NotifyEnabled bool
|
||||
NotifySMTPHost string
|
||||
NotifySMTPPort int
|
||||
NotifyFrom string
|
||||
NotifyUser string // optional — leave blank for unauthenticated relay
|
||||
NotifyPass string // optional
|
||||
|
||||
// Brute force protection
|
||||
BruteEnabled bool
|
||||
BruteMaxAttempts int
|
||||
BruteWindowMins int
|
||||
BruteBanHours int
|
||||
BruteWhitelist []net.IP // IPs exempt from blocking
|
||||
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
|
||||
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
|
||||
|
||||
// Storage
|
||||
DBPath string
|
||||
|
||||
@@ -118,6 +138,108 @@ var allFields = []configField{
|
||||
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Security Notifications ---",
|
||||
"Send email alerts to users when their account is targeted by brute-force attacks.",
|
||||
"Set to false to disable all security notification emails.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_HOST",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP server hostname for sending security notification emails.",
|
||||
"Example: smtp.example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_PORT",
|
||||
defVal: "587",
|
||||
comments: []string{
|
||||
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_FROM",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Sender address for security notification emails. Example: security@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_USER",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_PASS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Brute Force Protection ---",
|
||||
"Enable automatic IP blocking after repeated failed logins.",
|
||||
"Set to false to disable entirely.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_MAX_ATTEMPTS",
|
||||
defVal: "5",
|
||||
comments: []string{
|
||||
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WINDOW_MINUTES",
|
||||
defVal: "30",
|
||||
comments: []string{
|
||||
"Time window in minutes for counting failed login attempts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_BAN_HOURS",
|
||||
defVal: "12",
|
||||
comments: []string{
|
||||
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WHITELIST_IPS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
|
||||
"Example: 192.168.1.1,10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_BLOCK_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
|
||||
"Comma-separated 2-letter ISO country codes to DENY access from.",
|
||||
"Example: CN,RU,KP",
|
||||
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_ALLOW_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
|
||||
"Example: SK,CZ,DE",
|
||||
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "DB_PATH",
|
||||
defVal: "./data/gowebmail.db",
|
||||
@@ -184,10 +306,15 @@ var allFields = []configField{
|
||||
},
|
||||
{
|
||||
key: "MICROSOFT_TENANT_ID",
|
||||
defVal: "common",
|
||||
defVal: "consumers",
|
||||
comments: []string{
|
||||
"Use 'common' to allow any Microsoft account,",
|
||||
"or your Azure tenant ID to restrict to one organisation.",
|
||||
"Tenant endpoint to use for Microsoft OAuth2.",
|
||||
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
|
||||
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
|
||||
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
|
||||
" Use if registered as 'Personal accounts only'.",
|
||||
" organizations - Work/school Microsoft 365 accounts only.",
|
||||
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
|
||||
Hostname: hostname,
|
||||
BaseURL: baseURL,
|
||||
DBPath: get("DB_PATH"),
|
||||
Debug: atobool(get("DEBUG"), false),
|
||||
EncryptionKey: encKey,
|
||||
SessionSecret: []byte(sessSecret),
|
||||
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||
TrustedProxies: trustedProxies,
|
||||
|
||||
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
|
||||
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
|
||||
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
|
||||
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
|
||||
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
|
||||
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
|
||||
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
|
||||
|
||||
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
|
||||
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
|
||||
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
|
||||
NotifyFrom: get("NOTIFY_FROM"),
|
||||
NotifyUser: get("NOTIFY_USER"),
|
||||
NotifyPass: get("NOTIFY_PASS"),
|
||||
|
||||
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||
GoogleRedirectURL: googleRedirect,
|
||||
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
||||
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
|
||||
MicrosoftRedirectURL: outlookRedirect,
|
||||
}
|
||||
|
||||
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
|
||||
func (c *Config) IsIPWhitelisted(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, w := range c.BruteWhitelist {
|
||||
if w.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
|
||||
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
|
||||
func (c *Config) IsCountryAllowed(code string) bool {
|
||||
code = strings.ToUpper(code)
|
||||
if len(c.GeoBlockCountries) > 0 {
|
||||
for _, bc := range c.GeoBlockCountries {
|
||||
if bc == code {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.GeoAllowCountries) > 0 {
|
||||
for _, ac := range c.GeoAllowCountries {
|
||||
if ac == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
||||
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
||||
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
|
||||
}
|
||||
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
||||
}
|
||||
if cfg.GoogleClientID != "" {
|
||||
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
|
||||
}
|
||||
if cfg.MicrosoftClientID != "" {
|
||||
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
|
||||
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPList(s string) []net.IP {
|
||||
var ips []net.IP
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func parseCountryList(s string) []string {
|
||||
var codes []string
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||
if len(raw) == 2 {
|
||||
codes = append(codes, raw)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func mustHex(n int) string {
|
||||
|
||||
9
go.mod
9
go.mod
@@ -5,14 +5,13 @@ go 1.26
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
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=
|
||||
|
||||
@@ -3,11 +3,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/microsoft"
|
||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
||||
|
||||
// ---- Microsoft / Outlook OAuth2 ----
|
||||
|
||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
||||
var OutlookScopes = []string{
|
||||
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||
// audience for IMAP XOAUTH2 authentication.
|
||||
var OutlookAuthScopes = []string{
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
||||
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||
// "common" routes personal accounts through login.live.com which returns
|
||||
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookScopes,
|
||||
Scopes: OutlookAuthScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
||||
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||
// authorization and exchanges it for an access token scoped to the Outlook
|
||||
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||
// The two-step approach is necessary because:
|
||||
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", "refresh_token")
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("client_secret", clientSecret)
|
||||
params.Set("refresh_token", refreshToken)
|
||||
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||
}
|
||||
|
||||
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||
preview := result.AccessToken
|
||||
if len(preview) > 30 {
|
||||
preview = preview[:30] + "..."
|
||||
}
|
||||
parts := strings.Count(result.AccessToken, ".") + 1
|
||||
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||
parts, preview, params.Get("scope"))
|
||||
|
||||
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
return &oauth2.Token{
|
||||
AccessToken: result.AccessToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||
type MicrosoftUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName"` // Graph field
|
||||
Name string `json:"name"` // ID token claim
|
||||
Mail string `json:"mail"`
|
||||
EmailClaim string `json:"email"` // ID token claim
|
||||
UserPrincipalName string `json:"userPrincipalName"`
|
||||
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||
}
|
||||
|
||||
// Email returns the best available email address.
|
||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
||||
if m.Mail != "" {
|
||||
return m.Mail
|
||||
}
|
||||
if m.EmailClaim != "" {
|
||||
return m.EmailClaim
|
||||
}
|
||||
if m.PreferredUsername != "" {
|
||||
return m.PreferredUsername
|
||||
}
|
||||
return m.UserPrincipalName
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
||||
// BestName returns the best available display name.
|
||||
func (m *MicrosoftUserInfo) BestName() string {
|
||||
if m.DisplayName != "" {
|
||||
return m.DisplayName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||
// The ID token is issued alongside the access token and contains email/name claims.
|
||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
if idToken == "" {
|
||||
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||
}
|
||||
|
||||
// JWT structure: header.payload.signature — decode the payload only
|
||||
parts := strings.SplitN(idToken, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var info MicrosoftUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if info.Email() == "" {
|
||||
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) ----
|
||||
|
||||
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||
// Mail is accessed via Graph REST API instead of IMAP.
|
||||
var OutlookPersonalScopes = []string{
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookPersonalScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token refresh helpers ----
|
||||
|
||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||
return ts.Token()
|
||||
}
|
||||
|
||||
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
|
||||
// Pass the credentials for both providers; the correct ones are selected based
|
||||
// on provider ("gmail" or "outlook").
|
||||
func RefreshAccountToken(ctx context.Context,
|
||||
provider, refreshToken, baseURL,
|
||||
googleClientID, googleClientSecret,
|
||||
msClientID, msClientSecret, msTenantID string,
|
||||
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||
|
||||
switch provider {
|
||||
case "gmail":
|
||||
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||
case "outlook":
|
||||
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
case "outlook_personal":
|
||||
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||
baseURL+"/auth/outlook-personal/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
default:
|
||||
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -170,7 +172,6 @@ func (d *DB) Migrate() error {
|
||||
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
|
||||
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
|
||||
// Default: primary folder types sync by default, others don't.
|
||||
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
||||
// 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.
|
||||
`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`,
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
return d.bootstrapAdmin()
|
||||
}
|
||||
@@ -577,14 +684,14 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
|
||||
access_token, refresh_token, token_expiry,
|
||||
imap_host, imap_port, smtp_host, smtp_port,
|
||||
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,
|
||||
).Scan(
|
||||
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
|
||||
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
||||
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
||||
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
||||
&a.SyncDays, &a.SyncMode,
|
||||
&a.SyncDays, &a.SyncMode, &a.SortOrder,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
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,
|
||||
access_token, refresh_token, token_expiry,
|
||||
imap_host, imap_port, smtp_host, smtp_port,
|
||||
last_error, color, is_active, last_sync, created_at
|
||||
FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID)
|
||||
last_error, color, is_active, last_sync, created_at,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -737,6 +846,7 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
|
||||
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
||||
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
||||
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
||||
&a.SortOrder,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -759,6 +869,111 @@ func (d *DB) DeleteAccount(accountID, userID int64) error {
|
||||
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) {
|
||||
d.sql.Exec(`
|
||||
UPDATE folders SET
|
||||
@@ -1070,6 +1285,22 @@ func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error {
|
||||
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) {
|
||||
var current bool
|
||||
err := d.sql.QueryRow(`
|
||||
@@ -1265,6 +1496,28 @@ func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, fold
|
||||
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.
|
||||
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
@@ -1693,3 +1946,564 @@ func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
|
||||
WHERE id=?`, targetUserID)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/emersion/go-imap"
|
||||
"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)
|
||||
return "XOAUTH2", []byte(payload), nil
|
||||
}
|
||||
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
|
||||
|
||||
// Next handles the XOAUTH2 challenge from the server.
|
||||
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
||||
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
||||
// final tagged NO response and returns a proper error.
|
||||
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
||||
if len(challenge) > 0 {
|
||||
// Decode and log the error from Microsoft so it appears in server logs
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
||||
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
||||
} else {
|
||||
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
||||
}
|
||||
// Send empty response to let the server send the final error
|
||||
return []byte("\x01"), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type xoauth2SMTP struct{ user, token string }
|
||||
|
||||
@@ -80,6 +99,9 @@ type Client struct {
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
||||
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
||||
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
||||
}
|
||||
host, port := imapHostFor(account.Provider)
|
||||
if account.IMAPHost != "" {
|
||||
host = account.IMAPHost
|
||||
@@ -108,6 +130,33 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
||||
|
||||
switch account.Provider {
|
||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
||||
tokenPreview := account.AccessToken
|
||||
if len(tokenPreview) > 20 {
|
||||
tokenPreview = tokenPreview[:20] + "..."
|
||||
}
|
||||
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
||||
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||
var claims struct {
|
||||
Aud interface{} `json:"aud"`
|
||||
Scp string `json:"scp"`
|
||||
Upn string `json:"upn"`
|
||||
}
|
||||
if json.Unmarshal(payload, &claims) == nil {
|
||||
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
|
||||
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
|
||||
account.EmailAddress, string(payload), tokenPreview)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
|
||||
account.EmailAddress, tokenPreview)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
|
||||
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
||||
}
|
||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||
if err := c.Authenticate(sasl); err != nil {
|
||||
c.Logout()
|
||||
@@ -852,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
rawMsg := buf.Bytes()
|
||||
|
||||
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 err error
|
||||
@@ -893,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
if err := authSMTP(c, account, host); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
||||
}
|
||||
log.Printf("[SMTP] auth OK")
|
||||
logger.Debug("[SMTP] auth OK")
|
||||
|
||||
if err := c.Mail(account.EmailAddress); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
||||
@@ -923,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
// DATA close is where the server accepts or rejects the message
|
||||
return fmt.Errorf("SMTP server rejected message: %w", err)
|
||||
}
|
||||
log.Printf("[SMTP] message accepted by server")
|
||||
logger.Debug("[SMTP] message accepted by server")
|
||||
_ = c.Quit()
|
||||
|
||||
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
||||
|
||||
97
internal/geo/geo.go
Normal file
97
internal/geo/geo.go
Normal 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
426
internal/graph/graph.go
Normal 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("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -225,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- IP Blocks ----
|
||||
|
||||
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
blocks, err := h.db.ListIPBlocks()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
|
||||
return
|
||||
}
|
||||
if blocks == nil {
|
||||
blocks = []db.IPBlock{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
BanHours int `json:"ban_hours"` // 0 = permanent
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.IP == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
// Try geo lookup for the IP being manually blocked
|
||||
g := geo.Lookup(req.IP)
|
||||
if req.Reason == "" {
|
||||
req.Reason = "Manual admin block"
|
||||
}
|
||||
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
ip := mux.Vars(r)["ip"]
|
||||
if ip == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
if err := h.db.UnblockIP(ip); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "unblock failed")
|
||||
return
|
||||
}
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Login Attempts ----
|
||||
|
||||
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
|
||||
return
|
||||
}
|
||||
if stats == nil {
|
||||
stats = []db.LoginAttemptStat{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"attempts": stats})
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
graphpkg "github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"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{
|
||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||
"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"`
|
||||
SMTPHost string `json:"smtp_host,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"`
|
||||
Color string `json:"color"`
|
||||
LastSync string `json:"last_sync"`
|
||||
TokenExpired bool `json:"token_expired,omitempty"`
|
||||
}
|
||||
|
||||
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||
@@ -70,11 +77,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||
if !a.LastSync.IsZero() {
|
||||
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{
|
||||
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
||||
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
||||
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
||||
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
|
||||
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})
|
||||
}
|
||||
|
||||
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 ----
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
|
||||
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"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Update local DB first
|
||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||
|
||||
// Enqueue IMAP op — drained by background worker with retry
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err == nil && uid != 0 && account != nil {
|
||||
if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
|
||||
account.Provider == models.ProviderOutlookPersonal {
|
||||
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"
|
||||
if req.Read {
|
||||
val = "1"
|
||||
}
|
||||
if req.Read { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_read",
|
||||
AccountID: acc.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
h.syncer.TriggerAccountSync(acc.ID)
|
||||
}
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && account != nil {
|
||||
if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && 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"
|
||||
if starred {
|
||||
val = "1"
|
||||
}
|
||||
if starred { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_star",
|
||||
AccountID: acc.ID, OpType: "flag_star",
|
||||
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})
|
||||
}
|
||||
@@ -627,8 +705,11 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP move
|
||||
if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||
// Route to Graph or IMAP
|
||||
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{
|
||||
AccountID: account.ID, OpType: "move",
|
||||
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)
|
||||
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)
|
||||
|
||||
// Delete from local DB
|
||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP delete
|
||||
if imapErr == nil && uid != 0 && account != nil {
|
||||
if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID)
|
||||
} else if imapErr == nil && uid != 0 && account != nil {
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "delete",
|
||||
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 {
|
||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||
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())
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -1333,3 +1435,44 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,3 +17,13 @@ type AppHandler struct {
|
||||
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "app", nil)
|
||||
}
|
||||
|
||||
// ViewMessage renders a single message in a full browser tab.
|
||||
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "message", nil)
|
||||
}
|
||||
|
||||
// ComposePage renders the compose form in a full browser tab.
|
||||
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "compose", nil)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||
@@ -23,6 +29,7 @@ type AuthHandler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
renderer *Renderer
|
||||
syncer interface{ TriggerReconcile() }
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
@@ -53,6 +60,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||
case "deny":
|
||||
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||
return
|
||||
case "skip_brute":
|
||||
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||
w.Header().Set("X-Skip-Brute", "1")
|
||||
}
|
||||
|
||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||
uid := user.ID
|
||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||
@@ -299,12 +317,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
if err := h.db.CreateAccount(account); err != nil {
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
|
||||
action := "gmail:" + userInfo.Email
|
||||
if !created {
|
||||
action = "gmail-reconnect:" + userInfo.Email
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -319,13 +345,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
||||
state := encodeOAuthState(userID, "outlook")
|
||||
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
// Microsoft returns ?error=...&error_description=... instead of ?code=...
|
||||
// when the user denies consent or the app has misconfigured permissions.
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2>
|
||||
<pre>%s</pre>
|
||||
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
|
||||
<ul>
|
||||
<li>In Azure portal → API Permissions → Add a permission</li>
|
||||
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
|
||||
<li>Search: <strong>Office 365 Exchange Online</strong></li>
|
||||
<li>Delegated permissions → add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
|
||||
<li>Then click <strong>Grant admin consent</strong></li>
|
||||
<li>Do NOT use Microsoft Graph versions of these scopes</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
@@ -335,29 +399,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
|
||||
// Show the raw error in the browser so the user can diagnose the problem
|
||||
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
|
||||
<h2>Outlook OAuth Token Exchange Failed</h2>
|
||||
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
|
||||
<pre>%s</pre>
|
||||
<hr>
|
||||
<p><strong>Things to check:</strong></p>
|
||||
<ul>
|
||||
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
|
||||
<li>Tenant ID in config: <code>%s</code> — must match your app's "Supported account types"</li>
|
||||
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
|
||||
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
|
||||
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
|
||||
return
|
||||
}
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
|
||||
|
||||
// Exchange initial token for one scoped to https://outlook.office.com
|
||||
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
|
||||
imapToken, err := goauth.ExchangeForIMAPToken(
|
||||
r.Context(),
|
||||
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, token.RefreshToken,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
|
||||
imapToken = token
|
||||
} else {
|
||||
logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
|
||||
if imapToken.RefreshToken == "" {
|
||||
imapToken.RefreshToken = token.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlook,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
|
||||
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
if err := h.db.CreateAccount(account); err != nil {
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
|
||||
action := "outlook:" + userInfo.Email()
|
||||
if !created {
|
||||
action = "outlook-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -403,3 +518,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---- Profile Updates ----
|
||||
|
||||
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Field string `json:"field"` // "email" | "username"
|
||||
Value string `json:"value"`
|
||||
Password string `json:"password"` // current password required for confirmation
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if req.Value == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "value required")
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
|
||||
return
|
||||
}
|
||||
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
|
||||
writeJSONError(w, http.StatusForbidden, "incorrect password")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Field {
|
||||
case "email":
|
||||
// Check uniqueness
|
||||
existing, _ := h.db.GetUserByEmail(req.Value)
|
||||
if existing != nil && existing.ID != userID {
|
||||
writeJSONError(w, http.StatusConflict, "email already in use")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
|
||||
return
|
||||
}
|
||||
case "username":
|
||||
existing, _ := h.db.GetUserByUsername(req.Value)
|
||||
if existing != nil && existing.ID != userID {
|
||||
writeJSONError(w, http.StatusConflict, "username already in use")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
|
||||
return
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r)
|
||||
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Per-User IP Rules ----
|
||||
|
||||
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
rule, err := h.db.GetUserIPRule(userID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(rule)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
|
||||
IPList string `json:"ip_list"` // comma-separated
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
|
||||
if !validModes[req.Mode] {
|
||||
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate IPs
|
||||
for _, rawIP := range db.SplitIPList(req.IPList) {
|
||||
if net.ParseIP(rawIP) == nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Mode == "disabled" {
|
||||
h.db.DeleteUserIPRule(userID)
|
||||
} else {
|
||||
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r)
|
||||
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) OAuth2 ----
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.MicrosoftClientID == "" {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
|
||||
return
|
||||
}
|
||||
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
|
||||
userID := middleware.GetUserID(r)
|
||||
state := encodeOAuthState(userID, "outlook_personal")
|
||||
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2><pre>%s</pre>
|
||||
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
|
||||
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
|
||||
<p><a href="/">← Back</a></p></body></html>`,
|
||||
html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
if code == "" {
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook_personal" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from ID token
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
|
||||
tokenParts := len(strings.Split(token.AccessToken, "."))
|
||||
logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d",
|
||||
userInfo.Email(), tokenParts)
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlookPersonal,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
action := "outlook-personal:" + userInfo.Email()
|
||||
if !created {
|
||||
action = "outlook-personal-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
|
||||
}
|
||||
|
||||
309
internal/handlers/contacts_calendar.go
Normal file
309
internal/handlers/contacts_calendar.go
Normal 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")
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *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},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
|
||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
||||
"login.html",
|
||||
"mfa.html",
|
||||
"admin.html",
|
||||
"message.html",
|
||||
"compose.html",
|
||||
}
|
||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||
if err != nil {
|
||||
|
||||
24
internal/logger/logger.go
Normal file
24
internal/logger/logger.go
Normal 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 }
|
||||
@@ -3,6 +3,8 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,7 +13,9 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/notify"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||
if role != models.RoleAdmin {
|
||||
if isAPIPath(r) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `{"error":"forbidden"}`)
|
||||
} else {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"You don't have permission to access this page. Admin privileges are required.")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
|
||||
// It must be called with the raw handler so it can intercept BEFORE auth.
|
||||
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
|
||||
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
|
||||
if cfg.IsIPWhitelisted(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve country for geo-block and attempt recording.
|
||||
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
|
||||
geoResult := geo.Lookup(ip)
|
||||
|
||||
// --- Geo block (apply to all requests) ---
|
||||
if geoResult.CountryCode != "" {
|
||||
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
|
||||
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"Access from your country is not permitted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.BruteEnabled || r.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
if database.IsIPBlocked(ip) {
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"IP Address Blocked",
|
||||
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap the response writer to detect a failed login (redirect to error vs success)
|
||||
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Determine success: a redirect away from login = success
|
||||
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
|
||||
username := r.FormValue("username")
|
||||
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
|
||||
|
||||
if !success && !rw.skipBrute {
|
||||
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
|
||||
if failures >= cfg.BruteMaxAttempts {
|
||||
reason := "Too many failed logins"
|
||||
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
|
||||
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
|
||||
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
|
||||
|
||||
// Send security notification to the targeted user (non-blocking goroutine)
|
||||
go func(targetUsername string) {
|
||||
user, _ := database.GetUserByUsername(targetUsername)
|
||||
if user == nil {
|
||||
user, _ = database.GetUserByEmail(targetUsername)
|
||||
}
|
||||
if user != nil && user.Email != "" {
|
||||
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
|
||||
Username: user.Username,
|
||||
ToEmail: user.Email,
|
||||
AttackerIP: ip,
|
||||
Country: geoResult.Country,
|
||||
CountryCode: geoResult.CountryCode,
|
||||
Attempts: failures,
|
||||
BlockedAt: time.Now().UTC(),
|
||||
BanHours: cfg.BruteBanHours,
|
||||
Hostname: cfg.Hostname,
|
||||
})
|
||||
}
|
||||
}(username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
|
||||
type loginResponseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
location string
|
||||
skipBrute bool
|
||||
}
|
||||
|
||||
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||
lrc.statusCode = code
|
||||
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||
if lrc.Header().Get("X-Skip-Brute") == "1" {
|
||||
lrc.skipBrute = true
|
||||
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
|
||||
}
|
||||
lrc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
|
||||
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
renderErrorPage(w, r, status, title, message)
|
||||
}
|
||||
|
||||
// renderErrorPage writes a themed HTML error page for browser requests,
|
||||
// or a JSON error for API paths.
|
||||
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
if isAPIPath(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||
return
|
||||
}
|
||||
// Back-button destination: always send to "/" which RequireAuth will
|
||||
// transparently forward to /auth/login if the session is absent or invalid.
|
||||
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
|
||||
backHref := "/"
|
||||
backLabel := "← Go Back"
|
||||
|
||||
data := struct {
|
||||
Status int
|
||||
Title string
|
||||
Message string
|
||||
BackHref string
|
||||
BackLabel string
|
||||
}{status, title, message, backHref, backLabel}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := errorPageTmpl.Execute(w, data); err != nil {
|
||||
// Last-resort plain text fallback
|
||||
fmt.Fprintf(w, "%d %s: %s", status, title, message)
|
||||
}
|
||||
}
|
||||
|
||||
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Status}} – {{.Title}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg, #18191b);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
.error-card {
|
||||
background: var(--surface, #232428);
|
||||
border: 1px solid var(--border, #2e2f34);
|
||||
border-radius: 16px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||
}
|
||||
.error-code {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #6b8afd);
|
||||
line-height: 1;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e9ed);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #8b8d97);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.error-back {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: var(--accent, #6b8afd);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.error-back:hover { opacity: .85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-card">
|
||||
<div class="error-code">{{.Status}}</div>
|
||||
<h1 class="error-title">{{.Title}}</h1>
|
||||
<p class="error-message">{{.Message}}</p>
|
||||
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
@@ -85,6 +85,7 @@ type AccountProvider string
|
||||
const (
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
)
|
||||
|
||||
@@ -113,6 +114,7 @@ type EmailAccount struct {
|
||||
// Display
|
||||
Color string `json:"color"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
LastSync time.Time `json:"last_sync"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -241,3 +243,49 @@ type PagedMessages struct {
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// ---- Contacts ----
|
||||
|
||||
type Contact struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
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
201
internal/notify/notify.go
Normal 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()
|
||||
}
|
||||
@@ -9,31 +9,51 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// Scheduler coordinates all background sync activity.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// push channels: accountID -> channel to signal "something changed on server"
|
||||
pushMu sync.Mutex
|
||||
pushCh map[int64]chan struct{}
|
||||
|
||||
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||
reconcileCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
reconcileCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerReconcile asks the main loop to immediately check for new accounts.
|
||||
// Safe to call from any goroutine; non-blocking.
|
||||
func (s *Scheduler) TriggerReconcile() {
|
||||
select {
|
||||
case s.reconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +143,13 @@ func (s *Scheduler) mainLoop() {
|
||||
stopWorker(id)
|
||||
}
|
||||
return
|
||||
case <-s.reconcileCh:
|
||||
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
for id := range workers {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||
case <-ticker.C:
|
||||
// Build active IDs map for reconciliation
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
@@ -187,6 +214,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
||||
return a
|
||||
}
|
||||
|
||||
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
s.graphWorker(account, stop, push)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial sync on startup
|
||||
s.drainPendingOps(account)
|
||||
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)
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
cancel()
|
||||
if err != nil {
|
||||
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||
@@ -349,7 +384,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not connected") {
|
||||
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||
}
|
||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
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)
|
||||
if err != nil || len(ops) == 0 {
|
||||
return
|
||||
@@ -496,6 +549,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
||||
@@ -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) ----
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Graph accounts use the Graph sync path, not IMAP
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
account = s.ensureFreshToken(account)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
gc := graph.New(account)
|
||||
// Force full resync of this folder by ignoring the since filter
|
||||
msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("graph list messages: %w", err)
|
||||
}
|
||||
n := 0
|
||||
for _, gm := range msgs {
|
||||
msg := &models.Message{
|
||||
AccountID: account.ID,
|
||||
FolderID: folder.ID,
|
||||
RemoteUID: gm.ID,
|
||||
MessageID: gm.InternetMessageID,
|
||||
Subject: gm.Subject,
|
||||
FromName: gm.FromName(),
|
||||
FromEmail: gm.FromEmail(),
|
||||
ToList: gm.ToList(),
|
||||
Date: gm.ReceivedDateTime,
|
||||
IsRead: gm.IsRead,
|
||||
IsStarred: gm.IsFlagged(),
|
||||
HasAttachment: gm.HasAttachments,
|
||||
}
|
||||
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// Update folder counts
|
||||
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -574,3 +721,129 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
|
||||
return s.syncFolder(c, account, folder)
|
||||
}
|
||||
|
||||
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
|
||||
|
||||
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
|
||||
// It polls Graph API instead of using IMAP.
|
||||
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
logger.Debug("[graph] worker started for %s", account.EmailAddress)
|
||||
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
s.graphDeltaSync(getAccount())
|
||||
|
||||
syncTicker := time.NewTicker(30 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
|
||||
return
|
||||
case <-push:
|
||||
acc := getAccount()
|
||||
s.graphDeltaSync(acc)
|
||||
case <-syncTicker.C:
|
||||
acc := getAccount()
|
||||
// Respect sync interval
|
||||
if !acc.LastSync.IsZero() {
|
||||
interval := time.Duration(acc.SyncInterval) * time.Minute
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Minute
|
||||
}
|
||||
if time.Since(acc.LastSync) < interval {
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.graphDeltaSync(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
|
||||
// as the IMAP sync path, so the rest of the app works unchanged.
|
||||
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
|
||||
account = s.ensureFreshToken(account)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
gc := graph.New(account)
|
||||
|
||||
// Fetch folders
|
||||
gFolders, err := gc.ListFolders(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.db.ClearAccountError(account.ID)
|
||||
|
||||
totalNew := 0
|
||||
for _, gf := range gFolders {
|
||||
folderType := graph.InferFolderType(gf.DisplayName)
|
||||
dbFolder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: gf.DisplayName,
|
||||
FullPath: gf.ID, // Graph uses opaque IDs as folder path
|
||||
FolderType: folderType,
|
||||
UnreadCount: gf.UnreadCount,
|
||||
TotalCount: gf.TotalCount,
|
||||
SyncEnabled: true,
|
||||
}
|
||||
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||
continue
|
||||
}
|
||||
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
|
||||
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch latest messages — no since filter, rely on upsert idempotency.
|
||||
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
|
||||
// making date-based filters unreliable across folder types.
|
||||
// Fetching top 100 newest per folder per sync is efficient enough.
|
||||
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
|
||||
.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}
|
||||
.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;
|
||||
align-items:center;justify-content:space-between;flex-shrink: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)}
|
||||
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)}
|
||||
|
||||
@@ -4,6 +4,7 @@ const adminRoutes = {
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
'/admin/security': renderSecurity,
|
||||
};
|
||||
|
||||
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' },
|
||||
]
|
||||
},
|
||||
{
|
||||
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() {
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
@@ -9,12 +9,27 @@ const S = {
|
||||
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
||||
filterUnread: false, filterAttachment: false,
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
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','/ui-prefs'),
|
||||
]);
|
||||
if (me) {
|
||||
S.me = me;
|
||||
@@ -23,6 +38,7 @@ async function init() {
|
||||
}
|
||||
if (providers) { S.providers = providers; updateProviderButtons(); }
|
||||
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
|
||||
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
|
||||
|
||||
await loadAccounts();
|
||||
await loadFolders();
|
||||
@@ -33,9 +49,47 @@ async function init() {
|
||||
}
|
||||
|
||||
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({},'','/'); }
|
||||
|
||||
// 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 => {
|
||||
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.target.contentEditable === 'true') return;
|
||||
@@ -45,11 +99,12 @@ async function init() {
|
||||
|
||||
initComposeDragResize();
|
||||
startPoller();
|
||||
mobSetView('list'); // initialise mobile view state
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
function updateProviderButtons() {
|
||||
['gmail','outlook'].forEach(p => {
|
||||
['gmail','outlook','outlook_personal'].forEach(p => {
|
||||
const btn = document.getElementById('btn-'+p);
|
||||
if (!btn) return;
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = S.accounts.map(a => `
|
||||
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
||||
el.innerHTML = S.accounts.map(a => {
|
||||
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">
|
||||
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
||||
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
||||
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
||||
${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 style="display:flex;gap:4px;flex-shrink:0">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
@@ -109,7 +169,13 @@ async function loadAccounts() {
|
||||
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() {
|
||||
['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-email').textContent=r.email_address;
|
||||
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-imap-host').value=r.imap_host||'';
|
||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
||||
}
|
||||
|
||||
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');
|
||||
if (r.sync_mode==='all' || !r.sync_days) {
|
||||
sel.value='all';
|
||||
@@ -199,12 +290,12 @@ async function openEditAccount(id) {
|
||||
sel.value = presetMap[r.sync_days] || 'days';
|
||||
}
|
||||
toggleSyncDaysField();
|
||||
|
||||
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
||||
connEl.style.display='none';
|
||||
errEl.style.display=r.last_error?'block':'none';
|
||||
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 hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
|
||||
if (!hidden.length) {
|
||||
@@ -249,6 +340,10 @@ function toggleSyncDaysField() {
|
||||
}
|
||||
|
||||
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 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;}
|
||||
@@ -263,11 +358,16 @@ async function testEditConnection() {
|
||||
|
||||
async function saveAccountEdit() {
|
||||
const id=document.getElementById('edit-account-id').value;
|
||||
const body={display_name:document.getElementById('edit-name').value.trim(),
|
||||
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
|
||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
|
||||
const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
|
||||
const body={display_name:document.getElementById('edit-name').value.trim()};
|
||||
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;
|
||||
if (pw) body.password=pw;
|
||||
}
|
||||
const modeVal = document.getElementById('edit-sync-mode').value;
|
||||
let syncMode='all', syncDays=0;
|
||||
if (modeVal==='days') {
|
||||
@@ -334,29 +434,132 @@ function renderFolders() {
|
||||
const el = document.getElementById('folders-by-account');
|
||||
const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a);
|
||||
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'];
|
||||
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
|
||||
const acc=accMap[parseInt(accId)];
|
||||
const accColor = acc?.color || '#888';
|
||||
const accEmail = acc?.email_address || 'Account '+accId;
|
||||
if(!folders?.length) return '';
|
||||
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
|
||||
return `<div class="nav-folder-header">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span>
|
||||
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px">
|
||||
const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
|
||||
|
||||
el.innerHTML = orderedAccounts.map(acc => {
|
||||
const folders = byAcc[acc.id];
|
||||
// Show account even if no folders yet — it was just added and syncer hasn't run
|
||||
if (!folders?.length) {
|
||||
const statusHtml = acc.last_error
|
||||
? `<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">
|
||||
⚠ ${esc(acc.last_error)}
|
||||
</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">⋮</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>
|
||||
</button>
|
||||
</div>`+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)}')"
|
||||
</div>
|
||||
${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})">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
||||
${esc(f.name)}
|
||||
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
||||
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">⊘</span>':''}
|
||||
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">\u29b8</span>':''}
|
||||
</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()">⋮</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('');
|
||||
|
||||
// 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) {
|
||||
@@ -512,6 +715,8 @@ function selectFolder(folderId, folderName) {
|
||||
:folderId==='starred'?document.getElementById('nav-starred')
|
||||
:document.getElementById('nav-f'+folderId);
|
||||
if (navEl) navEl.classList.add('active');
|
||||
mobCloseNav();
|
||||
mobSetView('list');
|
||||
loadMessages();
|
||||
}
|
||||
|
||||
@@ -708,6 +913,7 @@ function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
||||
|
||||
async function openMessage(id) {
|
||||
S.selectedMessageId=id; renderMessageList();
|
||||
mobSetView('detail');
|
||||
const detail=document.getElementById('message-detail');
|
||||
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
||||
const msg=await api('GET','/messages/'+id);
|
||||
@@ -941,6 +1147,8 @@ function showMessageMenu(e, id) {
|
||||
<div class="ctx-submenu">${moveItems}</div>
|
||||
</div>` : '';
|
||||
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="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>
|
||||
@@ -998,10 +1206,17 @@ function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)retur
|
||||
// ── Compose ────────────────────────────────────────────────────────────────
|
||||
let composeAttachments=[];
|
||||
|
||||
function populateComposeFrom() {
|
||||
function populateComposeFrom(preferAccountId) {
|
||||
const sel=document.getElementById('compose-from');
|
||||
if(!sel) return;
|
||||
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></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={}) {
|
||||
@@ -1021,6 +1236,7 @@ function openCompose(opts={}) {
|
||||
editor.innerHTML=opts.body||'';
|
||||
S.draftDirty=false;
|
||||
updateAttachList();
|
||||
populateComposeFrom(opts.accountId||null);
|
||||
showCompose();
|
||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
|
||||
startDraftAutosave();
|
||||
@@ -1073,6 +1289,7 @@ function openReplyTo(msgId) {
|
||||
if (!msg) return;
|
||||
openCompose({
|
||||
mode:'reply', replyId:msgId, title:'Reply',
|
||||
accountId: msg.account_id||null,
|
||||
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>`,
|
||||
});
|
||||
@@ -1085,6 +1302,7 @@ function openForward() {
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||
accountId: msg.account_id||null,
|
||||
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>`,
|
||||
});
|
||||
@@ -1096,6 +1314,7 @@ function openForwardAsAttachment() {
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
||||
accountId: msg.account_id||null,
|
||||
subject:'Fwd: '+(msg.subject||''),
|
||||
body:'',
|
||||
});
|
||||
@@ -1286,7 +1505,13 @@ async function sendMessage() {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1379,6 +1604,28 @@ async function openSettings() {
|
||||
openModal('settings-modal');
|
||||
loadSyncInterval();
|
||||
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() {
|
||||
@@ -1432,6 +1679,39 @@ async function disableMFA() {
|
||||
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'; }
|
||||
|
||||
// ── Context menu helper ────────────────────────────────────────────────────
|
||||
@@ -1504,7 +1784,7 @@ async function startPoller() {
|
||||
|
||||
function schedulePoll() {
|
||||
if (!POLLER.active) return;
|
||||
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval
|
||||
POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval
|
||||
}
|
||||
|
||||
async function runPoll() {
|
||||
@@ -1530,15 +1810,9 @@ async function runPoll() {
|
||||
sendOSNotification(newMsgs);
|
||||
}
|
||||
|
||||
// Refresh current view if we're looking at inbox/unified
|
||||
const isInboxView = S.currentFolder === 'unified' ||
|
||||
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox');
|
||||
if (isInboxView) {
|
||||
await loadMessages();
|
||||
// Always refresh the message list and folder counts when new mail arrives
|
||||
await loadFolders();
|
||||
} else {
|
||||
await loadFolders(); // update counts in sidebar
|
||||
}
|
||||
await loadMessages();
|
||||
}
|
||||
} catch(e) {
|
||||
// Network error — silent, retry next cycle
|
||||
@@ -1641,3 +1915,76 @@ function sendOSNotification(msgs) {
|
||||
// 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';
|
||||
}
|
||||
|
||||
411
web/static/js/contacts_calendar.js
Normal file
411
web/static/js/contacts_calendar.js
Normal 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'));
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
<a href="/admin/security" id="nav-security">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||
Security
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -35,5 +39,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/admin.js?v=16"></script>
|
||||
<script src="/static/js/admin.js?v=25"></script>
|
||||
{{end}}
|
||||
@@ -3,7 +3,20 @@
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="app">
|
||||
<div class="app" id="app-root" data-mob-view="list">
|
||||
<!-- Mobile top bar (hidden on desktop) -->
|
||||
<div class="mob-topbar" id="mob-topbar">
|
||||
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
|
||||
<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 -->
|
||||
<aside class="sidebar">
|
||||
<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>
|
||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||
</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 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>
|
||||
Starred
|
||||
</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>
|
||||
|
||||
@@ -45,6 +75,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list-panel">
|
||||
@@ -89,6 +121,105 @@
|
||||
<p>Choose a message from the list to read it</p>
|
||||
</div>
|
||||
</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">‹</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">›</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>
|
||||
|
||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||
@@ -179,12 +310,27 @@
|
||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||
<div class="provider-btns">
|
||||
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
Gmail
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
||||
Outlook
|
||||
<!-- Microsoft 365 icon -->
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
|
||||
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
|
||||
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
|
||||
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
|
||||
</svg>
|
||||
Microsoft 365
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
|
||||
<!-- Outlook icon (blue envelope) -->
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="3" fill="#0078D4"/>
|
||||
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
|
||||
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
|
||||
</svg>
|
||||
Outlook Personal
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||
@@ -224,6 +370,20 @@
|
||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||
<input type="hidden" id="edit-account-id">
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||
|
||||
<!-- 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-row">
|
||||
<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 Port</label><input type="number" id="edit-smtp-port"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Email history to sync</label>
|
||||
@@ -264,12 +426,34 @@
|
||||
|
||||
<!-- ── 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">
|
||||
<h2 style="margin-bottom:0">Settings</h2>
|
||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Profile</div>
|
||||
<div class="modal-field">
|
||||
<label>Username</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Email Address</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">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>
|
||||
@@ -300,6 +484,27 @@
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</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>
|
||||
|
||||
@@ -309,5 +514,6 @@
|
||||
{{end}}
|
||||
|
||||
{{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}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=16">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{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}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
220
web/templates/compose.html
Normal file
220
web/templates/compose.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
// 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}}
|
||||
@@ -19,7 +19,7 @@
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||
const k=new URLSearchParams(location.search).get('error');
|
||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||
</script>
|
||||
|
||||
95
web/templates/message.html
Normal file
95
web/templates/message.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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)"><${esc(msg.from_email)}></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}}
|
||||
Reference in New Issue
Block a user