11 Commits
latest ... main

Author SHA1 Message Date
ghostersk
d470c8b71f added calendar and contact - basic 2026-03-22 11:28:33 +00:00
ghostersk
9e7e87d11b updated outlook account sync 2026-03-15 20:27:29 +00:00
ghostersk
a9c7f4c575 personal outlook working - still needs tuning 2026-03-15 19:33:51 +00:00
ghostersk
1e08d5f50f add option to re-order accounts 2026-03-15 13:15:46 +00:00
ghostersk
015c00251b fixed Gmail authentication, hotmail still in progress 2026-03-15 09:04:40 +00:00
ghostersk
68c81ebaed update README.MD 2026-03-08 18:37:52 +00:00
ghostersk
f122d29282 added parameters to list bans and unban ip from terminal 2026-03-08 18:07:19 +00:00
ghostersk
d6e987f66c added per user ip block/whitelist 2026-03-08 17:54:13 +00:00
ghostersk
ef85246806 added IP Block and notification for failed logins 2026-03-08 17:35:58 +00:00
ghostersk
948e111cc6 fix image and link rendering 2026-03-08 12:14:58 +00:00
ghostersk
ac43075d62 fix attachments 2026-03-08 11:48:27 +00:00
34 changed files with 6374 additions and 290 deletions

5
.gitignore vendored
View File

