mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d470c8b71f | ||
|
|
9e7e87d11b | ||
|
|
a9c7f4c575 | ||
|
|
1e08d5f50f | ||
|
|
015c00251b | ||
|
|
68c81ebaed | ||
|
|
f122d29282 | ||
|
|
d6e987f66c | ||
|
|
ef85246806 | ||
|
|
948e111cc6 | ||
|
|
ac43075d62 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gowebmail.conf
|
||||
data/*.txt
|
||||
gowebmail-devplan.md
|
||||
testrun/
|
||||
webmail.code-workspace
|
||||
@@ -1,13 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/handlers"
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
|
||||
@@ -47,6 +51,16 @@ func main() {
|
||||
}
|
||||
runDisableMFA(args[1])
|
||||
return
|
||||
case "--blocklist":
|
||||
runBlockList()
|
||||
return
|
||||
case "--unblock":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||
os.Exit(1)
|
||||
}
|
||||
runUnblock(args[1])
|
||||
return
|
||||
case "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
@@ -62,6 +76,18 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("config load: %v", err)
|
||||
}
|
||||
logger.Init(cfg.Debug)
|
||||
|
||||
// Install a filtered log writer that suppresses harmless go-imap v1 parser
|
||||
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
|
||||
// on Gmail connections due to non-standard server responses. These don't affect
|
||||
// functionality — go-imap recovers and continues syncing correctly.
|
||||
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
|
||||
"imap/client:",
|
||||
"atom contains forbidden",
|
||||
"atom contains bad",
|
||||
"bad brackets nesting",
|
||||
}})
|
||||
|
||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
@@ -73,7 +99,7 @@ func main() {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
sc := syncer.New(database)
|
||||
sc := syncer.New(database, cfg)
|
||||
sc.Start()
|
||||
defer sc.Stop()
|
||||
|
||||
@@ -85,10 +111,27 @@ func main() {
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(cfg.HostCheckMiddleware)
|
||||
|
||||
// Custom error handlers for non-API paths
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||
})
|
||||
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||
})
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(
|
||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||
)
|
||||
// Legacy /app path redirect — some browsers bookmark this; redirect to root
|
||||
// which RequireAuth will then forward to login if not signed in.
|
||||
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
|
||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||
if err != nil {
|
||||
@@ -103,7 +146,7 @@ func main() {
|
||||
// Public auth routes
|
||||
auth := r.PathPrefix("/auth").Subrouter()
|
||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
||||
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||
|
||||
// MFA (session exists but mfa_verified=0)
|
||||
@@ -119,11 +162,15 @@ func main() {
|
||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||
|
||||
// App
|
||||
app := r.PathPrefix("").Subrouter()
|
||||
app.Use(middleware.RequireAuth(database, cfg))
|
||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
||||
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
|
||||
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
|
||||
|
||||
// Admin UI
|
||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||
@@ -133,6 +180,7 @@ func main() {
|
||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||
|
||||
// API
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
@@ -141,10 +189,13 @@ func main() {
|
||||
|
||||
// Profile / auth
|
||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||
|
||||
// Providers (which OAuth providers are configured)
|
||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
233
config/config.go
233
config/config.go
@@ -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
9
go.mod
@@ -5,14 +5,13 @@ go 1.26
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -6,18 +6,16 @@ github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwd
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -3,11 +3,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/microsoft"
|
||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
||||
|
||||
// ---- Microsoft / Outlook OAuth2 ----
|
||||
|
||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
||||
var OutlookScopes = []string{
|
||||
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||
// audience for IMAP XOAUTH2 authentication.
|
||||
var OutlookAuthScopes = []string{
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
||||
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||
// "common" routes personal accounts through login.live.com which returns
|
||||
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookScopes,
|
||||
Scopes: OutlookAuthScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
||||
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||
// authorization and exchanges it for an access token scoped to the Outlook
|
||||
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||
// The two-step approach is necessary because:
|
||||
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", "refresh_token")
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("client_secret", clientSecret)
|
||||
params.Set("refresh_token", refreshToken)
|
||||
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||
}
|
||||
|
||||
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||
preview := result.AccessToken
|
||||
if len(preview) > 30 {
|
||||
preview = preview[:30] + "..."
|
||||
}
|
||||
parts := strings.Count(result.AccessToken, ".") + 1
|
||||
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||
parts, preview, params.Get("scope"))
|
||||
|
||||
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
return &oauth2.Token{
|
||||
AccessToken: result.AccessToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||
type MicrosoftUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName"` // Graph field
|
||||
Name string `json:"name"` // ID token claim
|
||||
Mail string `json:"mail"`
|
||||
EmailClaim string `json:"email"` // ID token claim
|
||||
UserPrincipalName string `json:"userPrincipalName"`
|
||||
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||
}
|
||||
|
||||
// Email returns the best available email address.
|
||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
||||
if m.Mail != "" {
|
||||
return m.Mail
|
||||
}
|
||||
if m.EmailClaim != "" {
|
||||
return m.EmailClaim
|
||||
}
|
||||
if m.PreferredUsername != "" {
|
||||
return m.PreferredUsername
|
||||
}
|
||||
return m.UserPrincipalName
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
||||
// BestName returns the best available display name.
|
||||
func (m *MicrosoftUserInfo) BestName() string {
|
||||
if m.DisplayName != "" {
|
||||
return m.DisplayName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||
// The ID token is issued alongside the access token and contains email/name claims.
|
||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
if idToken == "" {
|
||||
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||
}
|
||||
|
||||
// JWT structure: header.payload.signature — decode the payload only
|
||||
parts := strings.SplitN(idToken, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var info MicrosoftUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if info.Email() == "" {
|
||||
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) ----
|
||||
|
||||
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||
// Mail is accessed via Graph REST API instead of IMAP.
|
||||
var OutlookPersonalScopes = []string{
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookPersonalScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token refresh helpers ----
|
||||
|
||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||
return ts.Token()
|
||||
}
|
||||
|
||||
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
|
||||
// Pass the credentials for both providers; the correct ones are selected based
|
||||
// on provider ("gmail" or "outlook").
|
||||
func RefreshAccountToken(ctx context.Context,
|
||||
provider, refreshToken, baseURL,
|
||||
googleClientID, googleClientSecret,
|
||||
msClientID, msClientSecret, msTenantID string,
|
||||
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||
|
||||
switch provider {
|
||||
case "gmail":
|
||||
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||
case "outlook":
|
||||
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
case "outlook_personal":
|
||||
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||
baseURL+"/auth/outlook-personal/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
default:
|
||||
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
|
||||
97
internal/geo/geo.go
Normal file
97
internal/geo/geo.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package geo provides IP geolocation lookup using the free ip-api.com service.
|
||||
// No API key is required. Rate limit: 45 requests/minute on the free tier.
|
||||
// Results are cached in memory to reduce API calls.
|
||||
package geo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeoResult struct {
|
||||
CountryCode string
|
||||
Country string
|
||||
Cached bool
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
result GeoResult
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = make(map[string]*cacheEntry)
|
||||
)
|
||||
|
||||
const cacheTTL = 24 * time.Hour
|
||||
|
||||
// Lookup returns the country for an IP address.
|
||||
// Returns empty strings on failure (private IPs, rate limit, etc.).
|
||||
func Lookup(ip string) GeoResult {
|
||||
// Skip private / loopback
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || isPrivate(parsed) {
|
||||
return GeoResult{}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
|
||||
mu.Unlock()
|
||||
r := e.result
|
||||
r.Cached = true
|
||||
return r
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
result := fetchFromAPI(ip)
|
||||
|
||||
mu.Lock()
|
||||
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
|
||||
mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchFromAPI(ip string) GeoResult {
|
||||
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
log.Printf("geo lookup failed for %s: %v", ip, err)
|
||||
return GeoResult{}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data struct {
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
|
||||
return GeoResult{}
|
||||
}
|
||||
return GeoResult{
|
||||
CountryCode: strings.ToUpper(data.CountryCode),
|
||||
Country: data.Country,
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivate(ip net.IP) bool {
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"127.0.0.0/8", "::1/128", "fc00::/7",
|
||||
}
|
||||
for _, cidr := range privateRanges {
|
||||
_, network, _ := net.ParseCIDR(cidr)
|
||||
if network != nil && network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
426
internal/graph/graph.go
Normal file
426
internal/graph/graph.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Package graph provides Microsoft Graph API mail access for personal
|
||||
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
|
||||
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
|
||||
// so we use the Graph REST API instead with the JWT access token.
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
const baseURL = "https://graph.microsoft.com/v1.0/me"
|
||||
|
||||
// Client wraps Graph API calls for a single account.
|
||||
type Client struct {
|
||||
token string
|
||||
account *models.EmailAccount
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New creates a Graph client for the given account.
|
||||
func New(account *models.EmailAccount) *Client {
|
||||
return &Client{
|
||||
token: account.AccessToken,
|
||||
account: account,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
|
||||
fullURL := path
|
||||
if !strings.HasPrefix(path, "https://") {
|
||||
fullURL = baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
|
||||
strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) deleteReq(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Folders ----
|
||||
|
||||
// GraphFolder represents a mail folder from Graph API.
|
||||
type GraphFolder struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TotalCount int `json:"totalItemCount"`
|
||||
UnreadCount int `json:"unreadItemCount"`
|
||||
WellKnown string `json:"wellKnownName"`
|
||||
}
|
||||
|
||||
type foldersResp struct {
|
||||
Value []GraphFolder `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListFolders returns all mail folders for the account.
|
||||
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
|
||||
var all []GraphFolder
|
||||
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
|
||||
for path != "" {
|
||||
var resp foldersResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ---- Messages ----
|
||||
|
||||
// EmailAddress wraps a Graph email address object.
|
||||
type EmailAddress struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
|
||||
// GraphMessage represents a mail message from Graph API.
|
||||
type GraphMessage struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
IsRead bool `json:"isRead"`
|
||||
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
|
||||
ReceivedDateTime time.Time `json:"receivedDateTime"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
From *EmailAddress `json:"from"`
|
||||
ToRecipients []EmailAddress `json:"toRecipients"`
|
||||
CcRecipients []EmailAddress `json:"ccRecipients"`
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"contentType"`
|
||||
} `json:"body"`
|
||||
InternetMessageID string `json:"internetMessageId"`
|
||||
}
|
||||
|
||||
// IsFlagged returns true if the message is flagged.
|
||||
func (m *GraphMessage) IsFlagged() bool {
|
||||
return m.Flag.Status == "flagged"
|
||||
}
|
||||
|
||||
// FromName returns the sender display name.
|
||||
func (m *GraphMessage) FromName() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Name
|
||||
}
|
||||
|
||||
// FromEmail returns the sender email address.
|
||||
func (m *GraphMessage) FromEmail() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Address
|
||||
}
|
||||
|
||||
// ToList returns a comma-separated list of recipients.
|
||||
func (m *GraphMessage) ToList() string {
|
||||
var parts []string
|
||||
for _, r := range m.ToRecipients {
|
||||
parts = append(parts, r.EmailAddress.Address)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
type messagesResp struct {
|
||||
Value []GraphMessage `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListMessages returns messages in a folder, optionally filtered by received date.
|
||||
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
|
||||
filter := ""
|
||||
if !since.IsZero() {
|
||||
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
|
||||
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
|
||||
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
|
||||
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
|
||||
}
|
||||
top := 50
|
||||
if maxResults > 0 && maxResults < top {
|
||||
top = maxResults
|
||||
}
|
||||
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
|
||||
folderID, top, filter)
|
||||
|
||||
var all []GraphMessage
|
||||
for path != "" {
|
||||
var resp messagesResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a single message with full body.
|
||||
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
|
||||
var msg GraphMessage
|
||||
err := c.get(ctx, "/messages/"+msgID+
|
||||
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
|
||||
&msg)
|
||||
return &msg, err
|
||||
}
|
||||
|
||||
// GetMessageRaw returns the raw RFC 822 message bytes.
|
||||
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
baseURL+"/messages/"+msgID+"/$value", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// MarkRead sets the isRead flag on a message.
|
||||
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears the flag on a message.
|
||||
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
|
||||
status := "notFlagged"
|
||||
if flagged {
|
||||
status = "flagged"
|
||||
}
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
|
||||
"flag": map[string]string{"flagStatus": status},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessage moves a message to Deleted Items (soft delete).
|
||||
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
|
||||
return c.deleteReq(ctx, "/messages/"+msgID)
|
||||
}
|
||||
|
||||
// MoveMessage moves a message to a different folder.
|
||||
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
|
||||
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph move returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
|
||||
// WellKnown field is not selectable via $select — we infer from displayName instead.
|
||||
func InferFolderType(displayName string) string {
|
||||
switch strings.ToLower(displayName) {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent items", "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "deleted items", "trash", "bin":
|
||||
return "trash"
|
||||
case "junk email", "spam", "junk":
|
||||
return "spam"
|
||||
case "archive":
|
||||
return "archive"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// WellKnownToFolderType kept for compatibility.
|
||||
func WellKnownToFolderType(wk string) string {
|
||||
return InferFolderType(wk)
|
||||
}
|
||||
|
||||
// ---- Send mail ----
|
||||
|
||||
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
|
||||
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
|
||||
func stripHTML(s string) string {
|
||||
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
|
||||
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").Replace(s)
|
||||
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// SendMail sends an email via Graph API POST /me/sendMail.
|
||||
// Sets both HTML and plain-text body to improve deliverability (spam filters
|
||||
// penalise HTML-only messages with no text/plain alternative).
|
||||
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
|
||||
// Build body: always provide both HTML and plain text for better deliverability
|
||||
body := map[string]string{
|
||||
"contentType": "HTML",
|
||||
"content": req.BodyHTML,
|
||||
}
|
||||
if req.BodyHTML == "" {
|
||||
body["contentType"] = "Text"
|
||||
body["content"] = req.BodyText
|
||||
}
|
||||
|
||||
// Set explicit from with display name
|
||||
var fromField interface{}
|
||||
if c.account.DisplayName != "" {
|
||||
fromField = map[string]interface{}{
|
||||
"emailAddress": map[string]string{
|
||||
"address": c.account.EmailAddress,
|
||||
"name": c.account.DisplayName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"subject": req.Subject,
|
||||
"body": body,
|
||||
"toRecipients": graphRecipients(req.To),
|
||||
"ccRecipients": graphRecipients(req.CC),
|
||||
"bccRecipients": graphRecipients(req.BCC),
|
||||
}
|
||||
if fromField != nil {
|
||||
msg["from"] = fromField
|
||||
}
|
||||
|
||||
if len(req.Attachments) > 0 {
|
||||
var atts []map[string]interface{}
|
||||
for _, a := range req.Attachments {
|
||||
atts = append(atts, map[string]interface{}{
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": a.Filename,
|
||||
"contentType": a.ContentType,
|
||||
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
|
||||
})
|
||||
}
|
||||
msg["attachments"] = atts
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"message": msg,
|
||||
"saveToSentItems": true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sendMail: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/sendMail", strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build sendMail request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendMail request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func graphRecipients(addrs []string) []map[string]interface{} {
|
||||
result := []map[string]interface{}{}
|
||||
for _, a := range addrs {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
result = append(result, map[string]interface{}{
|
||||
"emailAddress": map[string]string{"address": a},
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -111,6 +112,7 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -45,6 +48,7 @@ func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, map[string]bool{
|
||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
|
||||
account.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read)
|
||||
} else {
|
||||
uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err2 == nil && uid != 0 && acc != nil {
|
||||
val := "0"
|
||||
if req.Read {
|
||||
val = "1"
|
||||
}
|
||||
if req.Read { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_read",
|
||||
AccountID: acc.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
h.syncer.TriggerAccountSync(acc.ID)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -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 {
|
||||
if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil &&
|
||||
account.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred)
|
||||
} else {
|
||||
uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && acc != nil {
|
||||
val := "0"
|
||||
if starred {
|
||||
val = "1"
|
||||
}
|
||||
if starred { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_star",
|
||||
AccountID: acc.ID, OpType: "flag_star",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
h.syncer.TriggerAccountSync(acc.ID)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
||||
}
|
||||
@@ -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,14 +755,55 @@ 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
|
||||
|
||||
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)
|
||||
if err != nil || account == nil || account.UserID != userID {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,3 +17,13 @@ type AppHandler struct {
|
||||
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "app", nil)
|
||||
}
|
||||
|
||||
// ViewMessage renders a single message in a full browser tab.
|
||||
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "message", nil)
|
||||
}
|
||||
|
||||
// ComposePage renders the compose form in a full browser tab.
|
||||
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "compose", nil)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||
@@ -23,6 +29,7 @@ type AuthHandler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
renderer *Renderer
|
||||
syncer interface{ TriggerReconcile() }
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
@@ -53,6 +60,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||
case "deny":
|
||||
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||
return
|
||||
case "skip_brute":
|
||||
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||
w.Header().Set("X-Skip-Brute", "1")
|
||||
}
|
||||
|
||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||
uid := user.ID
|
||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
309
internal/handlers/contacts_calendar.go
Normal file
309
internal/handlers/contacts_calendar.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ======== Contacts ========
|
||||
|
||||
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var contacts interface{}
|
||||
var err error
|
||||
if q != "" {
|
||||
contacts, err = h.db.SearchContacts(userID, q)
|
||||
} else {
|
||||
contacts, err = h.db.ListContacts(userID)
|
||||
}
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||
return
|
||||
}
|
||||
if contacts == nil {
|
||||
contacts = []*models.Contact{}
|
||||
}
|
||||
h.writeJSON(w, contacts)
|
||||
}
|
||||
|
||||
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
c, err := h.db.GetContact(id, userID)
|
||||
if err != nil || c == nil {
|
||||
h.writeError(w, http.StatusNotFound, "contact not found")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, c)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req models.Contact
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
req.UserID = userID
|
||||
if req.AvatarColor == "" {
|
||||
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
|
||||
req.AvatarColor = colors[int(userID)%len(colors)]
|
||||
}
|
||||
if err := h.db.CreateContact(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, req)
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
var req models.Contact
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.db.UpdateContact(&req, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteContact(id, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ======== Calendar Events ========
|
||||
|
||||
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
if from == "" {
|
||||
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||
}
|
||||
if to == "" {
|
||||
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
|
||||
}
|
||||
events, err := h.db.ListCalendarEvents(userID, from, to)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list events")
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []*models.CalendarEvent{}
|
||||
}
|
||||
h.writeJSON(w, events)
|
||||
}
|
||||
|
||||
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
ev, err := h.db.GetCalendarEvent(id, userID)
|
||||
if err != nil || ev == nil {
|
||||
h.writeError(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, ev)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req models.CalendarEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
req.UserID = userID
|
||||
if err := h.db.UpsertCalendarEvent(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create event")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, req)
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
existing, err := h.db.GetCalendarEvent(id, userID)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
var req models.CalendarEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
req.UserID = userID
|
||||
req.UID = existing.UID // preserve original UID
|
||||
if err := h.db.UpsertCalendarEvent(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update event")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ======== CalDAV Tokens ========
|
||||
|
||||
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
tokens, err := h.db.ListCalDAVTokens(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
|
||||
return
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []*models.CalDAVToken{}
|
||||
}
|
||||
h.writeJSON(w, tokens)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Label == "" {
|
||||
req.Label = "CalDAV token"
|
||||
}
|
||||
t, err := h.db.CreateCalDAVToken(userID, req.Label)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create token")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, t)
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ======== CalDAV Server ========
|
||||
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
|
||||
// Compatible with any CalDAV client that supports basic calendar subscription.
|
||||
|
||||
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
|
||||
token := mux.Vars(r)["token"]
|
||||
userID, err := h.db.GetUserByCalDAVToken(token)
|
||||
if err != nil || userID == 0 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch events for next 12 months + past 3 months
|
||||
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
|
||||
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
|
||||
events, err := h.db.ListCalendarEvents(userID, from, to)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
|
||||
|
||||
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
|
||||
|
||||
for _, ev := range events {
|
||||
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
|
||||
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
|
||||
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
|
||||
if ev.Description != "" {
|
||||
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
|
||||
}
|
||||
if ev.Location != "" {
|
||||
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
|
||||
}
|
||||
if ev.AllDay {
|
||||
// All-day events use DATE format
|
||||
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
|
||||
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
|
||||
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
|
||||
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
|
||||
} else {
|
||||
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
|
||||
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
|
||||
}
|
||||
if ev.OrganizerEmail != "" {
|
||||
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
|
||||
}
|
||||
if ev.Status != "" {
|
||||
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
|
||||
}
|
||||
if ev.RecurrenceRule != "" {
|
||||
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
|
||||
}
|
||||
fmt.Fprintf(w, "END:VEVENT\r\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "END:VCALENDAR\r\n")
|
||||
}
|
||||
|
||||
func escICAL(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, ";", "\\;")
|
||||
s = strings.ReplaceAll(s, ",", "\\,")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
// Fold long lines at 75 chars
|
||||
if len(s) > 70 {
|
||||
var out strings.Builder
|
||||
for i, ch := range s {
|
||||
if i > 0 && i%70 == 0 {
|
||||
out.WriteString("\r\n ")
|
||||
}
|
||||
out.WriteRune(ch)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func toICALDate(s string) string {
|
||||
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
|
||||
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
|
||||
if err != nil {
|
||||
t, err = time.Parse("2006-01-02 15:04:05", s)
|
||||
}
|
||||
if err != nil {
|
||||
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
|
||||
}
|
||||
return t.UTC().Format("20060102T150405Z")
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
|
||||
}
|
||||
|
||||
return &Handlers{
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
|
||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
|
||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
||||
"login.html",
|
||||
"mfa.html",
|
||||
"admin.html",
|
||||
"message.html",
|
||||
"compose.html",
|
||||
}
|
||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||
if err != nil {
|
||||
|
||||
24
internal/logger/logger.go
Normal file
24
internal/logger/logger.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package logger provides a conditional debug logger controlled by config.Debug.
|
||||
package logger
|
||||
|
||||
import "log"
|
||||
|
||||
var debugEnabled bool
|
||||
|
||||
// Init sets whether debug logging is active. Call once at startup.
|
||||
func Init(debug bool) {
|
||||
debugEnabled = debug
|
||||
if debug {
|
||||
log.Println("[logger] debug logging enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logs a message only when debug mode is on.
|
||||
func Debug(format string, args ...interface{}) {
|
||||
if debugEnabled {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns true if debug logging is on.
|
||||
func IsEnabled() bool { return debugEnabled }
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`))
|
||||
|
||||
@@ -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"`
|
||||
@@ -85,6 +85,7 @@ type AccountProvider string
|
||||
const (
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
)
|
||||
|
||||
@@ -113,6 +114,7 @@ type EmailAccount struct {
|
||||
// Display
|
||||
Color string `json:"color"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
LastSync time.Time `json:"last_sync"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -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
201
internal/notify/notify.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Package notify sends security alert emails using a configurable SMTP relay.
|
||||
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
)
|
||||
|
||||
// BruteForceAlert holds the data for the brute-force notification email.
|
||||
type BruteForceAlert struct {
|
||||
Username string
|
||||
ToEmail string
|
||||
AttackerIP string
|
||||
Country string
|
||||
CountryCode string
|
||||
Attempts int
|
||||
BlockedAt time.Time
|
||||
BanHours int // 0 = permanent
|
||||
AppName string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
|
||||
To: {{.ToEmail}}
|
||||
Subject: Security Alert: Failed login attempts on your account
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Hello {{.Username}},
|
||||
|
||||
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
|
||||
|
||||
We detected multiple failed login attempts on your account and have
|
||||
automatically blocked the source IP address.
|
||||
|
||||
Account targeted : {{.Username}}
|
||||
Source IP : {{.AttackerIP}}
|
||||
{{- if .Country}}
|
||||
Country : {{.Country}} ({{.CountryCode}})
|
||||
{{- end}}
|
||||
Failed attempts : {{.Attempts}}
|
||||
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
|
||||
{{- if eq .BanHours 0}}
|
||||
Block duration : Permanent (administrator action required to unblock)
|
||||
{{- else}}
|
||||
Block duration : {{.BanHours}} hours
|
||||
{{- end}}
|
||||
|
||||
If this was you, you may have mistyped your password. The block will
|
||||
{{- if eq .BanHours 0}} remain until removed by an administrator.
|
||||
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
|
||||
|
||||
If you did not attempt to log in, your account credentials may be at
|
||||
risk. We recommend changing your password as soon as possible.
|
||||
|
||||
This is an automated message. Please do not reply.
|
||||
|
||||
--
|
||||
{{.AppName}} Security
|
||||
{{.Hostname}}
|
||||
`))
|
||||
|
||||
type templateData struct {
|
||||
BruteForceAlert
|
||||
From string
|
||||
}
|
||||
|
||||
// SendBruteForceAlert sends a security notification email to the targeted user.
|
||||
// It runs in a goroutine — errors are logged but not returned.
|
||||
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
|
||||
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
|
||||
return
|
||||
}
|
||||
if alert.ToEmail == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := sendAlert(cfg, alert); err != nil {
|
||||
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
|
||||
} else {
|
||||
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
|
||||
if alert.AppName == "" {
|
||||
alert.AppName = "GoWebMail"
|
||||
}
|
||||
if alert.Hostname == "" {
|
||||
alert.Hostname = cfg.Hostname
|
||||
}
|
||||
|
||||
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
|
||||
var buf bytes.Buffer
|
||||
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
|
||||
return fmt.Errorf("template execute: %w", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
|
||||
|
||||
// Choose auth method
|
||||
var auth smtp.Auth
|
||||
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
|
||||
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
|
||||
}
|
||||
|
||||
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
|
||||
if cfg.NotifySMTPPort == 465 {
|
||||
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||
}
|
||||
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||
}
|
||||
|
||||
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
|
||||
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
// Log but continue — some relays advertise STARTTLS but don't enforce it
|
||||
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendMessage(c, from, to, msg)
|
||||
}
|
||||
|
||||
// sendTLS sends via direct TLS connection (port 465).
|
||||
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
// Resolve host for the smtp.NewClient call
|
||||
bareHost, _, _ := net.SplitHostPort(addr)
|
||||
if bareHost == "" {
|
||||
bareHost = host
|
||||
}
|
||||
|
||||
c, err := smtp.NewClient(conn, bareHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendMessage(c, from, to, msg)
|
||||
}
|
||||
|
||||
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("MAIL FROM: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO: %w", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
// Normalise line endings to CRLF
|
||||
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
|
||||
if _, err := w.Write([]byte(normalized)); err != nil {
|
||||
return fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
@@ -9,31 +9,51 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// Scheduler coordinates all background sync activity.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// push channels: accountID -> channel to signal "something changed on server"
|
||||
pushMu sync.Mutex
|
||||
pushCh map[int64]chan struct{}
|
||||
|
||||
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||
reconcileCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
reconcileCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerReconcile asks the main loop to immediately check for new accounts.
|
||||
// Safe to call from any goroutine; non-blocking.
|
||||
func (s *Scheduler) TriggerReconcile() {
|
||||
select {
|
||||
case s.reconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +143,13 @@ func (s *Scheduler) mainLoop() {
|
||||
stopWorker(id)
|
||||
}
|
||||
return
|
||||
case <-s.reconcileCh:
|
||||
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
for id := range workers {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||
case <-ticker.C:
|
||||
// Build active IDs map for reconciliation
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
@@ -187,6 +214,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
||||
return a
|
||||
}
|
||||
|
||||
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
s.graphWorker(account, stop, push)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial sync on startup
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(getAccount())
|
||||
@@ -258,6 +291,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
cancel()
|
||||
if err != nil {
|
||||
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||
@@ -349,7 +384,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not connected") {
|
||||
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||
}
|
||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// GoMail Admin SPA
|
||||
// GoWebMail Admin SPA
|
||||
|
||||
const adminRoutes = {
|
||||
'/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() {
|
||||
@@ -309,3 +358,134 @@ function eventBadge(evt) {
|
||||
});
|
||||
});
|
||||
})();
|
||||
// ============================================================
|
||||
// Security — IP Blocks & Login Attempts
|
||||
// ============================================================
|
||||
async function renderSecurity() {
|
||||
const el = document.getElementById('admin-content');
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Security</h1>
|
||||
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-bottom:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
|
||||
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
|
||||
</div>
|
||||
<div id="blocks-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
|
||||
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="attempts-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="add-block-modal">
|
||||
<div class="modal" style="max-width:420px">
|
||||
<h2>Block IP Address</h2>
|
||||
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
|
||||
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
|
||||
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
|
||||
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadIPBlocks();
|
||||
loadLoginAttempts();
|
||||
}
|
||||
|
||||
async function loadIPBlocks() {
|
||||
const el = document.getElementById('blocks-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/ip-blocks');
|
||||
const blocks = r?.blocks || [];
|
||||
if (!blocks.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${blocks.map(b => `<tr>
|
||||
<td><code>${esc(b.ip)}</code></td>
|
||||
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
|
||||
<td>${esc(b.reason)}</td>
|
||||
<td>${b.attempts||0}</td>
|
||||
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
|
||||
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
|
||||
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadLoginAttempts() {
|
||||
const el = document.getElementById('attempts-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/login-attempts');
|
||||
const attempts = r?.attempts || [];
|
||||
if (!attempts.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
|
||||
<td><code>${esc(a.ip)}</code></td>
|
||||
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
|
||||
<td>${a.total}</td>
|
||||
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
|
||||
<td style="font-size:11px">${a.last_seen||'—'}</td>
|
||||
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function openAddBlock() { openModal('add-block-modal'); }
|
||||
|
||||
async function submitAddBlock() {
|
||||
const ip = document.getElementById('block-ip').value.trim();
|
||||
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
|
||||
const hours = parseInt(document.getElementById('block-hours').value) || 0;
|
||||
if (!ip) { toast('IP address required', 'error'); return; }
|
||||
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
|
||||
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
|
||||
else toast(r?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
async function unblockIP(ip) {
|
||||
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
|
||||
const data = await r.json();
|
||||
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
|
||||
else toast(data?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
function blockFromAttempt(ip) {
|
||||
document.getElementById('block-ip').value = ip;
|
||||
document.getElementById('block-reason').value = 'Manual block from login attempts';
|
||||
openModal('add-block-modal');
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
411
web/static/js/contacts_calendar.js
Normal file
411
web/static/js/contacts_calendar.js
Normal file
@@ -0,0 +1,411 @@
|
||||
// ── Contacts & Calendar ─────────────────────────────────────────────────────
|
||||
|
||||
let _currentView = 'mail';
|
||||
|
||||
// ======== VIEW SWITCHING ========
|
||||
// Uses data-view attribute on #app-root to switch panels via CSS,
|
||||
// avoiding direct style manipulation of elements that may not exist.
|
||||
|
||||
function _setView(view) {
|
||||
_currentView = view;
|
||||
// Update nav item active states
|
||||
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
|
||||
document.getElementById(id)?.classList.remove('active');
|
||||
});
|
||||
// Show/hide panels
|
||||
const mail1 = document.getElementById('message-list-panel');
|
||||
const mail2 = document.getElementById('message-detail');
|
||||
const contacts = document.getElementById('contacts-panel');
|
||||
const calendar = document.getElementById('calendar-panel');
|
||||
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
|
||||
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
|
||||
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
|
||||
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function showMail() {
|
||||
_setView('mail');
|
||||
document.getElementById('nav-unified')?.classList.add('active');
|
||||
}
|
||||
|
||||
function showContacts() {
|
||||
_setView('contacts');
|
||||
document.getElementById('nav-contacts')?.classList.add('active');
|
||||
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
|
||||
loadContacts();
|
||||
}
|
||||
|
||||
function showCalendar() {
|
||||
_setView('calendar');
|
||||
document.getElementById('nav-calendar')?.classList.add('active');
|
||||
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
|
||||
calRender();
|
||||
}
|
||||
|
||||
// Patch selectFolder — called from app.js sidebar click handlers.
|
||||
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
|
||||
// Avoids infinite recursion by checking _currentView before doing anything.
|
||||
(function() {
|
||||
const _orig = window.selectFolder;
|
||||
window.selectFolder = function(folderId, folderName) {
|
||||
if (_currentView !== 'mail') {
|
||||
showMail();
|
||||
// Give the DOM a tick to re-show the mail panels before loading
|
||||
setTimeout(function() {
|
||||
_orig && _orig(folderId, folderName);
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
_orig && _orig(folderId, folderName);
|
||||
};
|
||||
})();
|
||||
|
||||
// ======== CONTACTS ========
|
||||
|
||||
let _contacts = [];
|
||||
let _editingContactId = null;
|
||||
|
||||
async function loadContacts() {
|
||||
const data = await api('GET', '/contacts');
|
||||
_contacts = data || [];
|
||||
renderContacts(_contacts);
|
||||
}
|
||||
|
||||
function renderContacts(list) {
|
||||
const el = document.getElementById('contacts-list');
|
||||
if (!el) return;
|
||||
if (!list || list.length === 0) {
|
||||
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
|
||||
<p>No contacts yet. Click "+ New Contact" to add one.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(c => {
|
||||
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
|
||||
const color = c.avatar_color || '#6b7280';
|
||||
const meta = [c.email, c.company].filter(Boolean).join(' · ');
|
||||
return `<div class="contact-card" onclick="openContactForm(${c.id})">
|
||||
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">${esc(c.display_name || c.email)}</div>
|
||||
<div class="contact-meta">${esc(meta)}</div>
|
||||
</div>
|
||||
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterContacts(q) {
|
||||
if (!q) { renderContacts(_contacts); return; }
|
||||
const lower = q.toLowerCase();
|
||||
renderContacts(_contacts.filter(c =>
|
||||
(c.display_name||'').toLowerCase().includes(lower) ||
|
||||
(c.email||'').toLowerCase().includes(lower) ||
|
||||
(c.company||'').toLowerCase().includes(lower)
|
||||
));
|
||||
}
|
||||
|
||||
function composeToContact(email) {
|
||||
showMail();
|
||||
setTimeout(() => {
|
||||
if (typeof openCompose === 'function') openCompose();
|
||||
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function openContactForm(id) {
|
||||
_editingContactId = id || null;
|
||||
const delBtn = document.getElementById('cf-delete-btn');
|
||||
if (id) {
|
||||
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
const c = _contacts.find(x => x.id === id);
|
||||
if (c) {
|
||||
document.getElementById('cf-name').value = c.display_name || '';
|
||||
document.getElementById('cf-email').value = c.email || '';
|
||||
document.getElementById('cf-phone').value = c.phone || '';
|
||||
document.getElementById('cf-company').value = c.company || '';
|
||||
document.getElementById('cf-notes').value = c.notes || '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('contact-modal-title').textContent = 'New Contact';
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
|
||||
const el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
}
|
||||
openModal('contact-modal');
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
const body = {
|
||||
display_name: document.getElementById('cf-name').value.trim(),
|
||||
email: document.getElementById('cf-email').value.trim(),
|
||||
phone: document.getElementById('cf-phone').value.trim(),
|
||||
company: document.getElementById('cf-company').value.trim(),
|
||||
notes: document.getElementById('cf-notes').value.trim(),
|
||||
};
|
||||
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
|
||||
if (_editingContactId) {
|
||||
await api('PUT', `/contacts/${_editingContactId}`, body);
|
||||
} else {
|
||||
await api('POST', '/contacts', body);
|
||||
}
|
||||
closeModal('contact-modal');
|
||||
await loadContacts();
|
||||
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
|
||||
}
|
||||
|
||||
async function deleteContact() {
|
||||
if (!_editingContactId) return;
|
||||
if (!confirm('Delete this contact?')) return;
|
||||
await api('DELETE', `/contacts/${_editingContactId}`);
|
||||
closeModal('contact-modal');
|
||||
await loadContacts();
|
||||
toast('Contact deleted', 'success');
|
||||
}
|
||||
|
||||
// ======== CALENDAR ========
|
||||
|
||||
const CAL = {
|
||||
view: 'month',
|
||||
cursor: new Date(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
function calSetView(v) {
|
||||
CAL.view = v;
|
||||
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
|
||||
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
|
||||
calRender();
|
||||
}
|
||||
|
||||
function calNav(dir) {
|
||||
if (CAL.view === 'month') {
|
||||
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
|
||||
} else {
|
||||
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
|
||||
}
|
||||
calRender();
|
||||
}
|
||||
|
||||
function calGoToday() { CAL.cursor = new Date(); calRender(); }
|
||||
|
||||
async function calRender() {
|
||||
const gridEl = document.getElementById('cal-grid');
|
||||
if (!gridEl) return;
|
||||
|
||||
let from, to;
|
||||
if (CAL.view === 'month') {
|
||||
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
|
||||
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
|
||||
from = new Date(from.getTime() - from.getDay() * 86400000);
|
||||
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
|
||||
} else {
|
||||
const dow = CAL.cursor.getDay();
|
||||
from = new Date(CAL.cursor.getTime() - dow * 86400000);
|
||||
to = new Date(from.getTime() + 6 * 86400000);
|
||||
}
|
||||
|
||||
const fmt = d => d.toISOString().split('T')[0];
|
||||
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
|
||||
CAL.events = data || [];
|
||||
|
||||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const titleEl = document.getElementById('cal-title');
|
||||
if (CAL.view === 'month') {
|
||||
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
|
||||
calRenderMonth(from, to);
|
||||
} else {
|
||||
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} – ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
|
||||
calRenderWeek(from);
|
||||
}
|
||||
}
|
||||
|
||||
function calRenderMonth(from, to) {
|
||||
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
let html = `<div class="cal-grid-month">`;
|
||||
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
|
||||
const cur = new Date(from);
|
||||
const curMonth = CAL.cursor.getMonth();
|
||||
while (cur <= to) {
|
||||
const dateStr = cur.toISOString().split('T')[0];
|
||||
const isToday = cur.getTime() === today.getTime();
|
||||
const isOther = cur.getMonth() !== curMonth;
|
||||
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
|
||||
const shown = dayEvents.slice(0, 3);
|
||||
const more = dayEvents.length - 3;
|
||||
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
|
||||
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
|
||||
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
|
||||
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
|
||||
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
|
||||
</div>`;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
html += `</div>`;
|
||||
document.getElementById('cal-grid').innerHTML = html;
|
||||
}
|
||||
|
||||
function calRenderWeek(weekStart) {
|
||||
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
let html = `<div class="cal-week-grid">`;
|
||||
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
|
||||
for (let i=0;i<7;i++) {
|
||||
const d = new Date(weekStart.getTime()+i*86400000);
|
||||
const isT = d.getTime()===today.getTime();
|
||||
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
|
||||
}
|
||||
for (let h=0;h<24;h++) {
|
||||
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
|
||||
html += `<div class="cal-time-col">${label}</div>`;
|
||||
for (let i=0;i<7;i++) {
|
||||
const d = new Date(weekStart.getTime()+i*86400000);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const slotEvs = CAL.events.filter(ev => {
|
||||
if (!ev.start_time) return false;
|
||||
return ev.start_time.startsWith(dateStr) &&
|
||||
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
|
||||
});
|
||||
const isT = d.getTime()===today.getTime();
|
||||
html += `<div class="cal-week-cell${isT?' today':''}"
|
||||
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
|
||||
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
|
||||
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
document.getElementById('cal-grid').innerHTML = html;
|
||||
}
|
||||
|
||||
// ======== EVENT FORM ========
|
||||
|
||||
let _editingEventId = null;
|
||||
let _selectedEvColor = '#0078D4';
|
||||
|
||||
function selectEvColor(el) {
|
||||
_selectedEvColor = el.dataset.color;
|
||||
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
|
||||
el.style.borderColor = 'white';
|
||||
}
|
||||
|
||||
function openEventForm(id, defaultStart) {
|
||||
_editingEventId = id || null;
|
||||
const delBtn = document.getElementById('ev-delete-btn');
|
||||
_selectedEvColor = '#0078D4';
|
||||
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
|
||||
if (id) {
|
||||
document.getElementById('event-modal-title').textContent = 'Edit Event';
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
const ev = CAL.events.find(e => e.id === id);
|
||||
if (ev) {
|
||||
document.getElementById('ev-title').value = ev.title||'';
|
||||
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
|
||||
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
|
||||
document.getElementById('ev-allday').checked = !!ev.all_day;
|
||||
document.getElementById('ev-location').value = ev.location||'';
|
||||
document.getElementById('ev-desc').value = ev.description||'';
|
||||
_selectedEvColor = ev.color||'#0078D4';
|
||||
document.querySelectorAll('#ev-colors span').forEach(s => {
|
||||
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
|
||||
});
|
||||
}
|
||||
} else {
|
||||
document.getElementById('event-modal-title').textContent = 'New Event';
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
document.getElementById('ev-title').value = '';
|
||||
const start = defaultStart || new Date().toISOString().substring(0,16);
|
||||
document.getElementById('ev-start').value = start;
|
||||
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
|
||||
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
|
||||
document.getElementById('ev-allday').checked = false;
|
||||
document.getElementById('ev-location').value = '';
|
||||
document.getElementById('ev-desc').value = '';
|
||||
}
|
||||
openModal('event-modal');
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
const title = document.getElementById('ev-title').value.trim();
|
||||
if (!title) { toast('Title is required','error'); return; }
|
||||
const body = {
|
||||
title,
|
||||
start_time: document.getElementById('ev-start').value.replace('T',' '),
|
||||
end_time: document.getElementById('ev-end').value.replace('T',' '),
|
||||
all_day: document.getElementById('ev-allday').checked,
|
||||
location: document.getElementById('ev-location').value.trim(),
|
||||
description:document.getElementById('ev-desc').value.trim(),
|
||||
color: _selectedEvColor,
|
||||
status: 'confirmed',
|
||||
};
|
||||
if (_editingEventId) {
|
||||
await api('PUT', `/calendar/events/${_editingEventId}`, body);
|
||||
} else {
|
||||
await api('POST', '/calendar/events', body);
|
||||
}
|
||||
closeModal('event-modal');
|
||||
await calRender();
|
||||
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!_editingEventId) return;
|
||||
if (!confirm('Delete this event?')) return;
|
||||
await api('DELETE', `/calendar/events/${_editingEventId}`);
|
||||
closeModal('event-modal');
|
||||
await calRender();
|
||||
toast('Event deleted', 'success');
|
||||
}
|
||||
|
||||
// ======== CALDAV ========
|
||||
|
||||
async function showCalDAVSettings() {
|
||||
openModal('caldav-modal');
|
||||
await loadCalDAVTokens();
|
||||
}
|
||||
|
||||
async function loadCalDAVTokens() {
|
||||
const tokens = await api('GET', '/caldav/tokens') || [];
|
||||
const el = document.getElementById('caldav-tokens-list');
|
||||
if (!el) return;
|
||||
if (!tokens.length) {
|
||||
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = tokens.map(t => {
|
||||
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
|
||||
return `<div class="caldav-token-row">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
|
||||
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
|
||||
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
|
||||
</div>
|
||||
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function createCalDAVToken() {
|
||||
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
|
||||
await api('POST', '/caldav/tokens', { label });
|
||||
document.getElementById('caldav-label').value = '';
|
||||
await loadCalDAVTokens();
|
||||
toast('Token created', 'success');
|
||||
}
|
||||
|
||||
async function revokeCalDAVToken(id) {
|
||||
if (!confirm('Revoke this token?')) return;
|
||||
await api('DELETE', `/caldav/tokens/${id}`);
|
||||
await loadCalDAVTokens();
|
||||
toast('Token revoked', 'success');
|
||||
}
|
||||
|
||||
function copyCalDAVUrl(url) {
|
||||
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}}
|
||||
@@ -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">
|
||||
@@ -61,6 +93,7 @@
|
||||
<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-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>
|
||||
@@ -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">‹</button>
|
||||
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
|
||||
<button class="icon-btn" onclick="calNav(1)" title="Next">›</button>
|
||||
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
|
||||
<div style="margin-left:auto;display:flex;gap:4px">
|
||||
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
|
||||
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
|
||||
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
|
||||
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="contact-modal">
|
||||
<div class="modal" style="max-width:480px">
|
||||
<h2 id="contact-modal-title">New Contact</h2>
|
||||
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
|
||||
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
|
||||
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
|
||||
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
|
||||
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
|
||||
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
|
||||
<button class="modal-submit" onclick="saveContact()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="event-modal">
|
||||
<div class="modal" style="max-width:520px">
|
||||
<h2 id="event-modal-title">New Event</h2>
|
||||
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
|
||||
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input id="ev-allday" type="checkbox" style="width:auto">
|
||||
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
|
||||
</div>
|
||||
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
|
||||
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
|
||||
<div class="modal-field"><label>Color</label>
|
||||
<div style="display:flex;gap:6px" id="ev-colors">
|
||||
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
|
||||
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
|
||||
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
|
||||
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
|
||||
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
|
||||
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
|
||||
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
|
||||
<button class="modal-submit" onclick="saveEvent()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="caldav-modal">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<h2>CalDAV / Calendar Sharing</h2>
|
||||
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
|
||||
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
|
||||
</p>
|
||||
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
|
||||
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:16px">
|
||||
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||
@@ -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,6 +370,20 @@
|
||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||
<input type="hidden" id="edit-account-id">
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||
|
||||
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
|
||||
<div id="edit-oauth-section" style="display:none">
|
||||
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
|
||||
⚠️ Access token has expired — sync and send will fail until you reconnect.
|
||||
</div>
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
|
||||
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
|
||||
<div id="edit-creds-section">
|
||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||
@@ -232,6 +393,8 @@
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Email history to sync</label>
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
220
web/templates/compose.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}Compose — GoWebMail{{end}}
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
|
||||
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back to GoWebMail
|
||||
</a>
|
||||
<span style="color:var(--border);font-size:16px">|</span>
|
||||
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
|
||||
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="compose-page-form">
|
||||
<!-- From -->
|
||||
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
|
||||
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
|
||||
</div>
|
||||
<!-- To -->
|
||||
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
|
||||
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||
</div>
|
||||
<!-- CC -->
|
||||
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
|
||||
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||
</div>
|
||||
<!-- Subject -->
|
||||
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
|
||||
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
|
||||
<!-- Attachments -->
|
||||
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
|
||||
Attach file
|
||||
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
|
||||
</label>
|
||||
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// Parse URL params
|
||||
const params = new URLSearchParams(location.search);
|
||||
const replyId = parseInt(params.get('reply_id') || '0');
|
||||
const forwardId = parseInt(params.get('forward_id') || '0');
|
||||
const cpAttachments = [];
|
||||
|
||||
async function apiCall(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body instanceof FormData) { opts.body = body; }
|
||||
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||
const r = await fetch('/api' + path, opts);
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
// Tag field (simple comma/enter separated)
|
||||
function initTagField(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
|
||||
const inp = el.querySelector('input');
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const v = inp.value.trim().replace(/,$/, '');
|
||||
if (v) addTagTo(id, v);
|
||||
inp.value = '';
|
||||
} else if (e.key === 'Backspace' && !inp.value) {
|
||||
const tags = el.querySelectorAll('.tag-chip');
|
||||
if (tags.length) tags[tags.length-1].remove();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('blur', () => {
|
||||
const v = inp.value.trim().replace(/,$/, '');
|
||||
if (v) { addTagTo(id, v); inp.value = ''; }
|
||||
});
|
||||
}
|
||||
|
||||
function addTagTo(fieldId, email) {
|
||||
const el = document.getElementById(fieldId);
|
||||
const inp = el.querySelector('input');
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip';
|
||||
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
|
||||
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
|
||||
el.insertBefore(chip, inp);
|
||||
}
|
||||
|
||||
function getTagValues(fieldId) {
|
||||
const el = document.getElementById(fieldId);
|
||||
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function addPageAttachments(files) {
|
||||
for (const f of files) {
|
||||
cpAttachments.push(f);
|
||||
const chip = document.createElement('span');
|
||||
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
|
||||
chip.textContent = f.name;
|
||||
document.getElementById('cp-att-list').appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const accounts = await apiCall('GET', '/accounts') || [];
|
||||
const sel = document.getElementById('cp-from');
|
||||
accounts.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function prefillReply() {
|
||||
if (!replyId) return;
|
||||
document.getElementById('compose-page-title').textContent = 'Reply';
|
||||
const msg = await apiCall('GET', '/messages/' + replyId);
|
||||
if (!msg) return;
|
||||
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
|
||||
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
|
||||
addTagTo('cp-to-tags', msg.from_email || '');
|
||||
const editor = document.getElementById('cp-editor');
|
||||
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
|
||||
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||
</div>`;
|
||||
// Set from to same account
|
||||
if (msg.account_id) {
|
||||
const sel = document.getElementById('cp-from');
|
||||
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
|
||||
}
|
||||
}
|
||||
|
||||
async function prefillForward() {
|
||||
if (!forwardId) return;
|
||||
document.getElementById('compose-page-title').textContent = 'Forward';
|
||||
const msg = await apiCall('GET', '/messages/' + forwardId);
|
||||
if (!msg) return;
|
||||
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
|
||||
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
|
||||
const editor = document.getElementById('cp-editor');
|
||||
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
|
||||
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function sendFromPage() {
|
||||
const btn = document.getElementById('send-page-btn');
|
||||
const accountId = parseInt(document.getElementById('cp-from').value || '0');
|
||||
const to = getTagValues('cp-to-tags');
|
||||
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
|
||||
btn.disabled = true; btn.textContent = 'Sending…';
|
||||
|
||||
const meta = {
|
||||
account_id: accountId,
|
||||
to,
|
||||
cc: getTagValues('cp-cc-tags'),
|
||||
bcc: [],
|
||||
subject: document.getElementById('cp-subject').value,
|
||||
body_html: document.getElementById('cp-editor').innerHTML,
|
||||
body_text: document.getElementById('cp-editor').innerText,
|
||||
in_reply_to_id: replyId || 0,
|
||||
forward_from_id: forwardId || 0,
|
||||
};
|
||||
|
||||
let r;
|
||||
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
|
||||
if (cpAttachments.length) {
|
||||
const fd = new FormData();
|
||||
fd.append('meta', JSON.stringify(meta));
|
||||
cpAttachments.forEach(f => fd.append('file', f, f.name));
|
||||
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
|
||||
r = await resp.json().catch(() => null);
|
||||
} else {
|
||||
r = await apiCall('POST', endpoint, meta);
|
||||
}
|
||||
|
||||
btn.disabled = false; btn.textContent = 'Send';
|
||||
if (r?.ok) {
|
||||
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
|
||||
document.getElementById('compose-page-form').style.opacity = '0.5';
|
||||
document.getElementById('compose-page-form').style.pointerEvents = 'none';
|
||||
} else {
|
||||
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
|
||||
}
|
||||
|
||||
// Init
|
||||
initTagField('cp-to-tags');
|
||||
initTagField('cp-cc-tags');
|
||||
loadAccounts();
|
||||
if (replyId) prefillReply();
|
||||
else if (forwardId) prefillForward();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -19,7 +19,7 @@
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||
const k=new URLSearchParams(location.search).get('error');
|
||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||
</script>
|
||||
|
||||
95
web/templates/message.html
Normal file
95
web/templates/message.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}Message — GoWebMail{{end}}
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
|
||||
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back to GoWebMail
|
||||
</a>
|
||||
<span style="color:var(--border);font-size:16px">|</span>
|
||||
<div id="msg-actions" style="display:flex;gap:8px"></div>
|
||||
<div style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
|
||||
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="msg-content">
|
||||
<div class="spinner" style="margin-top:80px"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const msgId = parseInt(location.pathname.split('/').pop());
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||
const r = await fetch('/api' + path, opts);
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
async function load() {
|
||||
const msg = await api('GET', '/messages/' + msgId);
|
||||
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
|
||||
|
||||
// Mark read
|
||||
await api('PUT', '/messages/' + msgId + '/read', { read: true });
|
||||
|
||||
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
|
||||
|
||||
const atts = msg.attachments || [];
|
||||
const attHtml = atts.length ? `
|
||||
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
|
||||
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
|
||||
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
|
||||
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
|
||||
</div>` : '';
|
||||
|
||||
document.getElementById('msg-content').innerHTML = `
|
||||
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
||||
<div>
|
||||
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
|
||||
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)"><${esc(msg.from_email)}></span>` : ''}
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
|
||||
</div>
|
||||
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
|
||||
</div>
|
||||
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
|
||||
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
|
||||
</div>
|
||||
${attHtml}`;
|
||||
|
||||
// Write body into sandboxed iframe
|
||||
const iframe = document.getElementById('msg-iframe');
|
||||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
doc.open();
|
||||
doc.write(`<!DOCTYPE html><html><head><style>
|
||||
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
|
||||
img{max-width:100%;height:auto}a{color:#0078D4}
|
||||
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
|
||||
doc.close();
|
||||
// Auto-resize iframe
|
||||
setTimeout(() => {
|
||||
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function replyFromPage() {
|
||||
window.location = '/?action=reply&id=' + msgId;
|
||||
}
|
||||
function forwardFromPage() {
|
||||
window.location = '/?action=forward&id=' + msgId;
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user