@@ -3,4 +3,7 @@ data/*.db
data/*.db-shm
data/*db-wal
data/gowebmail.conf
data/*.txt
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace

View File

@@ -1,13 +1,16 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -15,6 +18,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/handlers"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/syncer"
@@ -47,6 +51,16 @@ func main() {
}
runDisableMFA(args[1])
return
case "--blocklist":
runBlockList()
return
case "--unblock":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
os.Exit(1)
}
runUnblock(args[1])
return
case "--help", "-h":
printHelp()
return
@@ -62,6 +76,18 @@ func main() {
if err != nil {
log.Fatalf("config load: %v", err)
}
logger.Init(cfg.Debug)
// Install a filtered log writer that suppresses harmless go-imap v1 parser
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
// on Gmail connections due to non-standard server responses. These don't affect
// functionality — go-imap recovers and continues syncing correctly.
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
"imap/client:",
"atom contains forbidden",
"atom contains bad",
"bad brackets nesting",
}})
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
@@ -73,7 +99,7 @@ func main() {
log.Fatalf("migrations: %v", err)
}
sc := syncer.New(database)
sc := syncer.New(database, cfg)
sc.Start()
defer sc.Stop()
@@ -85,10 +111,27 @@ func main() {
r.Use(middleware.CORS)
r.Use(cfg.HostCheckMiddleware)
// Custom error handlers for non-API paths
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
})
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
})
// Static files
r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
)
// Legacy /app path redirect — some browsers bookmark this; redirect to root
// which RequireAuth will then forward to login if not signed in.
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
if err != nil {
@@ -103,7 +146,7 @@ func main() {
// Public auth routes
auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
// MFA (session exists but mfa_verified=0)
@@ -119,11 +162,15 @@ func main() {
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
// App
app := r.PathPrefix("").Subrouter()
app.Use(middleware.RequireAuth(database, cfg))
app.HandleFunc("/", h.App.Index).Methods("GET")
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
// Admin UI
adminUI := r.PathPrefix("/admin").Subrouter()
@@ -133,6 +180,7 @@ func main() {
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
// API
api := r.PathPrefix("/api").Subrouter()
@@ -141,10 +189,13 @@ func main() {
// Profile / auth
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
// Providers (which OAuth providers are configured)
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
@@ -169,6 +220,8 @@ func main() {
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
@@ -180,6 +233,8 @@ func main() {
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST")
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
// Folders
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
@@ -189,6 +244,7 @@ func main() {
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
@@ -197,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))
@@ -213,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,
@@ -305,22 +399,106 @@ func runDisableMFA(username string) {
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
}
func runBlockList() {
database, close := openDB()
defer close()
blocks, err := database.ListIPBlocksWithUsername()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(blocks) == 0 {
fmt.Println("No blocked IPs.")
return
}
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"--", "-------------", "-----", "----------", "-------", "---------")
for _, b := range blocks {
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
var expires, remaining string
if b.IsPermanent || b.ExpiresAt == nil {
expires = "permanent"
remaining = "∞ (manual unblock)"
} else {
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
left := time.Until(*b.ExpiresAt)
if left <= 0 {
remaining = "expired (purge pending)"
} else {
h := int(left.Hours())
m := int(left.Minutes()) % 60
s := int(left.Seconds()) % 60
if h > 0 {
remaining = fmt.Sprintf("%dh %dm", h, m)
} else if m > 0 {
remaining = fmt.Sprintf("%dm %ds", m, s)
} else {
remaining = fmt.Sprintf("%ds", s)
}
}
}
username := b.LastUsername
if username == "" {
username = "(unknown)"
}
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
b.IP, username, b.Attempts, blockedAt, expires, remaining)
}
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
}
func runUnblock(ip string) {
database, close := openDB()
defer close()
if err := database.UnblockIP(ip); err != nil {
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
os.Exit(1)
}
fmt.Printf("IP %s has been unblocked.\n", ip)
}
func printHelp() {
fmt.Print(`GoMail — Admin CLI
fmt.Print(`GoWebMail — Admin CLI
Usage:
gowebmail Start the mail server
gowebmail --list-admin List all admin accounts (username, email, MFA status)
gowebmail --pw <username> <pass> Reset password for an admin account
gowebmail --mfa-off <username> Disable MFA for an admin account
gowebmail --blocklist List all currently blocked IP addresses
gowebmail --unblock <ip> Remove block for a specific IP address
Examples:
./gowebmail --list-admin
./gowebmail --pw admin "NewSecurePass123"
./gowebmail --mfa-off admin
./gowebmail --blocklist
./gowebmail --unblock 1.2.3.4
Note: These commands only work on admin accounts.
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
Regular user management is done through the web UI.
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
`)
}
// filteredWriter wraps an io.Writer and drops log lines containing any of the
// suppress substrings. Used to silence harmless go-imap internal parser errors.
type filteredWriter struct {
w io.Writer
suppress []string
}
func (f *filteredWriter) Write(p []byte) (n int, err error) {
line := string(bytes.TrimSpace(p))
for _, s := range f.suppress {
if strings.Contains(line, s) {
return len(p), nil // silently drop
}
}
return f.w.Write(p)
}

View File

@@ -1,4 +1,4 @@
// Package config loads and persists GoMail configuration from data/gowebmail.conf
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
package config
import (
@@ -21,6 +21,9 @@ type Config struct {
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Debug
Debug bool // set DEBUG=true in config to enable verbose logging
// Security
EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte
@@ -28,6 +31,23 @@ type Config struct {
SessionMaxAge int
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
// Notification SMTP (outbound alerts — separate from user mail accounts)
NotifyEnabled bool
NotifySMTPHost string
NotifySMTPPort int
NotifyFrom string
NotifyUser string // optional — leave blank for unauthenticated relay
NotifyPass string // optional
// Brute force protection
BruteEnabled bool
BruteMaxAttempts int
BruteWindowMins int
BruteBanHours int
BruteWhitelist []net.IP // IPs exempt from blocking
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
// Storage
DBPath string
@@ -59,7 +79,7 @@ var allFields = []configField{
defVal: "localhost",
comments: []string{
"--- Server ---",
"Public hostname of this GoMail instance (no port, no protocol).",
"Public hostname of this GoWebMail instance (no port, no protocol).",
"Examples: localhost | mail.example.com | 192.168.1.10",
"Used to build BASE_URL and OAuth redirect URIs automatically.",
"Also used in security checks to reject requests with unexpected Host headers.",
@@ -92,7 +112,7 @@ var allFields = []configField{
key: "SECURE_COOKIE",
defVal: "false",
comments: []string{
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
"Marks session cookies as Secure so browsers only send them over TLS.",
},
},
@@ -109,7 +129,7 @@ var allFields = []configField{
comments: []string{
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
"which GoMail uses to determine the real client IP and whether TLS is in use.",
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
" Examples:",
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
@@ -118,6 +138,108 @@ var allFields = []configField{
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
},
},
{
key: "NOTIFY_ENABLED",
defVal: "true",
comments: []string{
"--- Security Notifications ---",
"Send email alerts to users when their account is targeted by brute-force attacks.",
"Set to false to disable all security notification emails.",
},
},
{
key: "NOTIFY_SMTP_HOST",
defVal: "",
comments: []string{
"SMTP server hostname for sending security notification emails.",
"Example: smtp.example.com",
},
},
{
key: "NOTIFY_SMTP_PORT",
defVal: "587",
comments: []string{
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
},
},
{
key: "NOTIFY_FROM",
defVal: "",
comments: []string{
"Sender address for security notification emails. Example: security@example.com",
},
},
{
key: "NOTIFY_USER",
defVal: "",
comments: []string{
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "NOTIFY_PASS",
defVal: "",
comments: []string{
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "BRUTE_ENABLED",
defVal: "true",
comments: []string{
"--- Brute Force Protection ---",
"Enable automatic IP blocking after repeated failed logins.",
"Set to false to disable entirely.",
},
},
{
key: "BRUTE_MAX_ATTEMPTS",
defVal: "5",
comments: []string{
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
},
},
{
key: "BRUTE_WINDOW_MINUTES",
defVal: "30",
comments: []string{
"Time window in minutes for counting failed login attempts.",
},
},
{
key: "BRUTE_BAN_HOURS",
defVal: "12",
comments: []string{
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
},
},
{
key: "BRUTE_WHITELIST_IPS",
defVal: "",
comments: []string{
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
"Example: 192.168.1.1,10.0.0.1",
},
},
{
key: "GEO_BLOCK_COUNTRIES",
defVal: "",
comments: []string{
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
"Comma-separated 2-letter ISO country codes to DENY access from.",
"Example: CN,RU,KP",
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
},
},
{
key: "GEO_ALLOW_COUNTRIES",
defVal: "",
comments: []string{
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
"Example: SK,CZ,DE",
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
},
},
{
key: "DB_PATH",
defVal: "./data/gowebmail.db",
@@ -184,10 +306,15 @@ var allFields = []configField{
},
{
key: "MICROSOFT_TENANT_ID",
defVal: "common",
defVal: "consumers",
comments: []string{
"Use 'common' to allow any Microsoft account,",
"or your Azure tenant ID to restrict to one organisation.",
"Tenant endpoint to use for Microsoft OAuth2.",
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
" Use if registered as 'Personal accounts only'.",
" organizations - Work/school Microsoft 365 accounts only.",
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
},
},
{
@@ -228,7 +355,7 @@ func Load() (*Config, error) {
// get returns env var if set, else file value, else ""
get := func(key string) string {
// Only check env vars that are explicitly GoMail-namespaced or well-known.
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
// We deliberately do NOT fall back to generic vars like PORT to avoid
// picking up cloud-platform env vars unintentionally.
if v := os.Getenv("GOMAIL_" + key); v != "" {
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
Hostname: hostname,
BaseURL: baseURL,
DBPath: get("DB_PATH"),
Debug: atobool(get("DEBUG"), false),
EncryptionKey: encKey,
SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
TrustedProxies: trustedProxies,
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
NotifyFrom: get("NOTIFY_FROM"),
NotifyUser: get("NOTIFY_USER"),
NotifyPass: get("NOTIFY_PASS"),
GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: googleRedirect,
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
MicrosoftRedirectURL: outlookRedirect,
}
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
}
}
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
func (c *Config) IsIPWhitelisted(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, w := range c.BruteWhitelist {
if w.Equal(ip) {
return true
}
}
return false
}
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
func (c *Config) IsCountryAllowed(code string) bool {
code = strings.ToUpper(code)
if len(c.GeoBlockCountries) > 0 {
for _, bc := range c.GeoBlockCountries {
if bc == code {
return false
}
}
}
if len(c.GeoAllowCountries) > 0 {
for _, ac := range c.GeoAllowCountries {
if ac == code {
return true
}
}
return false
}
return true
}
// IsAllowedHost returns true if the request Host header matches our expected hostname.
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
func (c *Config) IsAllowedHost(requestHost string) bool {
@@ -443,7 +622,7 @@ func readConfigFile(path string) (map[string]string, error) {
func writeConfigFile(path string, values map[string]string) error {
var sb strings.Builder
sb.WriteString("# GoMail Configuration\n")
sb.WriteString("# GoWebMail Configuration\n")
sb.WriteString("# =====================\n")
sb.WriteString("# Auto-generated and updated on each startup.\n")
sb.WriteString("# Edit freely — your values are always preserved.\n")
@@ -576,7 +755,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
}
func logStartupInfo(cfg *Config) {
fmt.Printf("GoMail starting:\n")
fmt.Printf("GoWebMail starting:\n")
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
}
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
}
if cfg.GoogleClientID != "" {
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
}
if cfg.MicrosoftClientID != "" {
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
}
}
func parseIPList(s string) []net.IP {
var ips []net.IP
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if ip := net.ParseIP(raw); ip != nil {
ips = append(ips, ip)
}
}
return ips
}
func parseCountryList(s string) []string {
var codes []string
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(strings.ToUpper(raw))
if len(raw) == 2 {
codes = append(codes, raw)
}
}
return codes
}
func mustHex(n int) string {

9
go.mod
View File

@@ -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
View File

@@ -6,18 +6,16 @@ github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwd
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/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=

View File

@@ -3,11 +3,15 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft"
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
// ---- Microsoft / Outlook OAuth2 ----
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
var OutlookScopes = []string{
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
// audience for IMAP XOAUTH2 authentication.
var OutlookAuthScopes = []string{
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
"openid",
"profile",
"email",
}
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
if tenantID == "" {
tenantID = "consumers"
}
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
// "common" routes personal accounts through login.live.com which returns
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: OutlookScopes,
Scopes: OutlookAuthScopes,
Endpoint: microsoft.AzureADEndpoint(tenantID),
}
}
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
// authorization and exchanges it for an access token scoped to the Outlook
// resource (aud=https://outlook.office.com), which the IMAP server requires.
// The two-step approach is necessary because:
// - Azure personal app registrations only expose bare Graph scope names in their UI
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
// - Using the refresh_token against the Outlook resource produces a correct token
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
if tenantID == "" {
tenantID = "consumers"
}
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
params := url.Values{}
params.Set("grant_type", "refresh_token")
params.Set("client_id", clientID)
params.Set("client_secret", clientSecret)
params.Set("refresh_token", refreshToken)
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
if err != nil {
return nil, fmt.Errorf("build IMAP token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("IMAP token request: %w", err)
}
defer resp.Body.Close()
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode IMAP token response: %w", err)
}
if result.Error != "" {
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
}
if result.AccessToken == "" {
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
}
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
preview := result.AccessToken
if len(preview) > 30 {
preview = preview[:30] + "..."
}
parts := strings.Count(result.AccessToken, ".") + 1
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
parts, preview, params.Get("scope"))
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
return &oauth2.Token{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
Expiry: expiry,
}, nil
}
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
type MicrosoftUserInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
DisplayName string `json:"displayName"` // Graph field
Name string `json:"name"` // ID token claim
Mail string `json:"mail"`
EmailClaim string `json:"email"` // ID token claim
UserPrincipalName string `json:"userPrincipalName"`
PreferredUsername string `json:"preferred_username"` // ID token claim
}
// Email returns the best available email address.
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
if m.Mail != "" {
return m.Mail
}
if m.EmailClaim != "" {
return m.EmailClaim
}
if m.PreferredUsername != "" {
return m.PreferredUsername
}
return m.UserPrincipalName
}
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
// BestName returns the best available display name.
func (m *MicrosoftUserInfo) BestName() string {
if m.DisplayName != "" {
return m.DisplayName
}
return m.Name
}
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
// The ID token is issued alongside the access token and contains email/name claims.
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
client := cfg.Client(ctx, token)
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
idToken, _ := token.Extra("id_token").(string)
if idToken == "" {
return nil, fmt.Errorf("no id_token in Microsoft token response")
}
// JWT structure: header.payload.signature — decode the payload only
parts := strings.SplitN(idToken, ".", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("graph /me request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
return nil, fmt.Errorf("id_token base64 decode: %w", err)
}
var info MicrosoftUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
if err := json.Unmarshal(decoded, &info); err != nil {
return nil, fmt.Errorf("id_token JSON decode: %w", err)
}
if info.Email() == "" {
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
}
return &info, nil
}
// ---- Outlook Personal (Graph API) ----
// OutlookPersonalScopes are used for personal outlook.com accounts.
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
// Mail is accessed via Graph REST API instead of IMAP.
var OutlookPersonalScopes = []string{
"https://graph.microsoft.com/Mail.ReadWrite",
"https://graph.microsoft.com/Mail.Send",
"https://graph.microsoft.com/User.Read",
"offline_access",
"openid",
"email",
}
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
if tenantID == "" {
tenantID = "consumers"
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: OutlookPersonalScopes,
Endpoint: microsoft.AzureADEndpoint(tenantID),
}
}
// ---- Token refresh helpers ----
// IsTokenExpired reports whether the token expires within a 60-second buffer.
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
return ts.Token()
}
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
// Pass the credentials for both providers; the correct ones are selected based
// on provider ("gmail" or "outlook").
func RefreshAccountToken(ctx context.Context,
provider, refreshToken, baseURL,
googleClientID, googleClientSecret,
msClientID, msClientSecret, msTenantID string,
) (accessToken, newRefresh string, expiry time.Time, err error) {
switch provider {
case "gmail":
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
case "outlook":
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
rt := tok.RefreshToken
if rt == "" {
rt = refreshToken
}
return tok.AccessToken, rt, tok.Expiry, nil
case "outlook_personal":
// Personal outlook.com accounts use Graph API scopes — standard refresh works
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
baseURL+"/auth/outlook-personal/callback")
tok, err := RefreshToken(ctx, cfg, refreshToken)
if err != nil {
return "", "", time.Time{}, err
}
rt := tok.RefreshToken
if rt == "" {
rt = refreshToken
}
return tok.AccessToken, rt, tok.Expiry, nil
default:
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()
@@ -392,8 +441,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
return m, nil
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
@@ -405,12 +460,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
// cidMap: Content-ID → base64 data URI for inline images
cidMap := make(map[string]string)
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
// Rewrite cid: references in HTML to data: URIs
if html != "" && len(cidMap) > 0 {
html = rewriteCIDReferences(html, cidMap)
}
return
}
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
func rewriteCIDReferences(html string, cidMap map[string]string) string {
for cid, dataURI := range cidMap {
// Match both with and without angle brackets
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
// Some clients wrap CID in angle brackets in the src attribute
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
}
return html
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
return parsePartIndexed(contentType, transferEncoding, body, []int{})
}
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
}
mediaType = strings.ToLower(mediaType)
decoded := decodeTransfer(transferEncoding, body)
switch {
case mediaType == "text/plain":
text = decodeCharset(params["charset"], decoded)
case mediaType == "text/html":
html = decodeCharset(params["charset"], decoded)
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
disposition := part.Header.Get("Content-Disposition")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
if filename != "" {
wd := mime.WordDecoder{}
if dec, e := wd.DecodeHeader(filename); e == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
partMediaLower := strings.ToLower(partMedia)
// Inline image with Content-ID → embed as data URI for cid: resolution
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
decodedPart := decodeTransfer(partTE, partBody)
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
cidMap[contentID] = dataURI
// Don't add as attachment chip — it's inline
continue
}
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
!strings.HasPrefix(partMediaLower, "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath,
})
continue
}
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
@@ -430,11 +617,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
@@ -444,24 +635,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
disposition := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
if strings.EqualFold(dispType, "attachment") {
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
// Filename from Content-Disposition or Content-Type params
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
// Decode RFC 2047 encoded filename
if filename != "" {
wd := mime.WordDecoder{}
if dec, err := wd.DecodeHeader(filename); err == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
partMedia, _, _ := mime.ParseMediaType(partCT)
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePart(partCT, partTE, partBody)
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
@@ -471,13 +679,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
attachments = append(attachments, atts...)
}
default:
// Any other type treat as attachment if it has a filename
mt, _, _ := mime.ParseMediaType(contentType)
_ = mt
// Any other non-text type with a filename → treat as attachment
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// mimePathString converts an int path like [1,2] to "1.2".
func mimePathString(path []int) string {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
return strings.Join(parts, ".")
}
func decodeTransfer(encoding string, data []byte) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
@@ -671,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
@@ -712,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)
@@ -742,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)
@@ -763,7 +993,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// Use the sender's actual domain for Message-ID so it passes spam filters
domain := account.EmailAddress
if at := strings.Index(domain, "@"); at >= 0 {
@@ -781,24 +1012,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
buf.WriteString("\r\n")
// Plain text part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
hasAttachments := len(req.Attachments) > 0
if hasAttachments {
// Outer multipart/mixed wraps body + attachments
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
buf.WriteString("--" + mixedBoundary + "\r\n")
}
// Inner multipart/alternative: text/plain + text/html
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
// HTML part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw2 := quotedprintable.NewWriter(buf)
@@ -809,8 +1048,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
}
qpw2.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "--\r\n")
if hasAttachments {
for _, att := range req.Attachments {
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
buf.WriteString(encoded[i:end] + "\r\n")
}
}
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
}
buf.WriteString("--" + boundary + "--\r\n")
return msgID
}
@@ -875,6 +1137,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
// Returns the folder name that was used (for sync purposes).
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
var draftsName string
for _, mb := range mailboxes {
ft := InferFolderType(mb.Name, mb.Attributes)
if ft == "drafts" {
draftsName = mb.Name
break
}
}
if draftsName == "" {
return "", nil // no Drafts folder, skip silently
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
}
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
// raw message and parsing the requested MIME part path.
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
raw, err := c.FetchRawByUID(mailboxName, uid)
if err != nil {
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
}
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, "", "", fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
if err != nil {
return nil, "", "", err
}
return data, filename, contentType, nil
}
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
}
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
}
decoded := decodeTransfer(transferEncoding, body)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, "", "", fmt.Errorf("no boundary")
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, currentPath...), partIdx)
childPathStr := mimePathString(childPath)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
if childPathStr == targetPath {
// Found it
disposition := part.Header.Get("Content-Disposition")
_, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
wd2 := mime.WordDecoder{}
if dec, e := wd2.DecodeHeader(filename); e == nil {
filename = dec
}
partMedia, _, _ := mime.ParseMediaType(partCT)
return decodeTransfer(partTE, partBody), filename, partMedia, nil
}
// Recurse into multipart children
partMedia, _, _ := mime.ParseMediaType(partCT)
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
return data, fn, ct2, nil
}
}
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
// Leaf node — only matches if path is root (empty)
if targetPath == "" || targetPath == "1" {
return decoded, "", strings.ToLower(mediaType), nil
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")

97
internal/geo/geo.go Normal file
View File

@@ -0,0 +1,97 @@
// Package geo provides IP geolocation lookup using the free ip-api.com service.
// No API key is required. Rate limit: 45 requests/minute on the free tier.
// Results are cached in memory to reduce API calls.
package geo
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
type GeoResult struct {
CountryCode string
Country string
Cached bool
}
type cacheEntry struct {
result GeoResult
fetchedAt time.Time
}
var (
mu sync.Mutex
cache = make(map[string]*cacheEntry)
)
const cacheTTL = 24 * time.Hour
// Lookup returns the country for an IP address.
// Returns empty strings on failure (private IPs, rate limit, etc.).
func Lookup(ip string) GeoResult {
// Skip private / loopback
parsed := net.ParseIP(ip)
if parsed == nil || isPrivate(parsed) {
return GeoResult{}
}
mu.Lock()
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
mu.Unlock()
r := e.result
r.Cached = true
return r
}
mu.Unlock()
result := fetchFromAPI(ip)
mu.Lock()
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
mu.Unlock()
return result
}
func fetchFromAPI(ip string) GeoResult {
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("geo lookup failed for %s: %v", ip, err)
return GeoResult{}
}
defer resp.Body.Close()
var data struct {
Status string `json:"status"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
return GeoResult{}
}
return GeoResult{
CountryCode: strings.ToUpper(data.CountryCode),
Country: data.Country,
}
}
func isPrivate(ip net.IP) bool {
privateRanges := []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "::1/128", "fc00::/7",
}
for _, cidr := range privateRanges {
_, network, _ := net.ParseCIDR(cidr)
if network != nil && network.Contains(ip) {
return true
}
}
return false
}

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

@@ -0,0 +1,426 @@
// Package graph provides Microsoft Graph API mail access for personal
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
// so we use the Graph REST API instead with the JWT access token.
package graph
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
const baseURL = "https://graph.microsoft.com/v1.0/me"
// Client wraps Graph API calls for a single account.
type Client struct {
token string
account *models.EmailAccount
http *http.Client
}
// New creates a Graph client for the given account.
func New(account *models.EmailAccount) *Client {
return &Client{
token: account.AccessToken,
account: account,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
fullURL := path
if !strings.HasPrefix(path, "https://") {
fullURL = baseURL + path
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
}
return nil
}
func (c *Client) deleteReq(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
}
return nil
}
// ---- Folders ----
// GraphFolder represents a mail folder from Graph API.
type GraphFolder struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
TotalCount int `json:"totalItemCount"`
UnreadCount int `json:"unreadItemCount"`
WellKnown string `json:"wellKnownName"`
}
type foldersResp struct {
Value []GraphFolder `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListFolders returns all mail folders for the account.
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
var all []GraphFolder
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
for path != "" {
var resp foldersResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// ---- Messages ----
// EmailAddress wraps a Graph email address object.
type EmailAddress struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
}
// GraphMessage represents a mail message from Graph API.
type GraphMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
IsRead bool `json:"isRead"`
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
ReceivedDateTime time.Time `json:"receivedDateTime"`
HasAttachments bool `json:"hasAttachments"`
From *EmailAddress `json:"from"`
ToRecipients []EmailAddress `json:"toRecipients"`
CcRecipients []EmailAddress `json:"ccRecipients"`
Body struct {
Content string `json:"content"`
ContentType string `json:"contentType"`
} `json:"body"`
InternetMessageID string `json:"internetMessageId"`
}
// IsFlagged returns true if the message is flagged.
func (m *GraphMessage) IsFlagged() bool {
return m.Flag.Status == "flagged"
}
// FromName returns the sender display name.
func (m *GraphMessage) FromName() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Name
}
// FromEmail returns the sender email address.
func (m *GraphMessage) FromEmail() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Address
}
// ToList returns a comma-separated list of recipients.
func (m *GraphMessage) ToList() string {
var parts []string
for _, r := range m.ToRecipients {
parts = append(parts, r.EmailAddress.Address)
}
return strings.Join(parts, ", ")
}
type messagesResp struct {
Value []GraphMessage `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListMessages returns messages in a folder, optionally filtered by received date.
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
filter := ""
if !since.IsZero() {
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
}
top := 50
if maxResults > 0 && maxResults < top {
top = maxResults
}
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
folderID, top, filter)
var all []GraphMessage
for path != "" {
var resp messagesResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// GetMessage returns a single message with full body.
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
var msg GraphMessage
err := c.get(ctx, "/messages/"+msgID+
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
&msg)
return &msg, err
}
// GetMessageRaw returns the raw RFC 822 message bytes.
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
baseURL+"/messages/"+msgID+"/$value", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// MarkRead sets the isRead flag on a message.
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
}
// MarkFlagged sets or clears the flag on a message.
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
status := "notFlagged"
if flagged {
status = "flagged"
}
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
"flag": map[string]string{"flagStatus": status},
})
}
// DeleteMessage moves a message to Deleted Items (soft delete).
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
return c.deleteReq(ctx, "/messages/"+msgID)
}
// MoveMessage moves a message to a different folder.
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph move returned %d", resp.StatusCode)
}
return nil
}
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
// WellKnown field is not selectable via $select — we infer from displayName instead.
func InferFolderType(displayName string) string {
switch strings.ToLower(displayName) {
case "inbox":
return "inbox"
case "sent items", "sent":
return "sent"
case "drafts":
return "drafts"
case "deleted items", "trash", "bin":
return "trash"
case "junk email", "spam", "junk":
return "spam"
case "archive":
return "archive"
default:
return "custom"
}
}
// WellKnownToFolderType kept for compatibility.
func WellKnownToFolderType(wk string) string {
return InferFolderType(wk)
}
// ---- Send mail ----
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
func stripHTML(s string) string {
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&#39;", "'", "&nbsp;", " ").Replace(s)
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// SendMail sends an email via Graph API POST /me/sendMail.
// Sets both HTML and plain-text body to improve deliverability (spam filters
// penalise HTML-only messages with no text/plain alternative).
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
// Build body: always provide both HTML and plain text for better deliverability
body := map[string]string{
"contentType": "HTML",
"content": req.BodyHTML,
}
if req.BodyHTML == "" {
body["contentType"] = "Text"
body["content"] = req.BodyText
}
// Set explicit from with display name
var fromField interface{}
if c.account.DisplayName != "" {
fromField = map[string]interface{}{
"emailAddress": map[string]string{
"address": c.account.EmailAddress,
"name": c.account.DisplayName,
},
}
}
msg := map[string]interface{}{
"subject": req.Subject,
"body": body,
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
if fromField != nil {
msg["from"] = fromField
}
if len(req.Attachments) > 0 {
var atts []map[string]interface{}
for _, a := range req.Attachments {
atts = append(atts, map[string]interface{}{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": a.Filename,
"contentType": a.ContentType,
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
})
}
msg["attachments"] = atts
}
payload, err := json.Marshal(map[string]interface{}{
"message": msg,
"saveToSentItems": true,
})
if err != nil {
return fmt.Errorf("marshal sendMail: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/sendMail", strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("build sendMail request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return fmt.Errorf("sendMail request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
}
return nil
}
func graphRecipients(addrs []string) []map[string]interface{} {
result := []map[string]interface{}{}
for _, a := range addrs {
a = strings.TrimSpace(a)
if a != "" {
result = append(result, map[string]interface{}{
"emailAddress": map[string]string{"address": a},
})
}
}
return result
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/gorilla/mux"
@@ -108,9 +109,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
DisableMFA bool `json:"disable_mfa"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.DisableMFA {
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
"changed": changed,
})
}
// ---- IP Blocks ----
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
blocks, err := h.db.ListIPBlocks()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
return
}
if blocks == nil {
blocks = []db.IPBlock{}
}
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
}
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Reason string `json:"reason"`
BanHours int `json:"ban_hours"` // 0 = permanent
}
json.NewDecoder(r.Body).Decode(&req)
if req.IP == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
// Try geo lookup for the IP being manually blocked
g := geo.Lookup(req.IP)
if req.Reason == "" {
req.Reason = "Manual admin block"
}
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
ip := mux.Vars(r)["ip"]
if ip == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
if err := h.db.UnblockIP(ip); err != nil {
h.writeError(w, http.StatusInternalServerError, "unblock failed")
return
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Login Attempts ----
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
return
}
if stats == nil {
stats = []db.LoginAttemptStat{}
}
h.writeJSON(w, map[string]interface{}{"attempts": stats})
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -12,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"
@@ -43,8 +46,9 @@ func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) {
// GetProviders returns which OAuth providers are configured and enabled.
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 != "",
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
})
}
@@ -59,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 {
@@ -69,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,
}
}
@@ -475,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) {
@@ -532,6 +592,42 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
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 {
if uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID); iErr == nil && uid != 0 && account != nil {
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
_, _, atts := email.ParseMIMEFull(raw)
if len(atts) > 0 {
h.db.SaveAttachmentMeta(messageID, atts)
if fresh, fErr := h.db.GetAttachmentsByMessage(messageID, userID); fErr == nil {
msg.Attachments = fresh
}
}
}
c.Close()
}
}
}
h.writeJSON(w, msg)
}
@@ -542,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 {
val := "0"
if req.Read {
val = "1"
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" }
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: acc.ID, OpType: "flag_read",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
})
h.syncer.TriggerAccountSync(acc.ID)
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_read",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
})
h.syncer.TriggerAccountSync(account.ID)
}
h.writeJSON(w, map[string]bool{"ok": true})
}
@@ -570,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 {
val := "0"
if starred {
val = "1"
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" }
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: acc.ID, OpType: "flag_star",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
})
h.syncer.TriggerAccountSync(acc.ID)
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_star",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
})
h.syncer.TriggerAccountSync(account.ID)
}
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
}
@@ -606,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,
@@ -621,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,
@@ -652,13 +755,54 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward")
}
func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward-attachment")
}
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Parse multipart form (attachments present)
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
metaStr := r.FormValue("meta")
if err := json.NewDecoder(strings.NewReader(metaStr)).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid meta JSON")
return
}
if r.MultipartForm != nil {
for _, fheaders := range r.MultipartForm.File {
for _, fh := range fheaders {
f, err := fh.Open()
if err != nil {
continue
}
data, _ := io.ReadAll(f)
f.Close()
fileCT := fh.Header.Get("Content-Type")
if fileCT == "" {
fileCT = "application/octet-stream"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: fh.Filename,
ContentType: fileCT,
Size: int64(len(data)),
Data: data,
})
}
}
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
}
account, err := h.db.GetAccount(req.AccountID)
@@ -667,6 +811,48 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
return
}
// Forward-as-attachment: fetch original message as EML and attach it
if mode == "forward-attachment" && req.ForwardFromID > 0 {
origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID)
if origMsg != nil {
uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID)
if iErr == nil && uid != 0 && origAccount != nil {
if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil {
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
safe := sanitizeFilename(origMsg.Subject)
if safe == "" {
safe = "message"
}
req.Attachments = append(req.Attachments, models.Attachment{
Filename: safe + ".eml",
ContentType: "message/rfc822",
Data: raw,
})
}
c.Close()
}
}
}
}
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,
@@ -675,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})
}
@@ -1062,3 +1250,229 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
}
h.writeJSON(w, map[string]interface{}{"messages": msgs})
}
// ---- Attachment download ----
// DownloadAttachment fetches and streams a message attachment from IMAP.
func (h *APIHandler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
// Get attachment metadata from DB
attachmentID := pathInt64(r, "att_id")
att, err := h.db.GetAttachment(attachmentID, userID)
if err != nil || att == nil {
h.writeError(w, http.StatusNotFound, "attachment not found")
return
}
_ = messageID // already verified via GetAttachment ownership check
// Get IMAP info for the message
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(att.MessageID, userID)
if iErr != nil || uid == 0 || account == nil {
h.writeError(w, http.StatusNotFound, "message IMAP info not found")
return
}
c, cErr := email.Connect(context.Background(), account)
if cErr != nil {
h.writeError(w, http.StatusBadGateway, "IMAP connect failed: "+cErr.Error())
return
}
defer c.Close()
// att.ContentID stores the MIME part path (set during parse)
mimePartPath := att.ContentID
if mimePartPath == "" {
h.writeError(w, http.StatusNotFound, "attachment part path not stored")
return
}
data, filename, ct, fetchErr := c.FetchAttachmentRaw(folderPath, uid, mimePartPath)
if fetchErr != nil {
h.writeError(w, http.StatusBadGateway, "fetch failed: "+fetchErr.Error())
return
}
if filename == "" {
filename = att.Filename
}
if ct == "" {
ct = att.ContentType
}
if ct == "" {
ct = "application/octet-stream"
}
safe := sanitizeFilename(filename)
// For browser-viewable types, use inline disposition so they open in a new tab.
// For everything else, force download.
disposition := "attachment"
ctLower := strings.ToLower(ct)
if strings.HasPrefix(ctLower, "image/") ||
strings.HasPrefix(ctLower, "text/") ||
strings.HasPrefix(ctLower, "video/") ||
strings.HasPrefix(ctLower, "audio/") ||
ctLower == "application/pdf" {
disposition = "inline"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe))
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// ListAttachments returns stored attachment metadata for a message.
func (h *APIHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
atts, err := h.db.GetAttachmentsByMessage(messageID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list attachments")
return
}
if atts == nil {
atts = []models.Attachment{}
}
// Strip raw data from response, keep metadata only
type attMeta struct {
ID int64 `json:"id"`
MessageID int64 `json:"message_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
result := make([]attMeta, len(atts))
for i, a := range atts {
result[i] = attMeta{a.ID, a.MessageID, a.Filename, a.ContentType, a.Size}
}
h.writeJSON(w, result)
}
// ---- Mark folder all read ----
func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
ops, err := h.db.MarkFolderAllRead(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Enqueue all flag_read ops and trigger sync
accountIDs := map[int64]bool{}
for _, op := range ops {
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: op.AccountID, OpType: "flag_read",
RemoteUID: op.RemoteUID, FolderPath: op.FolderPath, Extra: "1",
})
accountIDs[op.AccountID] = true
}
for accID := range accountIDs {
h.syncer.TriggerAccountSync(accID)
}
h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)})
}
// ---- Save draft (IMAP APPEND to Drafts) ----
func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
json.NewDecoder(strings.NewReader(r.FormValue("meta"))).Decode(&req)
} else {
json.NewDecoder(r.Body).Decode(&req)
}
account, err := h.db.GetAccount(req.AccountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusBadRequest, "account not found")
return
}
// Build the MIME message bytes
var buf strings.Builder
buf.WriteString("From: " + account.EmailAddress + "\r\n")
if len(req.To) > 0 {
buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n")
}
buf.WriteString("Subject: " + req.Subject + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
buf.WriteString(req.BodyHTML)
raw := []byte(buf.String())
// Append to IMAP Drafts in background
go func() {
c, err := email.Connect(context.Background(), account)
if err != nil {
log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err)
return
}
defer c.Close()
draftsFolder, err := c.AppendToDrafts(raw)
if err != nil {
log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err)
return
}
if draftsFolder != "" {
// Trigger a sync of the drafts folder to pick up the saved draft
h.syncer.TriggerAccountSync(account.ID)
}
}()
h.writeJSON(w, map[string]bool{"ok": true})
}
// ensureAccountTokenFresh refreshes the OAuth access token for a Gmail/Outlook
// account if it is near expiry. Returns a pointer to the (possibly updated)
// account, or the original if no refresh was needed / possible.
func (h *APIHandler) ensureAccountTokenFresh(account *models.EmailAccount) *models.EmailAccount {
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
return account
}
if !auth.IsTokenExpired(account.TokenExpiry) {
return account
}
if account.RefreshToken == "" {
log.Printf("[oauth:%s] token expired, no refresh token stored", account.EmailAddress)
return account
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
ctx,
string(account.Provider),
account.RefreshToken,
h.cfg.BaseURL,
h.cfg.GoogleClientID, h.cfg.GoogleClientSecret,
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, h.cfg.MicrosoftTenantID,
)
if err != nil {
log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
return account
}
if err := h.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
return account
}
refreshed, err := h.db.GetAccount(account.ID)
if err != nil || refreshed == nil {
return account
}
log.Printf("[oauth:%s] access token refreshed for send (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
return refreshed
}

View File

@@ -17,3 +17,13 @@ type AppHandler struct {
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil)
}
// ViewMessage renders a single message in a full browser tab.
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "message", nil)
}
// ComposePage renders the compose form in a full browser tab.
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "compose", nil)
}

View File

@@ -4,9 +4,15 @@ import (
"crypto/rand"
"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)
@@ -142,8 +160,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
@@ -299,12 +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)
}

View File

@@ -0,0 +1,309 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// ======== Contacts ========
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var contacts interface{}
var err error
if q != "" {
contacts, err = h.db.SearchContacts(userID, q)
} else {
contacts, err = h.db.ListContacts(userID)
}
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
if contacts == nil {
contacts = []*models.Contact{}
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
c, err := h.db.GetContact(id, userID)
if err != nil || c == nil {
h.writeError(w, http.StatusNotFound, "contact not found")
return
}
h.writeJSON(w, c)
}
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if req.AvatarColor == "" {
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
req.AvatarColor = colors[int(userID)%len(colors)]
}
if err := h.db.CreateContact(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
if err := h.db.UpdateContact(&req, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteContact(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== Calendar Events ========
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
}
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*models.CalendarEvent{}
}
h.writeJSON(w, events)
}
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
ev, err := h.db.GetCalendarEvent(id, userID)
if err != nil || ev == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
h.writeJSON(w, ev)
}
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
existing, err := h.db.GetCalendarEvent(id, userID)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
req.UserID = userID
req.UID = existing.UID // preserve original UID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Tokens ========
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
tokens, err := h.db.ListCalDAVTokens(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
return
}
if tokens == nil {
tokens = []*models.CalDAVToken{}
}
h.writeJSON(w, tokens)
}
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Label string `json:"label"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Label == "" {
req.Label = "CalDAV token"
}
t, err := h.db.CreateCalDAVToken(userID, req.Label)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create token")
return
}
h.writeJSON(w, t)
}
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Server ========
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
// Compatible with any CalDAV client that supports basic calendar subscription.
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
userID, err := h.db.GetUserByCalDAVToken(token)
if err != nil || userID == 0 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Fetch events for next 12 months + past 3 months
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
for _, ev := range events {
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
if ev.Description != "" {
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
}
if ev.Location != "" {
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
}
if ev.AllDay {
// All-day events use DATE format
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
} else {
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
}
if ev.OrganizerEmail != "" {
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
}
if ev.Status != "" {
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
}
if ev.RecurrenceRule != "" {
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
}
fmt.Fprintf(w, "END:VEVENT\r\n")
}
fmt.Fprintf(w, "END:VCALENDAR\r\n")
}
func escICAL(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
// Fold long lines at 75 chars
if len(s) > 70 {
var out strings.Builder
for i, ch := range s {
if i > 0 && i%70 == 0 {
out.WriteString("\r\n ")
}
out.WriteRune(ch)
}
return out.String()
}
return s
}
func toICALDate(s string) string {
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s)
}
if err != nil {
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
}
return t.UTC().Format("20060102T150405Z")
}

View File

@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
}
return &Handlers{
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},

View File

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

24
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,24 @@
// Package logger provides a conditional debug logger controlled by config.Debug.
package logger
import "log"
var debugEnabled bool
// Init sets whether debug logging is active. Call once at startup.
func Init(debug bool) {
debugEnabled = debug
if debug {
log.Println("[logger] debug logging enabled")
}
}
// Debug logs a message only when debug mode is on.
func Debug(format string, args ...interface{}) {
if debugEnabled {
log.Printf(format, args...)
}
}
// IsEnabled returns true if debug logging is on.
func IsEnabled() bool { return debugEnabled }

View File

@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
}
// OTPAuthURL builds an otpauth:// URI for QR code generation.
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
func OTPAuthURL(issuer, accountName, secret string) string {
v := url.Values{}
v.Set("secret", secret)

View File

@@ -1,8 +1,10 @@
// Package middleware provides HTTP middleware for GoMail.
// Package middleware provides HTTP middleware for GoWebMail.
package middleware
import (
"context"
"fmt"
"html/template"
"log"
"net"
"net/http"
@@ -11,7 +13,9 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/notify"
)
type contextKey string
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
next.ServeHTTP(w, r)
})
}
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
if role != models.RoleAdmin {
if isAPIPath(r) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"forbidden"}`)
} else {
http.Error(w, "403 Forbidden", http.StatusForbidden)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"You don't have permission to access this page. Admin privileges are required.")
}
return
}
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
}
return r.RemoteAddr
}
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
// It must be called with the raw handler so it can intercept BEFORE auth.
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
if cfg.IsIPWhitelisted(ip) {
next.ServeHTTP(w, r)
return
}
// Resolve country for geo-block and attempt recording.
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
geoResult := geo.Lookup(ip)
// --- Geo block (apply to all requests) ---
if geoResult.CountryCode != "" {
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"Access from your country is not permitted.")
return
}
}
if !cfg.BruteEnabled || r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
// Check if already blocked
if database.IsIPBlocked(ip) {
renderErrorPage(w, r, http.StatusForbidden,
"IP Address Blocked",
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
return
}
// Wrap the response writer to detect a failed login (redirect to error vs success)
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(rw, r)
// Determine success: a redirect away from login = success
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
username := r.FormValue("username")
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
if !success && !rw.skipBrute {
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
if failures >= cfg.BruteMaxAttempts {
reason := "Too many failed logins"
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
// Send security notification to the targeted user (non-blocking goroutine)
go func(targetUsername string) {
user, _ := database.GetUserByUsername(targetUsername)
if user == nil {
user, _ = database.GetUserByEmail(targetUsername)
}
if user != nil && user.Email != "" {
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
Username: user.Username,
ToEmail: user.Email,
AttackerIP: ip,
Country: geoResult.Country,
CountryCode: geoResult.CountryCode,
Attempts: failures,
BlockedAt: time.Now().UTC(),
BanHours: cfg.BruteBanHours,
Hostname: cfg.Hostname,
})
}
}(username)
}
}
})
}
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
type loginResponseCapture struct {
http.ResponseWriter
statusCode int
location string
skipBrute bool
}
func (lrc *loginResponseCapture) WriteHeader(code int) {
lrc.statusCode = code
lrc.location = lrc.ResponseWriter.Header().Get("Location")
if lrc.Header().Get("X-Skip-Brute") == "1" {
lrc.skipBrute = true
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
}
lrc.ResponseWriter.WriteHeader(code)
}
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
renderErrorPage(w, r, status, title, message)
}
// renderErrorPage writes a themed HTML error page for browser requests,
// or a JSON error for API paths.
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
if isAPIPath(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, message)
return
}
// Back-button destination: always send to "/" which RequireAuth will
// transparently forward to /auth/login if the session is absent or invalid.
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
backHref := "/"
backLabel := "← Go Back"
data := struct {
Status int
Title string
Message string
BackHref string
BackLabel string
}{status, title, message, backHref, backLabel}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := errorPageTmpl.Execute(w, data); err != nil {
// Last-resort plain text fallback
fmt.Fprintf(w, "%d %s: %s", status, title, message)
}
}
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Status}} {{.Title}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css">
<style>
html, body { height: 100%; margin: 0; }
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg, #18191b);
font-family: 'DM Sans', sans-serif;
}
.error-card {
background: var(--surface, #232428);
border: 1px solid var(--border, #2e2f34);
border-radius: 16px;
padding: 48px 56px;
text-align: center;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
}
.error-code {
font-size: 64px;
font-weight: 700;
color: var(--accent, #6b8afd);
line-height: 1;
margin: 0 0 8px;
letter-spacing: -2px;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: var(--text, #e8e9ed);
margin: 0 0 12px;
}
.error-message {
font-size: 14px;
color: var(--muted, #8b8d97);
line-height: 1.6;
margin: 0 0 32px;
}
.error-back {
display: inline-block;
padding: 10px 24px;
background: var(--accent, #6b8afd);
color: #fff;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: opacity .15s;
}
.error-back:hover { opacity: .85; }
</style>
</head>
<body>
<div class="error-page">
<div class="error-card">
<div class="error-code">{{.Status}}</div>
<h1 class="error-title">{{.Title}}</h1>
<p class="error-message">{{.Message}}</p>
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
</div>
</div>
</body>
</html>`))

View File

@@ -4,7 +4,7 @@ import "time"
// ---- Users ----
// UserRole controls access level within GoMail.
// UserRole controls access level within GoWebMail.
type UserRole string
const (
@@ -12,7 +12,7 @@ const (
RoleUser UserRole = "user"
)
// User represents a GoMail application user.
// User represents a GoWebMail application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
@@ -83,9 +83,10 @@ type AuditPage struct {
type AccountProvider string
const (
ProviderGmail AccountProvider = "gmail"
ProviderOutlook AccountProvider = "outlook"
ProviderIMAPSMTP AccountProvider = "imap_smtp"
ProviderGmail AccountProvider = "gmail"
ProviderOutlook AccountProvider = "outlook"
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
ProviderIMAPSMTP AccountProvider = "imap_smtp"
)
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
@@ -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"`
}
@@ -213,6 +215,8 @@ type ComposeRequest struct {
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
}
// ---- Search ----
@@ -239,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
View File

@@ -0,0 +1,201 @@
// Package notify sends security alert emails using a configurable SMTP relay.
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
package notify
import (
"bytes"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"text/template"
"time"
"github.com/ghostersk/gowebmail/config"
)
// BruteForceAlert holds the data for the brute-force notification email.
type BruteForceAlert struct {
Username string
ToEmail string
AttackerIP string
Country string
CountryCode string
Attempts int
BlockedAt time.Time
BanHours int // 0 = permanent
AppName string
Hostname string
}
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
To: {{.ToEmail}}
Subject: Security Alert: Failed login attempts on your account
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hello {{.Username}},
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
We detected multiple failed login attempts on your account and have
automatically blocked the source IP address.
Account targeted : {{.Username}}
Source IP : {{.AttackerIP}}
{{- if .Country}}
Country : {{.Country}} ({{.CountryCode}})
{{- end}}
Failed attempts : {{.Attempts}}
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
{{- if eq .BanHours 0}}
Block duration : Permanent (administrator action required to unblock)
{{- else}}
Block duration : {{.BanHours}} hours
{{- end}}
If this was you, you may have mistyped your password. The block will
{{- if eq .BanHours 0}} remain until removed by an administrator.
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
If you did not attempt to log in, your account credentials may be at
risk. We recommend changing your password as soon as possible.
This is an automated message. Please do not reply.
--
{{.AppName}} Security
{{.Hostname}}
`))
type templateData struct {
BruteForceAlert
From string
}
// SendBruteForceAlert sends a security notification email to the targeted user.
// It runs in a goroutine — errors are logged but not returned.
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
return
}
if alert.ToEmail == "" {
return
}
go func() {
if err := sendAlert(cfg, alert); err != nil {
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
} else {
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
}
}()
}
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
if alert.AppName == "" {
alert.AppName = "GoWebMail"
}
if alert.Hostname == "" {
alert.Hostname = cfg.Hostname
}
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
var buf bytes.Buffer
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
return fmt.Errorf("template execute: %w", err)
}
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
// Choose auth method
var auth smtp.Auth
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
}
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
if cfg.NotifySMTPPort == 465 {
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer c.Close()
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
if ok, _ := c.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := c.StartTLS(tlsCfg); err != nil {
// Log but continue — some relays advertise STARTTLS but don't enforce it
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
// sendTLS sends via direct TLS connection (port 465).
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("tls dial %s: %w", addr, err)
}
// Resolve host for the smtp.NewClient call
bareHost, _, _ := net.SplitHostPort(addr)
if bareHost == "" {
bareHost = host
}
c, err := smtp.NewClient(conn, bareHost)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
// Normalise line endings to CRLF
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
if _, err := w.Write([]byte(normalized)); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}

View File

@@ -9,31 +9,51 @@ import (
"context"
"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,
stop: make(chan struct{}),
pushCh: make(map[int64]chan struct{}),
db: database,
cfg: cfg,
stop: make(chan struct{}),
pushCh: make(map[int64]chan struct{}),
reconcileCh: make(chan struct{}, 1),
}
}
// TriggerReconcile asks the main loop to immediately check for new accounts.
// Safe to call from any goroutine; non-blocking.
func (s *Scheduler) TriggerReconcile() {
select {
case s.reconcileCh <- struct{}{}:
default:
}
}
@@ -123,6 +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)
}
}
@@ -446,6 +495,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
msg.FolderID = dbFolder.ID
if err := s.db.UpsertMessage(msg); err == nil {
newMessages++
// Save attachment metadata if any (enables download)
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
@@ -484,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
@@ -492,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)
@@ -536,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.
@@ -560,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
@@ -570,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)
}
}

View File

@@ -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}
@@ -369,10 +377,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
/* ---- Attachment chips ---- */
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
text-decoration:none;color:inherit}
.attachment-chip:hover{background:var(--border2)}
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
padding:8px 14px;border-bottom:1px solid var(--border)}
/* Drag-and-drop compose overlay */
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
/* ── Email tag input ─────────────────────────────────────────── */
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
@@ -495,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)}

View File

@@ -1,9 +1,10 @@
// GoMail Admin SPA
// GoWebMail Admin SPA
const adminRoutes = {
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin/security': renderSecurity,
};
function navigate(path) {
@@ -26,7 +27,7 @@ async function renderUsers() {
el.innerHTML = `
<div class="admin-page-header">
<h1>Users</h1>
<p>Manage GoMail accounts and permissions.</p>
<p>Manage GoWebMail accounts and permissions.</p>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
@@ -67,16 +68,19 @@ async function loadUsersTable() {
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
el.innerHTML = `<table class="data-table">
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
<tbody>${r.map(u => `
<tr>
<td style="font-weight:500">${esc(u.username)}</td>
<td style="color:var(--muted)">${esc(u.email)}</td>
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
<td style="display:flex;gap:6px;justify-content:flex-end">
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
</td>
</tr>`).join('')}
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
else toast((r && r.error) || 'Delete failed', 'error');
}
async function disableMFA(userId, username) {
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
else toast((r && r.error) || 'Failed to disable MFA', 'error');
}
function openResetPassword(userId, username) {
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
if (!pw) return;
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
if (r && r.ok) toast('Password reset for ' + username, 'success');
else toast((r && r.error) || 'Failed to reset password', 'error');
});
}
// ============================================================
// Settings
// ============================================================
@@ -182,6 +203,34 @@ const SETTINGS_META = [
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
]
},
{
group: 'Security Notifications',
fields: [
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
]
},
{
group: 'Brute Force Protection',
fields: [
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
]
},
{
group: 'Geo Blocking',
fields: [
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
]
},
];
async function renderSettings() {
@@ -308,4 +357,135 @@ function eventBadge(evt) {
navigate(a.getAttribute('href'));
});
});
})();
})();
// ============================================================
// Security — IP Blocks & Login Attempts
// ============================================================
async function renderSecurity() {
const el = document.getElementById('admin-content');
el.innerHTML = `
<div class="admin-page-header">
<h1>Security</h1>
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
</div>
<div class="admin-card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
</div>
<div id="blocks-table"><div class="spinner"></div></div>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
</div>
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal">
<div class="modal" style="max-width:420px">
<h2>Block IP Address</h2>
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
</div>
</div>
</div>`;
loadIPBlocks();
loadLoginAttempts();
}
async function loadIPBlocks() {
const el = document.getElementById('blocks-table');
if (!el) return;
const r = await api('GET', '/admin/ip-blocks');
const blocks = r?.blocks || [];
if (!blocks.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
</tr></thead>
<tbody>
${blocks.map(b => `<tr>
<td><code>${esc(b.ip)}</code></td>
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
<td>${esc(b.reason)}</td>
<td>${b.attempts||0}</td>
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
async function loadLoginAttempts() {
const el = document.getElementById('attempts-table');
if (!el) return;
const r = await api('GET', '/admin/login-attempts');
const attempts = r?.attempts || [];
if (!attempts.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
</tr></thead>
<tbody>
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
<td><code>${esc(a.ip)}</code></td>
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
<td>${a.total}</td>
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
<td style="font-size:11px">${a.last_seen||'—'}</td>
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
function openAddBlock() { openModal('add-block-modal'); }
async function submitAddBlock() {
const ip = document.getElementById('block-ip').value.trim();
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
const hours = parseInt(document.getElementById('block-hours').value) || 0;
if (!ip) { toast('IP address required', 'error'); return; }
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
else toast(r?.error || 'Failed', 'error');
}
async function unblockIP(ip) {
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
const data = await r.json();
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
else toast(data?.error || 'Failed', 'error');
}
function blockFromAttempt(ip) {
document.getElementById('block-ip').value = ip;
document.getElementById('block-reason').value = 'Manual block from login attempts';
openModal('add-block-modal');
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
// ── Contacts & Calendar ─────────────────────────────────────────────────────
let _currentView = 'mail';
// ======== VIEW SWITCHING ========
// Uses data-view attribute on #app-root to switch panels via CSS,
// avoiding direct style manipulation of elements that may not exist.
function _setView(view) {
_currentView = view;
// Update nav item active states
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
}
function showMail() {
_setView('mail');
document.getElementById('nav-unified')?.classList.add('active');
}
function showContacts() {
_setView('contacts');
document.getElementById('nav-contacts')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
loadContacts();
}
function showCalendar() {
_setView('calendar');
document.getElementById('nav-calendar')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
calRender();
}
// Patch selectFolder — called from app.js sidebar click handlers.
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
// Avoids infinite recursion by checking _currentView before doing anything.
(function() {
const _orig = window.selectFolder;
window.selectFolder = function(folderId, folderName) {
if (_currentView !== 'mail') {
showMail();
// Give the DOM a tick to re-show the mail panels before loading
setTimeout(function() {
_orig && _orig(folderId, folderName);
}, 10);
return;
}
_orig && _orig(folderId, folderName);
};
})();
// ======== CONTACTS ========
let _contacts = [];
let _editingContactId = null;
async function loadContacts() {
const data = await api('GET', '/contacts');
_contacts = data || [];
renderContacts(_contacts);
}
function renderContacts(list) {
const el = document.getElementById('contacts-list');
if (!el) return;
if (!list || list.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
<p>No contacts yet. Click "+ New Contact" to add one.</p>
</div>`;
return;
}
el.innerHTML = list.map(c => {
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
const color = c.avatar_color || '#6b7280';
const meta = [c.email, c.company].filter(Boolean).join(' · ');
return `<div class="contact-card" onclick="openContactForm(${c.id})">
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
<div class="contact-info">
<div class="contact-name">${esc(c.display_name || c.email)}</div>
<div class="contact-meta">${esc(meta)}</div>
</div>
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
</div>`;
}).join('');
}
function filterContacts(q) {
if (!q) { renderContacts(_contacts); return; }
const lower = q.toLowerCase();
renderContacts(_contacts.filter(c =>
(c.display_name||'').toLowerCase().includes(lower) ||
(c.email||'').toLowerCase().includes(lower) ||
(c.company||'').toLowerCase().includes(lower)
));
}
function composeToContact(email) {
showMail();
setTimeout(() => {
if (typeof openCompose === 'function') openCompose();
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
}, 50);
}
function openContactForm(id) {
_editingContactId = id || null;
const delBtn = document.getElementById('cf-delete-btn');
if (id) {
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
if (delBtn) delBtn.style.display = '';
const c = _contacts.find(x => x.id === id);
if (c) {
document.getElementById('cf-name').value = c.display_name || '';
document.getElementById('cf-email').value = c.email || '';
document.getElementById('cf-phone').value = c.phone || '';
document.getElementById('cf-company').value = c.company || '';
document.getElementById('cf-notes').value = c.notes || '';
}
} else {
document.getElementById('contact-modal-title').textContent = 'New Contact';
if (delBtn) delBtn.style.display = 'none';
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
}
openModal('contact-modal');
}
async function saveContact() {
const body = {
display_name: document.getElementById('cf-name').value.trim(),
email: document.getElementById('cf-email').value.trim(),
phone: document.getElementById('cf-phone').value.trim(),
company: document.getElementById('cf-company').value.trim(),
notes: document.getElementById('cf-notes').value.trim(),
};
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
if (_editingContactId) {
await api('PUT', `/contacts/${_editingContactId}`, body);
} else {
await api('POST', '/contacts', body);
}
closeModal('contact-modal');
await loadContacts();
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
}
async function deleteContact() {
if (!_editingContactId) return;
if (!confirm('Delete this contact?')) return;
await api('DELETE', `/contacts/${_editingContactId}`);
closeModal('contact-modal');
await loadContacts();
toast('Contact deleted', 'success');
}
// ======== CALENDAR ========
const CAL = {
view: 'month',
cursor: new Date(),
events: [],
};
function calSetView(v) {
CAL.view = v;
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
calRender();
}
function calNav(dir) {
if (CAL.view === 'month') {
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
} else {
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
}
calRender();
}
function calGoToday() { CAL.cursor = new Date(); calRender(); }
async function calRender() {
const gridEl = document.getElementById('cal-grid');
if (!gridEl) return;
let from, to;
if (CAL.view === 'month') {
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
from = new Date(from.getTime() - from.getDay() * 86400000);
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
} else {
const dow = CAL.cursor.getDay();
from = new Date(CAL.cursor.getTime() - dow * 86400000);
to = new Date(from.getTime() + 6 * 86400000);
}
const fmt = d => d.toISOString().split('T')[0];
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
CAL.events = data || [];
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const titleEl = document.getElementById('cal-title');
if (CAL.view === 'month') {
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
calRenderMonth(from, to);
} else {
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
calRenderWeek(from);
}
}
function calRenderMonth(from, to) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-grid-month">`;
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
const cur = new Date(from);
const curMonth = CAL.cursor.getMonth();
while (cur <= to) {
const dateStr = cur.toISOString().split('T')[0];
const isToday = cur.getTime() === today.getTime();
const isOther = cur.getMonth() !== curMonth;
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const shown = dayEvents.slice(0, 3);
const more = dayEvents.length - 3;
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
</div>`;
cur.setDate(cur.getDate() + 1);
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
function calRenderWeek(weekStart) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-week-grid">`;
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
}
for (let h=0;h<24;h++) {
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
html += `<div class="cal-time-col">${label}</div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const dateStr = d.toISOString().split('T')[0];
const slotEvs = CAL.events.filter(ev => {
if (!ev.start_time) return false;
return ev.start_time.startsWith(dateStr) &&
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
});
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-cell${isT?' today':''}"
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
</div>`;
}
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
// ======== EVENT FORM ========
let _editingEventId = null;
let _selectedEvColor = '#0078D4';
function selectEvColor(el) {
_selectedEvColor = el.dataset.color;
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
el.style.borderColor = 'white';
}
function openEventForm(id, defaultStart) {
_editingEventId = id || null;
const delBtn = document.getElementById('ev-delete-btn');
_selectedEvColor = '#0078D4';
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
if (id) {
document.getElementById('event-modal-title').textContent = 'Edit Event';
if (delBtn) delBtn.style.display = '';
const ev = CAL.events.find(e => e.id === id);
if (ev) {
document.getElementById('ev-title').value = ev.title||'';
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-allday').checked = !!ev.all_day;
document.getElementById('ev-location').value = ev.location||'';
document.getElementById('ev-desc').value = ev.description||'';
_selectedEvColor = ev.color||'#0078D4';
document.querySelectorAll('#ev-colors span').forEach(s => {
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
});
}
} else {
document.getElementById('event-modal-title').textContent = 'New Event';
if (delBtn) delBtn.style.display = 'none';
document.getElementById('ev-title').value = '';
const start = defaultStart || new Date().toISOString().substring(0,16);
document.getElementById('ev-start').value = start;
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
document.getElementById('ev-allday').checked = false;
document.getElementById('ev-location').value = '';
document.getElementById('ev-desc').value = '';
}
openModal('event-modal');
}
async function saveEvent() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { toast('Title is required','error'); return; }
const body = {
title,
start_time: document.getElementById('ev-start').value.replace('T',' '),
end_time: document.getElementById('ev-end').value.replace('T',' '),
all_day: document.getElementById('ev-allday').checked,
location: document.getElementById('ev-location').value.trim(),
description:document.getElementById('ev-desc').value.trim(),
color: _selectedEvColor,
status: 'confirmed',
};
if (_editingEventId) {
await api('PUT', `/calendar/events/${_editingEventId}`, body);
} else {
await api('POST', '/calendar/events', body);
}
closeModal('event-modal');
await calRender();
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
}
async function deleteEvent() {
if (!_editingEventId) return;
if (!confirm('Delete this event?')) return;
await api('DELETE', `/calendar/events/${_editingEventId}`);
closeModal('event-modal');
await calRender();
toast('Event deleted', 'success');
}
// ======== CALDAV ========
async function showCalDAVSettings() {
openModal('caldav-modal');
await loadCalDAVTokens();
}
async function loadCalDAVTokens() {
const tokens = await api('GET', '/caldav/tokens') || [];
const el = document.getElementById('caldav-tokens-list');
if (!el) return;
if (!tokens.length) {
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
return;
}
el.innerHTML = tokens.map(t => {
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
return `<div class="caldav-token-row">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
</div>
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`;
}).join('');
}
async function createCalDAVToken() {
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
await api('POST', '/caldav/tokens', { label });
document.getElementById('caldav-label').value = '';
await loadCalDAVTokens();
toast('Token created', 'success');
}
async function revokeCalDAVToken(id) {
if (!confirm('Revoke this token?')) return;
await api('DELETE', `/caldav/tokens/${id}`);
await loadCalDAVTokens();
toast('Token revoked', 'success');
}
function copyCalDAVUrl(url) {
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
}

View File

@@ -1,4 +1,4 @@
// GoMail shared utilities - loaded on every page
// GoWebMail shared utilities - loaded on every page
// ---- API helper ----
async function api(method, path, body) {

View File

@@ -23,6 +23,10 @@
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
Audit Log
</a>
<a href="/admin/security" id="nav-security">
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
Security
</a>
</div>
</nav>
@@ -35,5 +39,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js"></script>
<script src="/static/js/admin.js?v=25"></script>
{{end}}

View File

@@ -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">
@@ -58,13 +90,14 @@
<span id="filter-label">Filter</span>
</button>
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
</div>
</div>
</div>
@@ -88,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">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
</button>
</div>
</div>
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
</div>
</div>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<div class="modal" style="max-width:480px">
<h2 id="contact-modal-title">New Contact</h2>
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
<button class="modal-submit" onclick="saveContact()">Save</button>
</div>
</div>
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal">
<div class="modal" style="max-width:520px">
<h2 id="event-modal-title">New Event</h2>
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
<div class="modal-row">
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
<input id="ev-allday" type="checkbox" style="width:auto">
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
</div>
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-field"><label>Color</label>
<div style="display:flex;gap:6px" id="ev-colors">
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
<button class="modal-submit" onclick="saveEvent()">Save</button>
</div>
</div>
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal">
<div class="modal" style="max-width:560px">
<h2>CalDAV / Calendar Sharing</h2>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
</p>
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
<div style="display:flex;gap:8px;align-items:center">
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
</div>
<div class="modal-actions" style="margin-top:16px">
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
@@ -178,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>
@@ -223,15 +370,31 @@
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
<input type="hidden" id="edit-account-id">
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
<div id="edit-oauth-section" style="display:none">
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
⚠️ Access token has expired — sync and send will fail until you reconnect.
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
</div>
</div>
<div class="modal-row">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
<div id="edit-creds-section">
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
</div>
<div class="modal-row">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
</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>
@@ -263,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>
@@ -299,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>
@@ -308,5 +514,6 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=12"></script>
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
{{end}}

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=12"></script>
<script src="/static/js/gowebmail.js?v=58"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

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

@@ -0,0 +1,220 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
</div>
</div>
<div id="compose-page-form">
<!-- From -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
</div>
<!-- To -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- Subject -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments -->
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
Attach file
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
</label>
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
</div>
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Parse URL params
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const cpAttachments = [];
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
if (body instanceof FormData) { opts.body = body; }
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Tag field (simple comma/enter separated)
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
const inp = el.querySelector('input');
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
e.preventDefault();
const v = inp.value.trim().replace(/,$/, '');
if (v) addTagTo(id, v);
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value) {
const tags = el.querySelectorAll('.tag-chip');
if (tags.length) tags[tags.length-1].remove();
}
});
inp.addEventListener('blur', () => {
const v = inp.value.trim().replace(/,$/, '');
if (v) { addTagTo(id, v); inp.value = ''; }
});
}
function addTagTo(fieldId, email) {
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
el.insertBefore(chip, inp);
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
}
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
const chip = document.createElement('span');
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
}
async function loadAccounts() {
const accounts = await apiCall('GET', '/accounts') || [];
const sel = document.getElementById('cp-from');
accounts.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
sel.appendChild(opt);
});
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
const msg = await apiCall('GET', '/messages/' + replyId);
if (!msg) return;
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
addTagTo('cp-to-tags', msg.from_email || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
// Set from to same account
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
}
async function prefillForward() {
if (!forwardId) return;
document.getElementById('compose-page-title').textContent = 'Forward';
const msg = await apiCall('GET', '/messages/' + forwardId);
if (!msg) return;
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
}
async function sendFromPage() {
const btn = document.getElementById('send-page-btn');
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 0,
};
let r;
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
if (cpAttachments.length) {
const fd = new FormData();
fd.append('meta', JSON.stringify(meta));
cpAttachments.forEach(f => fd.append('file', f, f.name));
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
r = await resp.json().catch(() => null);
} else {
r = await apiCall('POST', endpoint, meta);
}
btn.disabled = false; btn.textContent = 'Send';
if (r?.ok) {
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
}
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
</script>
{{end}}

View File

@@ -19,7 +19,7 @@
{{end}}
{{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>

View File

@@ -0,0 +1,95 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<div id="msg-actions" style="display:flex;gap:8px"></div>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
</div>
</div>
<div id="msg-content">
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
const atts = msg.attachments || [];
const attHtml = atts.length ? `
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)">&lt;${esc(msg.from_email)}&gt;</span>` : ''}
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
</div>
${attHtml}`;
// Write body into sandboxed iframe
const iframe = document.getElementById('msg-iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><style>
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
img{max-width:100%;height:auto}a{color:#0078D4}
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
doc.close();
// Auto-resize iframe
setTimeout(() => {
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
}, 200);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
}
load();
</script>
{{end}}