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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,4 +3,7 @@ data/*.db
|
|||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
data/*db-wal
|
data/*db-wal
|
||||||
data/gowebmail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
|
gowebmail-devplan.md
|
||||||
|
testrun/
|
||||||
|
webmail.code-workspace
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ import (
|
|||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/handlers"
|
"github.com/ghostersk/gowebmail/internal/handlers"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||||
|
|
||||||
@@ -47,6 +51,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
runDisableMFA(args[1])
|
runDisableMFA(args[1])
|
||||||
return
|
return
|
||||||
|
case "--blocklist":
|
||||||
|
runBlockList()
|
||||||
|
return
|
||||||
|
case "--unblock":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUnblock(args[1])
|
||||||
|
return
|
||||||
case "--help", "-h":
|
case "--help", "-h":
|
||||||
printHelp()
|
printHelp()
|
||||||
return
|
return
|
||||||
@@ -62,6 +76,18 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("config load: %v", err)
|
log.Fatalf("config load: %v", err)
|
||||||
}
|
}
|
||||||
|
logger.Init(cfg.Debug)
|
||||||
|
|
||||||
|
// Install a filtered log writer that suppresses harmless go-imap v1 parser
|
||||||
|
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
|
||||||
|
// on Gmail connections due to non-standard server responses. These don't affect
|
||||||
|
// functionality — go-imap recovers and continues syncing correctly.
|
||||||
|
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
|
||||||
|
"imap/client:",
|
||||||
|
"atom contains forbidden",
|
||||||
|
"atom contains bad",
|
||||||
|
"bad brackets nesting",
|
||||||
|
}})
|
||||||
|
|
||||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,7 +99,7 @@ func main() {
|
|||||||
log.Fatalf("migrations: %v", err)
|
log.Fatalf("migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sc := syncer.New(database)
|
sc := syncer.New(database, cfg)
|
||||||
sc.Start()
|
sc.Start()
|
||||||
defer sc.Stop()
|
defer sc.Stop()
|
||||||
|
|
||||||
@@ -85,10 +111,27 @@ func main() {
|
|||||||
r.Use(middleware.CORS)
|
r.Use(middleware.CORS)
|
||||||
r.Use(cfg.HostCheckMiddleware)
|
r.Use(cfg.HostCheckMiddleware)
|
||||||
|
|
||||||
|
// Custom error handlers for non-API paths
|
||||||
|
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||||
|
})
|
||||||
|
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||||
|
})
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
r.PathPrefix("/static/").Handler(
|
r.PathPrefix("/static/").Handler(
|
||||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||||
)
|
)
|
||||||
|
// Legacy /app path redirect — some browsers bookmark this; redirect to root
|
||||||
|
// which RequireAuth will then forward to login if not signed in.
|
||||||
|
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}).Methods("GET")
|
||||||
|
|
||||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +146,7 @@ func main() {
|
|||||||
// Public auth routes
|
// Public auth routes
|
||||||
auth := r.PathPrefix("/auth").Subrouter()
|
auth := r.PathPrefix("/auth").Subrouter()
|
||||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||||
|
|
||||||
// MFA (session exists but mfa_verified=0)
|
// MFA (session exists but mfa_verified=0)
|
||||||
@@ -119,11 +162,15 @@ func main() {
|
|||||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||||
|
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||||
|
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||||
|
|
||||||
// App
|
// App
|
||||||
app := r.PathPrefix("").Subrouter()
|
app := r.PathPrefix("").Subrouter()
|
||||||
app.Use(middleware.RequireAuth(database, cfg))
|
app.Use(middleware.RequireAuth(database, cfg))
|
||||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
app.HandleFunc("/", h.App.Index).Methods("GET")
|
||||||
|
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
|
||||||
|
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
|
||||||
|
|
||||||
// Admin UI
|
// Admin UI
|
||||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||||
@@ -133,6 +180,7 @@ func main() {
|
|||||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
|
||||||
// API
|
// API
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
@@ -141,10 +189,13 @@ func main() {
|
|||||||
|
|
||||||
// Profile / auth
|
// Profile / auth
|
||||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||||
|
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||||
|
|
||||||
// Providers (which OAuth providers are configured)
|
// Providers (which OAuth providers are configured)
|
||||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||||
@@ -169,6 +220,8 @@ func main() {
|
|||||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
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]+}/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]+}/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/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
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("/send", h.API.SendMessage).Methods("POST")
|
||||||
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
||||||
api.HandleFunc("/forward", h.API.ForwardMessage).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
|
// Folders
|
||||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
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]+}/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]+}/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]+}/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("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
|
||||||
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
||||||
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
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.GetSyncInterval).Methods("GET")
|
||||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||||
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
||||||
|
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
|
||||||
|
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
|
||||||
|
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
|
||||||
|
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
|
||||||
|
|
||||||
|
// Calendar events
|
||||||
|
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
|
||||||
|
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
|
||||||
|
|
||||||
|
// CalDAV API tokens
|
||||||
|
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
|
||||||
|
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
|
||||||
|
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
|
||||||
|
|
||||||
|
// CalDAV public feed — token-authenticated, no session needed
|
||||||
|
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
||||||
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
||||||
@@ -213,6 +294,19 @@ func main() {
|
|||||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
|
||||||
|
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
|
||||||
|
|
||||||
|
// Periodically purge expired IP blocks
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
database.PurgeExpiredBlocks()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
@@ -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)
|
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runBlockList() {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
blocks, err := database.ListIPBlocksWithUsername()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
fmt.Println("No blocked IPs.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"--", "-------------", "-----", "----------", "-------", "---------")
|
||||||
|
for _, b := range blocks {
|
||||||
|
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
var expires, remaining string
|
||||||
|
if b.IsPermanent || b.ExpiresAt == nil {
|
||||||
|
expires = "permanent"
|
||||||
|
remaining = "∞ (manual unblock)"
|
||||||
|
} else {
|
||||||
|
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
left := time.Until(*b.ExpiresAt)
|
||||||
|
if left <= 0 {
|
||||||
|
remaining = "expired (purge pending)"
|
||||||
|
} else {
|
||||||
|
h := int(left.Hours())
|
||||||
|
m := int(left.Minutes()) % 60
|
||||||
|
s := int(left.Seconds()) % 60
|
||||||
|
if h > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
} else if m > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dm %ds", m, s)
|
||||||
|
} else {
|
||||||
|
remaining = fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username := b.LastUsername
|
||||||
|
if username == "" {
|
||||||
|
username = "(unknown)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
|
||||||
|
b.IP, username, b.Attempts, blockedAt, expires, remaining)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUnblock(ip string) {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
if err := database.UnblockIP(ip); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("IP %s has been unblocked.\n", ip)
|
||||||
|
}
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Print(`GoMail — Admin CLI
|
fmt.Print(`GoWebMail — Admin CLI
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
gowebmail Start the mail server
|
gowebmail Start the mail server
|
||||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||||
gowebmail --mfa-off <username> Disable MFA for an admin account
|
gowebmail --mfa-off <username> Disable MFA for an admin account
|
||||||
|
gowebmail --blocklist List all currently blocked IP addresses
|
||||||
|
gowebmail --unblock <ip> Remove block for a specific IP address
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
./gowebmail --blocklist
|
||||||
|
./gowebmail --unblock 1.2.3.4
|
||||||
|
|
||||||
Note: These commands only work on admin accounts.
|
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
||||||
Regular user management is done through the web UI.
|
Regular user management is done through the web UI.
|
||||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filteredWriter wraps an io.Writer and drops log lines containing any of the
|
||||||
|
// suppress substrings. Used to silence harmless go-imap internal parser errors.
|
||||||
|
type filteredWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
suppress []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filteredWriter) Write(p []byte) (n int, err error) {
|
||||||
|
line := string(bytes.TrimSpace(p))
|
||||||
|
for _, s := range f.suppress {
|
||||||
|
if strings.Contains(line, s) {
|
||||||
|
return len(p), nil // silently drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.w.Write(p)
|
||||||
|
}
|
||||||
|
|||||||
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,6 +21,9 @@ type Config struct {
|
|||||||
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
||||||
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
Debug bool // set DEBUG=true in config to enable verbose logging
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
EncryptionKey []byte // 32 bytes / AES-256
|
EncryptionKey []byte // 32 bytes / AES-256
|
||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
@@ -28,6 +31,23 @@ type Config struct {
|
|||||||
SessionMaxAge int
|
SessionMaxAge int
|
||||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
||||||
|
|
||||||
|
// Notification SMTP (outbound alerts — separate from user mail accounts)
|
||||||
|
NotifyEnabled bool
|
||||||
|
NotifySMTPHost string
|
||||||
|
NotifySMTPPort int
|
||||||
|
NotifyFrom string
|
||||||
|
NotifyUser string // optional — leave blank for unauthenticated relay
|
||||||
|
NotifyPass string // optional
|
||||||
|
|
||||||
|
// Brute force protection
|
||||||
|
BruteEnabled bool
|
||||||
|
BruteMaxAttempts int
|
||||||
|
BruteWindowMins int
|
||||||
|
BruteBanHours int
|
||||||
|
BruteWhitelist []net.IP // IPs exempt from blocking
|
||||||
|
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
|
||||||
|
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
DBPath string
|
DBPath string
|
||||||
|
|
||||||
@@ -59,7 +79,7 @@ var allFields = []configField{
|
|||||||
defVal: "localhost",
|
defVal: "localhost",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"--- Server ---",
|
"--- 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",
|
"Examples: localhost | mail.example.com | 192.168.1.10",
|
||||||
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
||||||
"Also used in security checks to reject requests with unexpected Host headers.",
|
"Also used in security checks to reject requests with unexpected Host headers.",
|
||||||
@@ -92,7 +112,7 @@ var allFields = []configField{
|
|||||||
key: "SECURE_COOKIE",
|
key: "SECURE_COOKIE",
|
||||||
defVal: "false",
|
defVal: "false",
|
||||||
comments: []string{
|
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.",
|
"Marks session cookies as Secure so browsers only send them over TLS.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +129,7 @@ var allFields = []configField{
|
|||||||
comments: []string{
|
comments: []string{
|
||||||
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
"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,",
|
"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:",
|
" Examples:",
|
||||||
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
||||||
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
" 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.",
|
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_ENABLED",
|
||||||
|
defVal: "true",
|
||||||
|
comments: []string{
|
||||||
|
"--- Security Notifications ---",
|
||||||
|
"Send email alerts to users when their account is targeted by brute-force attacks.",
|
||||||
|
"Set to false to disable all security notification emails.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_SMTP_HOST",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP server hostname for sending security notification emails.",
|
||||||
|
"Example: smtp.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_SMTP_PORT",
|
||||||
|
defVal: "587",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_FROM",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Sender address for security notification emails. Example: security@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_USER",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_PASS",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_ENABLED",
|
||||||
|
defVal: "true",
|
||||||
|
comments: []string{
|
||||||
|
"--- Brute Force Protection ---",
|
||||||
|
"Enable automatic IP blocking after repeated failed logins.",
|
||||||
|
"Set to false to disable entirely.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_MAX_ATTEMPTS",
|
||||||
|
defVal: "5",
|
||||||
|
comments: []string{
|
||||||
|
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_WINDOW_MINUTES",
|
||||||
|
defVal: "30",
|
||||||
|
comments: []string{
|
||||||
|
"Time window in minutes for counting failed login attempts.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_BAN_HOURS",
|
||||||
|
defVal: "12",
|
||||||
|
comments: []string{
|
||||||
|
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_WHITELIST_IPS",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
|
||||||
|
"Example: 192.168.1.1,10.0.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "GEO_BLOCK_COUNTRIES",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
|
||||||
|
"Comma-separated 2-letter ISO country codes to DENY access from.",
|
||||||
|
"Example: CN,RU,KP",
|
||||||
|
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "GEO_ALLOW_COUNTRIES",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
|
||||||
|
"Example: SK,CZ,DE",
|
||||||
|
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "DB_PATH",
|
key: "DB_PATH",
|
||||||
defVal: "./data/gowebmail.db",
|
defVal: "./data/gowebmail.db",
|
||||||
@@ -184,10 +306,15 @@ var allFields = []configField{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "MICROSOFT_TENANT_ID",
|
key: "MICROSOFT_TENANT_ID",
|
||||||
defVal: "common",
|
defVal: "consumers",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"Use 'common' to allow any Microsoft account,",
|
"Tenant endpoint to use for Microsoft OAuth2.",
|
||||||
"or your Azure tenant ID to restrict to one organisation.",
|
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
|
||||||
|
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
|
||||||
|
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
|
||||||
|
" Use if registered as 'Personal accounts only'.",
|
||||||
|
" organizations - Work/school Microsoft 365 accounts only.",
|
||||||
|
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -228,7 +355,7 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
// get returns env var if set, else file value, else ""
|
// get returns env var if set, else file value, else ""
|
||||||
get := func(key string) string {
|
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
|
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
||||||
// picking up cloud-platform env vars unintentionally.
|
// picking up cloud-platform env vars unintentionally.
|
||||||
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
||||||
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
|
|||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
DBPath: get("DB_PATH"),
|
DBPath: get("DB_PATH"),
|
||||||
|
Debug: atobool(get("DEBUG"), false),
|
||||||
EncryptionKey: encKey,
|
EncryptionKey: encKey,
|
||||||
SessionSecret: []byte(sessSecret),
|
SessionSecret: []byte(sessSecret),
|
||||||
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
||||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
|
|
||||||
|
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
|
||||||
|
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
|
||||||
|
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
|
||||||
|
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
|
||||||
|
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
|
||||||
|
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
|
||||||
|
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
|
||||||
|
|
||||||
|
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
|
||||||
|
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
|
||||||
|
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
|
||||||
|
NotifyFrom: get("NOTIFY_FROM"),
|
||||||
|
NotifyUser: get("NOTIFY_USER"),
|
||||||
|
NotifyPass: get("NOTIFY_PASS"),
|
||||||
|
|
||||||
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||||
GoogleRedirectURL: googleRedirect,
|
GoogleRedirectURL: googleRedirect,
|
||||||
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
||||||
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
||||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
|
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
|
||||||
MicrosoftRedirectURL: outlookRedirect,
|
MicrosoftRedirectURL: outlookRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
|
||||||
|
func (c *Config) IsIPWhitelisted(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range c.BruteWhitelist {
|
||||||
|
if w.Equal(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
|
||||||
|
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
|
||||||
|
func (c *Config) IsCountryAllowed(code string) bool {
|
||||||
|
code = strings.ToUpper(code)
|
||||||
|
if len(c.GeoBlockCountries) > 0 {
|
||||||
|
for _, bc := range c.GeoBlockCountries {
|
||||||
|
if bc == code {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.GeoAllowCountries) > 0 {
|
||||||
|
for _, ac := range c.GeoAllowCountries {
|
||||||
|
if ac == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
||||||
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
func (c *Config) IsAllowedHost(requestHost string) bool {
|
||||||
@@ -443,7 +622,7 @@ func readConfigFile(path string) (map[string]string, error) {
|
|||||||
|
|
||||||
func writeConfigFile(path string, values map[string]string) error {
|
func writeConfigFile(path string, values map[string]string) error {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("# GoMail Configuration\n")
|
sb.WriteString("# GoWebMail Configuration\n")
|
||||||
sb.WriteString("# =====================\n")
|
sb.WriteString("# =====================\n")
|
||||||
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
||||||
sb.WriteString("# Edit freely — your values are always preserved.\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) {
|
func logStartupInfo(cfg *Config) {
|
||||||
fmt.Printf("GoMail starting:\n")
|
fmt.Printf("GoWebMail starting:\n")
|
||||||
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
||||||
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
||||||
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
||||||
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
||||||
}
|
}
|
||||||
|
if cfg.GoogleClientID != "" {
|
||||||
|
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
|
||||||
|
}
|
||||||
|
if cfg.MicrosoftClientID != "" {
|
||||||
|
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
|
||||||
|
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPList(s string) []net.IP {
|
||||||
|
var ips []net.IP
|
||||||
|
for _, raw := range strings.Split(s, ",") {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(raw); ip != nil {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCountryList(s string) []string {
|
||||||
|
var codes []string
|
||||||
|
for _, raw := range strings.Split(s, ",") {
|
||||||
|
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||||
|
if len(raw) == 2 {
|
||||||
|
codes = append(codes, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustHex(n int) string {
|
func mustHex(n int) string {
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -5,14 +5,13 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
github.com/emersion/go-imap v1.2.1
|
github.com/emersion/go-imap v1.2.1
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.34
|
||||||
golang.org/x/crypto v0.24.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/oauth2 v0.21.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
18
go.sum
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 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
"golang.org/x/oauth2/microsoft"
|
"golang.org/x/oauth2/microsoft"
|
||||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
|||||||
|
|
||||||
// ---- Microsoft / Outlook OAuth2 ----
|
// ---- Microsoft / Outlook OAuth2 ----
|
||||||
|
|
||||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||||
var OutlookScopes = []string{
|
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||||
|
// audience for IMAP XOAUTH2 authentication.
|
||||||
|
var OutlookAuthScopes = []string{
|
||||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||||
"https://outlook.office.com/SMTP.Send",
|
"https://outlook.office.com/SMTP.Send",
|
||||||
"offline_access",
|
"offline_access",
|
||||||
"openid",
|
"openid",
|
||||||
"profile",
|
|
||||||
"email",
|
"email",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||||
|
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||||
|
// "common" routes personal accounts through login.live.com which returns
|
||||||
|
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
RedirectURL: redirectURL,
|
RedirectURL: redirectURL,
|
||||||
Scopes: OutlookScopes,
|
Scopes: OutlookAuthScopes,
|
||||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||||
|
// authorization and exchanges it for an access token scoped to the Outlook
|
||||||
|
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||||
|
// The two-step approach is necessary because:
|
||||||
|
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||||
|
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||||
|
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||||
|
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("grant_type", "refresh_token")
|
||||||
|
params.Set("client_id", clientID)
|
||||||
|
params.Set("client_secret", clientSecret)
|
||||||
|
params.Set("refresh_token", refreshToken)
|
||||||
|
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||||
|
}
|
||||||
|
if result.Error != "" {
|
||||||
|
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||||
|
}
|
||||||
|
if result.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||||
|
preview := result.AccessToken
|
||||||
|
if len(preview) > 30 {
|
||||||
|
preview = preview[:30] + "..."
|
||||||
|
}
|
||||||
|
parts := strings.Count(result.AccessToken, ".") + 1
|
||||||
|
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||||
|
parts, preview, params.Get("scope"))
|
||||||
|
|
||||||
|
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||||
|
return &oauth2.Token{
|
||||||
|
AccessToken: result.AccessToken,
|
||||||
|
RefreshToken: result.RefreshToken,
|
||||||
|
Expiry: expiry,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||||
type MicrosoftUserInfo struct {
|
type MicrosoftUserInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"` // Graph field
|
||||||
|
Name string `json:"name"` // ID token claim
|
||||||
Mail string `json:"mail"`
|
Mail string `json:"mail"`
|
||||||
|
EmailClaim string `json:"email"` // ID token claim
|
||||||
UserPrincipalName string `json:"userPrincipalName"`
|
UserPrincipalName string `json:"userPrincipalName"`
|
||||||
|
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email returns the best available email address.
|
// Email returns the best available email address.
|
||||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
|||||||
if m.Mail != "" {
|
if m.Mail != "" {
|
||||||
return m.Mail
|
return m.Mail
|
||||||
}
|
}
|
||||||
|
if m.EmailClaim != "" {
|
||||||
|
return m.EmailClaim
|
||||||
|
}
|
||||||
|
if m.PreferredUsername != "" {
|
||||||
|
return m.PreferredUsername
|
||||||
|
}
|
||||||
return m.UserPrincipalName
|
return m.UserPrincipalName
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
// BestName returns the best available display name.
|
||||||
|
func (m *MicrosoftUserInfo) BestName() string {
|
||||||
|
if m.DisplayName != "" {
|
||||||
|
return m.DisplayName
|
||||||
|
}
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||||
|
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||||
|
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||||
|
// The ID token is issued alongside the access token and contains email/name claims.
|
||||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||||
client := cfg.Client(ctx, token)
|
idToken, _ := token.Extra("id_token").(string)
|
||||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
if idToken == "" {
|
||||||
|
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT structure: header.payload.signature — decode the payload only
|
||||||
|
parts := strings.SplitN(idToken, ".", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info MicrosoftUserInfo
|
var info MicrosoftUserInfo
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Email() == "" {
|
||||||
|
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||||
}
|
}
|
||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Outlook Personal (Graph API) ----
|
||||||
|
|
||||||
|
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||||
|
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||||
|
// Mail is accessed via Graph REST API instead of IMAP.
|
||||||
|
var OutlookPersonalScopes = []string{
|
||||||
|
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||||
|
"https://graph.microsoft.com/Mail.Send",
|
||||||
|
"https://graph.microsoft.com/User.Read",
|
||||||
|
"offline_access",
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||||
|
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||||
|
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: OutlookPersonalScopes,
|
||||||
|
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token refresh helpers ----
|
// ---- Token refresh helpers ----
|
||||||
|
|
||||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||||
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
|||||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||||
return ts.Token()
|
return ts.Token()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
|
||||||
|
// Pass the credentials for both providers; the correct ones are selected based
|
||||||
|
// on provider ("gmail" or "outlook").
|
||||||
|
func RefreshAccountToken(ctx context.Context,
|
||||||
|
provider, refreshToken, baseURL,
|
||||||
|
googleClientID, googleClientSecret,
|
||||||
|
msClientID, msClientSecret, msTenantID string,
|
||||||
|
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "gmail":
|
||||||
|
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||||
|
case "outlook":
|
||||||
|
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
rt := tok.RefreshToken
|
||||||
|
if rt == "" {
|
||||||
|
rt = refreshToken
|
||||||
|
}
|
||||||
|
return tok.AccessToken, rt, tok.Expiry, nil
|
||||||
|
case "outlook_personal":
|
||||||
|
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||||
|
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||||
|
baseURL+"/auth/outlook-personal/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
rt := tok.RefreshToken
|
||||||
|
if rt == "" {
|
||||||
|
rt = refreshToken
|
||||||
|
}
|
||||||
|
return tok.AccessToken, rt, tok.Expiry, nil
|
||||||
|
default:
|
||||||
|
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
"github.com/emersion/go-imap/client"
|
"github.com/emersion/go-imap/client"
|
||||||
|
|
||||||
@@ -54,7 +56,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
|
|||||||
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
||||||
return "XOAUTH2", []byte(payload), nil
|
return "XOAUTH2", []byte(payload), nil
|
||||||
}
|
}
|
||||||
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
|
|
||||||
|
// Next handles the XOAUTH2 challenge from the server.
|
||||||
|
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
||||||
|
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
||||||
|
// final tagged NO response and returns a proper error.
|
||||||
|
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
||||||
|
if len(challenge) > 0 {
|
||||||
|
// Decode and log the error from Microsoft so it appears in server logs
|
||||||
|
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
||||||
|
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
||||||
|
}
|
||||||
|
// Send empty response to let the server send the final error
|
||||||
|
return []byte("\x01"), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
type xoauth2SMTP struct{ user, token string }
|
type xoauth2SMTP struct{ user, token string }
|
||||||
|
|
||||||
@@ -80,6 +99,9 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
||||||
|
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
||||||
|
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
||||||
|
}
|
||||||
host, port := imapHostFor(account.Provider)
|
host, port := imapHostFor(account.Provider)
|
||||||
if account.IMAPHost != "" {
|
if account.IMAPHost != "" {
|
||||||
host = account.IMAPHost
|
host = account.IMAPHost
|
||||||
@@ -108,6 +130,33 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
|||||||
|
|
||||||
switch account.Provider {
|
switch account.Provider {
|
||||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||||
|
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
||||||
|
tokenPreview := account.AccessToken
|
||||||
|
if len(tokenPreview) > 20 {
|
||||||
|
tokenPreview = tokenPreview[:20] + "..."
|
||||||
|
}
|
||||||
|
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
||||||
|
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||||
|
var claims struct {
|
||||||
|
Aud interface{} `json:"aud"`
|
||||||
|
Scp string `json:"scp"`
|
||||||
|
Upn string `json:"upn"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(payload, &claims) == nil {
|
||||||
|
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
|
||||||
|
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
|
||||||
|
account.EmailAddress, string(payload), tokenPreview)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
|
||||||
|
account.EmailAddress, tokenPreview)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
|
||||||
|
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
||||||
|
}
|
||||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||||
if err := c.Authenticate(sasl); err != nil {
|
if err := c.Authenticate(sasl); err != nil {
|
||||||
c.Logout()
|
c.Logout()
|
||||||
@@ -392,8 +441,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
|
|||||||
return m, nil
|
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
|
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
||||||
// text/plain, text/html and attachment metadata.
|
// 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) {
|
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -405,12 +460,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
|
|||||||
ct = "text/plain"
|
ct = "text/plain"
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(msg.Body)
|
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
|
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.
|
// parsePart recursively handles a MIME part.
|
||||||
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
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)
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(body), "", nil
|
return string(body), "", nil
|
||||||
@@ -430,11 +617,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
return string(decoded), "", nil
|
return string(decoded), "", nil
|
||||||
}
|
}
|
||||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||||
|
partIdx := 0
|
||||||
for {
|
for {
|
||||||
part, err := mr.NextPart()
|
part, err := mr.NextPart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
partIdx++
|
||||||
|
childPath := append(append([]int{}, path...), partIdx)
|
||||||
|
|
||||||
partBody, _ := io.ReadAll(part)
|
partBody, _ := io.ReadAll(part)
|
||||||
partCT := part.Header.Get("Content-Type")
|
partCT := part.Header.Get("Content-Type")
|
||||||
if partCT == "" {
|
if partCT == "" {
|
||||||
@@ -444,24 +635,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
disposition := part.Header.Get("Content-Disposition")
|
disposition := part.Header.Get("Content-Disposition")
|
||||||
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||||
|
|
||||||
if strings.EqualFold(dispType, "attachment") {
|
// Filename from Content-Disposition or Content-Type params
|
||||||
filename := dispParams["filename"]
|
filename := dispParams["filename"]
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = part.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 == "" {
|
if filename == "" {
|
||||||
filename = "attachment"
|
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{
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
ContentType: partMedia,
|
ContentType: partMedia,
|
||||||
Size: int64(len(partBody)),
|
Size: int64(len(partBody)),
|
||||||
|
ContentID: mimePartPath, // reuse ContentID to store part path
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
t, h, atts := parsePart(partCT, partTE, partBody)
|
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
|
||||||
if text == "" && t != "" {
|
if text == "" && t != "" {
|
||||||
text = t
|
text = t
|
||||||
}
|
}
|
||||||
@@ -471,13 +679,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
attachments = append(attachments, atts...)
|
attachments = append(attachments, atts...)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Any other type – treat as attachment if it has a filename
|
// Any other non-text type with a filename → treat as attachment
|
||||||
mt, _, _ := mime.ParseMediaType(contentType)
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||||
_ = mt
|
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
|
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 {
|
func decodeTransfer(encoding string, data []byte) []byte {
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
case "base64":
|
case "base64":
|
||||||
@@ -671,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
rawMsg := buf.Bytes()
|
rawMsg := buf.Bytes()
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
|
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
|
||||||
|
|
||||||
var c *smtp.Client
|
var c *smtp.Client
|
||||||
var err error
|
var err error
|
||||||
@@ -712,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
if err := authSMTP(c, account, host); err != nil {
|
if err := authSMTP(c, account, host); err != nil {
|
||||||
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
||||||
}
|
}
|
||||||
log.Printf("[SMTP] auth OK")
|
logger.Debug("[SMTP] auth OK")
|
||||||
|
|
||||||
if err := c.Mail(account.EmailAddress); err != nil {
|
if err := c.Mail(account.EmailAddress); err != nil {
|
||||||
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
||||||
@@ -742,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
// DATA close is where the server accepts or rejects the message
|
// DATA close is where the server accepts or rejects the message
|
||||||
return fmt.Errorf("SMTP server rejected message: %w", err)
|
return fmt.Errorf("SMTP server rejected message: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("[SMTP] message accepted by server")
|
logger.Debug("[SMTP] message accepted by server")
|
||||||
_ = c.Quit()
|
_ = c.Quit()
|
||||||
|
|
||||||
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
||||||
@@ -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 {
|
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
||||||
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
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
|
// Use the sender's actual domain for Message-ID so it passes spam filters
|
||||||
domain := account.EmailAddress
|
domain := account.EmailAddress
|
||||||
if at := strings.Index(domain, "@"); at >= 0 {
|
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("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
||||||
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\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("MIME-Version: 1.0\r\n")
|
||||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
|
|
||||||
buf.WriteString("\r\n")
|
|
||||||
|
|
||||||
// Plain text part
|
hasAttachments := len(req.Attachments) > 0
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
if hasAttachments {
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
// Outer multipart/mixed wraps body + attachments
|
||||||
qpw := quotedprintable.NewWriter(buf)
|
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
|
plainText := req.BodyText
|
||||||
if plainText == "" && req.BodyHTML != "" {
|
if plainText == "" && req.BodyHTML != "" {
|
||||||
plainText = htmlToPlainText(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.Write([]byte(plainText))
|
||||||
qpw.Close()
|
qpw.Close()
|
||||||
buf.WriteString("\r\n")
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
// HTML part
|
buf.WriteString("--" + altBoundary + "\r\n")
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
qpw2 := quotedprintable.NewWriter(buf)
|
qpw2 := quotedprintable.NewWriter(buf)
|
||||||
@@ -809,8 +1048,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
|||||||
}
|
}
|
||||||
qpw2.Close()
|
qpw2.Close()
|
||||||
buf.WriteString("\r\n")
|
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
|
return msgID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +1137,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
|
|||||||
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
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 {
|
func htmlEscape(s string) string {
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
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/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -108,9 +109,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
|
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
DisableMFA bool `json:"disable_mfa"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
adminID := middleware.GetUserID(r)
|
||||||
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
||||||
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"changed": changed,
|
"changed": changed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- IP Blocks ----
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
blocks, err := h.db.ListIPBlocks()
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blocks == nil {
|
||||||
|
blocks = []db.IPBlock{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
BanHours int `json:"ban_hours"` // 0 = permanent
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.IP == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try geo lookup for the IP being manually blocked
|
||||||
|
g := geo.Lookup(req.IP)
|
||||||
|
if req.Reason == "" {
|
||||||
|
req.Reason = "Manual admin block"
|
||||||
|
}
|
||||||
|
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
|
||||||
|
adminID := middleware.GetUserID(r)
|
||||||
|
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := mux.Vars(r)["ip"]
|
||||||
|
if ip == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UnblockIP(ip); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "unblock failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID := middleware.GetUserID(r)
|
||||||
|
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Login Attempts ----
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stats == nil {
|
||||||
|
stats = []db.LoginAttemptStat{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]interface{}{"attempts": stats})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,8 +13,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
|
graphpkg "github.com/ghostersk/gowebmail/internal/graph"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||||
@@ -43,8 +46,9 @@ func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
// GetProviders returns which OAuth providers are configured and enabled.
|
// GetProviders returns which OAuth providers are configured and enabled.
|
||||||
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
h.writeJSON(w, map[string]bool{
|
h.writeJSON(w, map[string]bool{
|
||||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||||
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||||
|
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +63,13 @@ type safeAccount struct {
|
|||||||
IMAPPort int `json:"imap_port,omitempty"`
|
IMAPPort int `json:"imap_port,omitempty"`
|
||||||
SMTPHost string `json:"smtp_host,omitempty"`
|
SMTPHost string `json:"smtp_host,omitempty"`
|
||||||
SMTPPort int `json:"smtp_port,omitempty"`
|
SMTPPort int `json:"smtp_port,omitempty"`
|
||||||
|
SyncDays int `json:"sync_days"`
|
||||||
|
SyncMode string `json:"sync_mode"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
LastSync string `json:"last_sync"`
|
LastSync string `json:"last_sync"`
|
||||||
|
TokenExpired bool `json:"token_expired,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||||
@@ -69,11 +77,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
|
|||||||
if !a.LastSync.IsZero() {
|
if !a.LastSync.IsZero() {
|
||||||
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
||||||
}
|
}
|
||||||
|
tokenExpired := false
|
||||||
|
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook || a.Provider == models.ProviderOutlookPersonal) && auth.IsTokenExpired(a.TokenExpiry) {
|
||||||
|
tokenExpired = true
|
||||||
|
}
|
||||||
return safeAccount{
|
return safeAccount{
|
||||||
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
||||||
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
||||||
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
||||||
|
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
|
||||||
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
|
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
|
||||||
|
TokenExpired: tokenExpired,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +489,52 @@ func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) SetAccountSortOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Order []int64 `json:"order"` // account IDs in desired display order
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Order) == 0 {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateAccountSortOrder(userID, req.Order); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to save order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) GetUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
prefs, err := h.db.GetUIPrefs(userID)
|
||||||
|
if err != nil {
|
||||||
|
prefs = "{}"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) SetUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate it's valid JSON before storing
|
||||||
|
var check map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &check); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SetUIPrefs(userID, string(body)); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Messages ----
|
// ---- Messages ----
|
||||||
|
|
||||||
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -532,6 +592,42 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.db.MarkMessageRead(messageID, userID, true)
|
h.db.MarkMessageRead(messageID, userID, true)
|
||||||
|
|
||||||
|
// For Graph accounts: fetch body lazily on open (not stored during list sync)
|
||||||
|
if msg.BodyHTML == "" && msg.BodyText == "" {
|
||||||
|
if graphMsgID, _, account, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil &&
|
||||||
|
account != nil && account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), graphMsgID); gErr == nil {
|
||||||
|
if gMsg.Body.ContentType == "html" {
|
||||||
|
msg.BodyHTML = gMsg.Body.Content
|
||||||
|
} else {
|
||||||
|
msg.BodyText = gMsg.Body.Content
|
||||||
|
}
|
||||||
|
// Persist so next open is instant
|
||||||
|
h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
|
||||||
|
// (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)
|
h.writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,22 +638,22 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
Read bool `json:"read"`
|
Read bool `json:"read"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
// Update local DB first
|
|
||||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||||
|
|
||||||
// Enqueue IMAP op — drained by background worker with retry
|
if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
|
||||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
account.Provider == models.ProviderOutlookPersonal {
|
||||||
if err == nil && uid != 0 && account != nil {
|
go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read)
|
||||||
val := "0"
|
} else {
|
||||||
if req.Read {
|
uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
val = "1"
|
if err2 == nil && uid != 0 && acc != nil {
|
||||||
|
val := "0"
|
||||||
|
if req.Read { val = "1" }
|
||||||
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
|
AccountID: acc.ID, OpType: "flag_read",
|
||||||
|
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||||
|
})
|
||||||
|
h.syncer.TriggerAccountSync(acc.ID)
|
||||||
}
|
}
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
|
||||||
AccountID: account.ID, OpType: "flag_read",
|
|
||||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
|
||||||
})
|
|
||||||
h.syncer.TriggerAccountSync(account.ID)
|
|
||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
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")
|
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil &&
|
||||||
if ierr == nil && uid != 0 && account != nil {
|
account.Provider == models.ProviderOutlookPersonal {
|
||||||
val := "0"
|
go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred)
|
||||||
if starred {
|
} else {
|
||||||
val = "1"
|
uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
|
if ierr == nil && uid != 0 && acc != nil {
|
||||||
|
val := "0"
|
||||||
|
if starred { val = "1" }
|
||||||
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
|
AccountID: acc.ID, OpType: "flag_star",
|
||||||
|
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||||
|
})
|
||||||
|
h.syncer.TriggerAccountSync(acc.ID)
|
||||||
}
|
}
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
|
||||||
AccountID: account.ID, OpType: "flag_star",
|
|
||||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
|
||||||
})
|
|
||||||
h.syncer.TriggerAccountSync(account.ID)
|
|
||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue IMAP move
|
// Route to Graph or IMAP
|
||||||
if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
if graphMsgID, _, graphAcc, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && graphAcc != nil &&
|
||||||
|
graphAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil {
|
||||||
|
go graphpkg.New(graphAcc).MoveMessage(context.Background(), graphMsgID, destFolder.FullPath)
|
||||||
|
} else if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
AccountID: account.ID, OpType: "move",
|
AccountID: account.ID, OpType: "move",
|
||||||
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
||||||
@@ -621,17 +723,18 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := middleware.GetUserID(r)
|
userID := middleware.GetUserID(r)
|
||||||
messageID := pathInt64(r, "id")
|
messageID := pathInt64(r, "id")
|
||||||
|
|
||||||
// Get IMAP info before deleting from DB
|
// Get message info before deleting from DB
|
||||||
|
graphMsgID, _, graphAcc, graphErr := h.db.GetMessageGraphInfo(messageID, userID)
|
||||||
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
|
|
||||||
// Delete from local DB
|
|
||||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue IMAP delete
|
if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal {
|
||||||
if imapErr == nil && uid != 0 && account != nil {
|
go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID)
|
||||||
|
} else if imapErr == nil && uid != 0 && account != nil {
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
AccountID: account.ID, OpType: "delete",
|
AccountID: account.ID, OpType: "delete",
|
||||||
RemoteUID: uid, FolderPath: folderPath,
|
RemoteUID: uid, FolderPath: folderPath,
|
||||||
@@ -652,13 +755,54 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
h.handleSend(w, r, "forward")
|
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) {
|
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
|
||||||
userID := middleware.GetUserID(r)
|
userID := middleware.GetUserID(r)
|
||||||
|
|
||||||
var req models.ComposeRequest
|
var req models.ComposeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
ct := r.Header.Get("Content-Type")
|
||||||
return
|
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)
|
account, err := h.db.GetAccount(req.AccountID)
|
||||||
@@ -667,6 +811,48 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
return
|
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 {
|
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
h.db.WriteAudit(&userID, models.AuditAppError,
|
||||||
@@ -675,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
h.writeError(w, http.StatusBadGateway, err.Error())
|
h.writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Trigger immediate sync so the sent message appears in Sent Items
|
||||||
|
h.syncer.TriggerAccountSync(account.ID)
|
||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,3 +1250,229 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]interface{}{"messages": msgs})
|
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) {
|
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "app", nil)
|
h.renderer.Render(w, "app", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ViewMessage renders a single message in a full browser tab.
|
||||||
|
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "message", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposePage renders the compose form in a full browser tab.
|
||||||
|
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "compose", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||||
@@ -23,6 +29,7 @@ type AuthHandler struct {
|
|||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
|
syncer interface{ TriggerReconcile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Login ----
|
// ---- Login ----
|
||||||
@@ -53,6 +60,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||||
|
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||||
|
case "deny":
|
||||||
|
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||||
|
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||||
|
return
|
||||||
|
case "skip_brute":
|
||||||
|
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||||
|
w.Header().Set("X-Skip-Brute", "1")
|
||||||
|
}
|
||||||
|
|
||||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||||
uid := user.ID
|
uid := user.ID
|
||||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||||
@@ -142,8 +160,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
|
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
|
||||||
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
|
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
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,
|
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||||
}
|
}
|
||||||
if err := h.db.CreateAccount(account); err != nil {
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := userID
|
uid := userID
|
||||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
|
action := "gmail:" + userInfo.Email
|
||||||
|
if !created {
|
||||||
|
action = "gmail-reconnect:" + userInfo.Email
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,13 +345,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
state := encodeOAuthState(userID, "outlook")
|
state := encodeOAuthState(userID, "outlook")
|
||||||
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
|
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
|
||||||
|
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
http.Redirect(w, r, url, http.StatusFound)
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
// Microsoft returns ?error=...&error_description=... instead of ?code=...
|
||||||
|
// when the user denies consent or the app has misconfigured permissions.
|
||||||
|
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||||
|
msDesc := r.URL.Query().Get("error_description")
|
||||||
|
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||||
|
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
|
||||||
|
<h2>Microsoft returned: %s</h2>
|
||||||
|
<pre>%s</pre>
|
||||||
|
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
|
||||||
|
<ul>
|
||||||
|
<li>In Azure portal → API Permissions → Add a permission</li>
|
||||||
|
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
|
||||||
|
<li>Search: <strong>Office 365 Exchange Online</strong></li>
|
||||||
|
<li>Delegated permissions → add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
|
||||||
|
<li>Then click <strong>Grant admin consent</strong></li>
|
||||||
|
<li>Do NOT use Microsoft Graph versions of these scopes</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="/">← Back to GoWebMail</a></p>
|
||||||
|
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
|
||||||
|
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID, provider := decodeOAuthState(state)
|
userID, provider := decodeOAuthState(state)
|
||||||
if userID == 0 || provider != "outlook" {
|
if userID == 0 || provider != "outlook" {
|
||||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||||
@@ -335,29 +399,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
|
||||||
|
// Show the raw error in the browser so the user can diagnose the problem
|
||||||
|
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||||
|
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
|
||||||
|
<h2>Outlook OAuth Token Exchange Failed</h2>
|
||||||
|
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
|
||||||
|
<pre>%s</pre>
|
||||||
|
<hr>
|
||||||
|
<p><strong>Things to check:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
|
||||||
|
<li>Tenant ID in config: <code>%s</code> — must match your app's "Supported account types"</li>
|
||||||
|
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
|
||||||
|
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
|
||||||
|
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="/">← Back to GoWebMail</a></p>
|
||||||
|
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
|
||||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
|
||||||
|
|
||||||
|
// Exchange initial token for one scoped to https://outlook.office.com
|
||||||
|
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
|
||||||
|
imapToken, err := goauth.ExchangeForIMAPToken(
|
||||||
|
r.Context(),
|
||||||
|
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, token.RefreshToken,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
|
||||||
|
imapToken = token
|
||||||
|
} else {
|
||||||
|
logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
|
||||||
|
if imapToken.RefreshToken == "" {
|
||||||
|
imapToken.RefreshToken = token.RefreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||||
color := colors[len(accounts)%len(colors)]
|
color := colors[len(accounts)%len(colors)]
|
||||||
account := &models.EmailAccount{
|
account := &models.EmailAccount{
|
||||||
UserID: userID, Provider: models.ProviderOutlook,
|
UserID: userID, Provider: models.ProviderOutlook,
|
||||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
|
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
|
||||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
|
||||||
}
|
}
|
||||||
if err := h.db.CreateAccount(account); err != nil {
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := userID
|
uid := userID
|
||||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
|
action := "outlook:" + userInfo.Email()
|
||||||
|
if !created {
|
||||||
|
action = "outlook-reconnect:" + userInfo.Email()
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,3 +518,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Profile Updates ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
user, err := h.db.GetUserByID(userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Field string `json:"field"` // "email" | "username"
|
||||||
|
Value string `json:"value"`
|
||||||
|
Password string `json:"password"` // current password required for confirmation
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if req.Value == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "value required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "incorrect password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Field {
|
||||||
|
case "email":
|
||||||
|
// Check uniqueness
|
||||||
|
existing, _ := h.db.GetUserByEmail(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "username":
|
||||||
|
existing, _ := h.db.GetUserByUsername(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "username already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-User IP Rules ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
rule, err := h.db.GetUserIPRule(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
|
||||||
|
IPList string `json:"ip_list"` // comma-separated
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
|
||||||
|
if !validModes[req.Mode] {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IPs
|
||||||
|
for _, rawIP := range db.SplitIPList(req.IPList) {
|
||||||
|
if net.ParseIP(rawIP) == nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Mode == "disabled" {
|
||||||
|
h.db.DeleteUserIPRule(userID)
|
||||||
|
} else {
|
||||||
|
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Outlook Personal (Graph API) OAuth2 ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cfg.MicrosoftClientID == "" {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
state := encodeOAuthState(userID, "outlook_personal")
|
||||||
|
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, redirectURL)
|
||||||
|
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
|
||||||
|
h.cfg.MicrosoftTenantID, redirectURL)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||||
|
msDesc := r.URL.Query().Get("error_description")
|
||||||
|
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
|
||||||
|
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
|
||||||
|
<h2>Microsoft returned: %s</h2><pre>%s</pre>
|
||||||
|
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
|
||||||
|
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
|
||||||
|
<p><a href="/">← Back</a></p></body></html>`,
|
||||||
|
html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, provider := decodeOAuthState(state)
|
||||||
|
if userID == 0 || provider != "outlook_personal" {
|
||||||
|
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
|
||||||
|
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
|
||||||
|
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info from ID token
|
||||||
|
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
|
||||||
|
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
|
||||||
|
tokenParts := len(strings.Split(token.AccessToken, "."))
|
||||||
|
logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d",
|
||||||
|
userInfo.Email(), tokenParts)
|
||||||
|
|
||||||
|
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||||
|
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||||
|
color := colors[len(accounts)%len(colors)]
|
||||||
|
account := &models.EmailAccount{
|
||||||
|
UserID: userID, Provider: models.ProviderOutlookPersonal,
|
||||||
|
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||||
|
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||||
|
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||||
|
}
|
||||||
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID
|
||||||
|
action := "outlook-personal:" + userInfo.Email()
|
||||||
|
if !created {
|
||||||
|
action = "outlook-personal-reconnect:" + userInfo.Email()
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
|
||||||
|
}
|
||||||
|
|||||||
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{
|
return &Handlers{
|
||||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
|
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
|
||||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
|||||||
"login.html",
|
"login.html",
|
||||||
"mfa.html",
|
"mfa.html",
|
||||||
"admin.html",
|
"admin.html",
|
||||||
|
"message.html",
|
||||||
|
"compose.html",
|
||||||
}
|
}
|
||||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
24
internal/logger/logger.go
Normal file
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.
|
// 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 {
|
func OTPAuthURL(issuer, accountName, secret string) string {
|
||||||
v := url.Values{}
|
v := url.Values{}
|
||||||
v.Set("secret", secret)
|
v.Set("secret", secret)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Package middleware provides HTTP middleware for GoMail.
|
// Package middleware provides HTTP middleware for GoWebMail.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,7 +13,9 @@ import (
|
|||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Content-Security-Policy",
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||||
if role != models.RoleAdmin {
|
if role != models.RoleAdmin {
|
||||||
if isAPIPath(r) {
|
if isAPIPath(r) {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
fmt.Fprint(w, `{"error":"forbidden"}`)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"Access Denied",
|
||||||
|
"You don't have permission to access this page. Admin privileges are required.")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
|
||||||
|
// It must be called with the raw handler so it can intercept BEFORE auth.
|
||||||
|
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
|
|
||||||
|
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
|
||||||
|
if cfg.IsIPWhitelisted(ip) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve country for geo-block and attempt recording.
|
||||||
|
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
|
||||||
|
geoResult := geo.Lookup(ip)
|
||||||
|
|
||||||
|
// --- Geo block (apply to all requests) ---
|
||||||
|
if geoResult.CountryCode != "" {
|
||||||
|
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
|
||||||
|
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
|
||||||
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"Access Denied",
|
||||||
|
"Access from your country is not permitted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.BruteEnabled || r.Method != http.MethodPost {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already blocked
|
||||||
|
if database.IsIPBlocked(ip) {
|
||||||
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"IP Address Blocked",
|
||||||
|
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the response writer to detect a failed login (redirect to error vs success)
|
||||||
|
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
|
||||||
|
// Determine success: a redirect away from login = success
|
||||||
|
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
|
||||||
|
username := r.FormValue("username")
|
||||||
|
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
|
||||||
|
|
||||||
|
if !success && !rw.skipBrute {
|
||||||
|
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
|
||||||
|
if failures >= cfg.BruteMaxAttempts {
|
||||||
|
reason := "Too many failed logins"
|
||||||
|
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
|
||||||
|
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
|
||||||
|
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
|
||||||
|
|
||||||
|
// Send security notification to the targeted user (non-blocking goroutine)
|
||||||
|
go func(targetUsername string) {
|
||||||
|
user, _ := database.GetUserByUsername(targetUsername)
|
||||||
|
if user == nil {
|
||||||
|
user, _ = database.GetUserByEmail(targetUsername)
|
||||||
|
}
|
||||||
|
if user != nil && user.Email != "" {
|
||||||
|
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
|
||||||
|
Username: user.Username,
|
||||||
|
ToEmail: user.Email,
|
||||||
|
AttackerIP: ip,
|
||||||
|
Country: geoResult.Country,
|
||||||
|
CountryCode: geoResult.CountryCode,
|
||||||
|
Attempts: failures,
|
||||||
|
BlockedAt: time.Now().UTC(),
|
||||||
|
BanHours: cfg.BruteBanHours,
|
||||||
|
Hostname: cfg.Hostname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
|
||||||
|
type loginResponseCapture struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
location string
|
||||||
|
skipBrute bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||||
|
lrc.statusCode = code
|
||||||
|
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||||
|
if lrc.Header().Get("X-Skip-Brute") == "1" {
|
||||||
|
lrc.skipBrute = true
|
||||||
|
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
|
||||||
|
}
|
||||||
|
lrc.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
|
||||||
|
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||||
|
renderErrorPage(w, r, status, title, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderErrorPage writes a themed HTML error page for browser requests,
|
||||||
|
// or a JSON error for API paths.
|
||||||
|
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||||
|
if isAPIPath(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Back-button destination: always send to "/" which RequireAuth will
|
||||||
|
// transparently forward to /auth/login if the session is absent or invalid.
|
||||||
|
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
|
||||||
|
backHref := "/"
|
||||||
|
backLabel := "← Go Back"
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Status int
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
BackHref string
|
||||||
|
BackLabel string
|
||||||
|
}{status, title, message, backHref, backLabel}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := errorPageTmpl.Execute(w, data); err != nil {
|
||||||
|
// Last-resort plain text fallback
|
||||||
|
fmt.Fprintf(w, "%d %s: %s", status, title, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Status}} – {{.Title}}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/gowebmail.css">
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg, #18191b);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
.error-card {
|
||||||
|
background: var(--surface, #232428);
|
||||||
|
border: 1px solid var(--border, #2e2f34);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 56px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.error-code {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent, #6b8afd);
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #e8e9ed);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted, #8b8d97);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
.error-back {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--accent, #6b8afd);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.error-back:hover { opacity: .85; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-card">
|
||||||
|
<div class="error-code">{{.Status}}</div>
|
||||||
|
<h1 class="error-title">{{.Title}}</h1>
|
||||||
|
<p class="error-message">{{.Message}}</p>
|
||||||
|
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "time"
|
|||||||
|
|
||||||
// ---- Users ----
|
// ---- Users ----
|
||||||
|
|
||||||
// UserRole controls access level within GoMail.
|
// UserRole controls access level within GoWebMail.
|
||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -12,7 +12,7 @@ const (
|
|||||||
RoleUser UserRole = "user"
|
RoleUser UserRole = "user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a GoMail application user.
|
// User represents a GoWebMail application user.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -83,9 +83,10 @@ type AuditPage struct {
|
|||||||
type AccountProvider string
|
type AccountProvider string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProviderGmail AccountProvider = "gmail"
|
ProviderGmail AccountProvider = "gmail"
|
||||||
ProviderOutlook AccountProvider = "outlook"
|
ProviderOutlook AccountProvider = "outlook"
|
||||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||||
|
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
||||||
@@ -113,6 +114,7 @@ type EmailAccount struct {
|
|||||||
// Display
|
// Display
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
LastSync time.Time `json:"last_sync"`
|
LastSync time.Time `json:"last_sync"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -213,6 +215,8 @@ type ComposeRequest struct {
|
|||||||
// For reply/forward
|
// For reply/forward
|
||||||
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
||||||
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
||||||
|
// Attachments: populated from multipart/form-data or inline base64
|
||||||
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Search ----
|
// ---- Search ----
|
||||||
@@ -239,3 +243,49 @@ type PagedMessages struct {
|
|||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Contacts ----
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Calendar ----
|
||||||
|
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
AccountID *int64 `json:"account_id,omitempty"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
EndTime string `json:"end_time"`
|
||||||
|
AllDay bool `json:"all_day"`
|
||||||
|
RecurrenceRule string `json:"recurrence_rule"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OrganizerEmail string `json:"organizer_email"`
|
||||||
|
Attendees string `json:"attendees"`
|
||||||
|
AccountColor string `json:"account_color,omitempty"`
|
||||||
|
AccountEmail string `json:"account_email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalDAVToken struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUsed string `json:"last_used,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
201
internal/notify/notify.go
Normal file
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/graph"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scheduler coordinates all background sync activity.
|
// Scheduler coordinates all background sync activity.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
cfg *config.Config
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
// push channels: accountID -> channel to signal "something changed on server"
|
// push channels: accountID -> channel to signal "something changed on server"
|
||||||
pushMu sync.Mutex
|
pushMu sync.Mutex
|
||||||
pushCh map[int64]chan struct{}
|
pushCh map[int64]chan struct{}
|
||||||
|
|
||||||
|
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||||
|
reconcileCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Scheduler.
|
// New creates a new Scheduler.
|
||||||
func New(database *db.DB) *Scheduler {
|
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||||
return &Scheduler{
|
return &Scheduler{
|
||||||
db: database,
|
db: database,
|
||||||
stop: make(chan struct{}),
|
cfg: cfg,
|
||||||
pushCh: make(map[int64]chan struct{}),
|
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)
|
stopWorker(id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case <-s.reconcileCh:
|
||||||
|
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||||
|
activeIDs := make(map[int64]bool, len(workers))
|
||||||
|
for id := range workers {
|
||||||
|
activeIDs[id] = true
|
||||||
|
}
|
||||||
|
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Build active IDs map for reconciliation
|
// Build active IDs map for reconciliation
|
||||||
activeIDs := make(map[int64]bool, len(workers))
|
activeIDs := make(map[int64]bool, len(workers))
|
||||||
@@ -187,6 +214,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
s.graphWorker(account, stop, push)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Initial sync on startup
|
// Initial sync on startup
|
||||||
s.drainPendingOps(account)
|
s.drainPendingOps(account)
|
||||||
s.deltaSync(getAccount())
|
s.deltaSync(getAccount())
|
||||||
@@ -258,6 +291,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||||
@@ -349,7 +384,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
|
|
||||||
mailboxes, err := c.ListMailboxes()
|
mailboxes, err := c.ListMailboxes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "not connected") {
|
||||||
|
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||||
|
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||||
|
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||||
|
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||||
|
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||||
|
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||||
|
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||||
|
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||||
|
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||||
|
}
|
||||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||||
|
s.db.SetAccountError(account.ID, errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
|
|
||||||
s.db.UpdateAccountLastSync(account.ID)
|
s.db.UpdateAccountLastSync(account.ID)
|
||||||
if totalNew > 0 {
|
if totalNew > 0 {
|
||||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +495,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
|||||||
msg.FolderID = dbFolder.ID
|
msg.FolderID = dbFolder.ID
|
||||||
if err := s.db.UpsertMessage(msg); err == nil {
|
if err := s.db.UpsertMessage(msg); err == nil {
|
||||||
newMessages++
|
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)
|
uid := uint32(0)
|
||||||
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
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.
|
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
|
||||||
|
|
||||||
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||||
|
// Graph accounts don't use the IMAP ops queue
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
return
|
||||||
|
}
|
||||||
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
||||||
if err != nil || len(ops) == 0 {
|
if err != nil || len(ops) == 0 {
|
||||||
return
|
return
|
||||||
@@ -492,6 +549,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
||||||
@@ -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) ----
|
// ---- Public API (called by HTTP handlers) ----
|
||||||
|
|
||||||
// SyncAccountNow performs an immediate delta sync of one account.
|
// 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)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -570,3 +721,129 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
|||||||
|
|
||||||
return s.syncFolder(c, account, folder)
|
return s.syncFolder(c, account, folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
|
||||||
|
|
||||||
|
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
|
||||||
|
// It polls Graph API instead of using IMAP.
|
||||||
|
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||||
|
logger.Debug("[graph] worker started for %s", account.EmailAddress)
|
||||||
|
|
||||||
|
getAccount := func() *models.EmailAccount {
|
||||||
|
a, _ := s.db.GetAccount(account.ID)
|
||||||
|
if a == nil {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
s.graphDeltaSync(getAccount())
|
||||||
|
|
||||||
|
syncTicker := time.NewTicker(30 * time.Second)
|
||||||
|
defer syncTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
|
||||||
|
return
|
||||||
|
case <-push:
|
||||||
|
acc := getAccount()
|
||||||
|
s.graphDeltaSync(acc)
|
||||||
|
case <-syncTicker.C:
|
||||||
|
acc := getAccount()
|
||||||
|
// Respect sync interval
|
||||||
|
if !acc.LastSync.IsZero() {
|
||||||
|
interval := time.Duration(acc.SyncInterval) * time.Minute
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if time.Since(acc.LastSync) < interval {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.graphDeltaSync(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
|
||||||
|
// as the IMAP sync path, so the rest of the app works unchanged.
|
||||||
|
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
gc := graph.New(account)
|
||||||
|
|
||||||
|
// Fetch folders
|
||||||
|
gFolders, err := gc.ListFolders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
|
||||||
|
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.db.ClearAccountError(account.ID)
|
||||||
|
|
||||||
|
totalNew := 0
|
||||||
|
for _, gf := range gFolders {
|
||||||
|
folderType := graph.InferFolderType(gf.DisplayName)
|
||||||
|
dbFolder := &models.Folder{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Name: gf.DisplayName,
|
||||||
|
FullPath: gf.ID, // Graph uses opaque IDs as folder path
|
||||||
|
FolderType: folderType,
|
||||||
|
UnreadCount: gf.UnreadCount,
|
||||||
|
TotalCount: gf.TotalCount,
|
||||||
|
SyncEnabled: true,
|
||||||
|
}
|
||||||
|
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
|
||||||
|
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest messages — no since filter, rely on upsert idempotency.
|
||||||
|
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
|
||||||
|
// making date-based filters unreliable across folder types.
|
||||||
|
// Fetching top 100 newest per folder per sync is efficient enough.
|
||||||
|
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gm := range msgs {
|
||||||
|
// Body is NOT included in list response — fetched lazily on first open via GetMessage.
|
||||||
|
msg := &models.Message{
|
||||||
|
AccountID: account.ID,
|
||||||
|
FolderID: dbFolderSaved.ID,
|
||||||
|
RemoteUID: gm.ID,
|
||||||
|
MessageID: gm.InternetMessageID,
|
||||||
|
Subject: gm.Subject,
|
||||||
|
FromName: gm.FromName(),
|
||||||
|
FromEmail: gm.FromEmail(),
|
||||||
|
ToList: gm.ToList(),
|
||||||
|
Date: gm.ReceivedDateTime,
|
||||||
|
IsRead: gm.IsRead,
|
||||||
|
IsStarred: gm.IsFlagged(),
|
||||||
|
HasAttachment: gm.HasAttachments,
|
||||||
|
}
|
||||||
|
if err := s.db.UpsertMessage(msg); err == nil {
|
||||||
|
totalNew++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update folder counts from Graph (more accurate than counting locally)
|
||||||
|
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, gf.TotalCount, gf.UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db.UpdateAccountLastSync(account.ID)
|
||||||
|
if totalNew > 0 {
|
||||||
|
logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
|
|||||||
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
||||||
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
||||||
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||||
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
|
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
|
||||||
|
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
|
||||||
|
.nav-folder-header:hover{background:var(--surface3)}
|
||||||
|
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
|
||||||
|
.acc-drag-handle:hover{opacity:1}
|
||||||
|
.acc-chevron{flex-shrink:0;color:var(--muted);display:flex;align-items:center}
|
||||||
|
.nav-account-group{border-radius:6px;transition:background .15s}
|
||||||
|
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
|
||||||
|
.nav-account-group.acc-dragging{opacity:.4}
|
||||||
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
||||||
align-items:center;justify-content:space-between;flex-shrink:0}
|
align-items:center;justify-content:space-between;flex-shrink:0}
|
||||||
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
@@ -369,10 +377,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
|||||||
|
|
||||||
/* ---- Attachment chips ---- */
|
/* ---- Attachment chips ---- */
|
||||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
.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)}
|
.attachment-chip:hover{background:var(--border2)}
|
||||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
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 ─────────────────────────────────────────── */
|
/* ── Email tag input ─────────────────────────────────────────── */
|
||||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
.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)}
|
from{opacity:0;transform:translateY(16px) scale(.96)}
|
||||||
to{opacity:1;transform:translateY(0) scale(1)}
|
to{opacity:1;transform:translateY(0) scale(1)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile top bar (hidden on desktop) ───────────────────────────────── */
|
||||||
|
.mob-topbar{display:none}
|
||||||
|
|
||||||
|
/* ── Responsive layout ────────────────────────────────────────────────── */
|
||||||
|
@media (max-width:700px){
|
||||||
|
/* Show mobile top bar */
|
||||||
|
.mob-topbar{
|
||||||
|
display:flex;align-items:center;gap:8px;
|
||||||
|
position:fixed;top:0;left:0;right:0;height:50px;z-index:200;
|
||||||
|
background:var(--surface);border-bottom:1px solid var(--border);
|
||||||
|
padding:0 12px;
|
||||||
|
}
|
||||||
|
.mob-nav-btn,.mob-back-btn{
|
||||||
|
background:none;border:none;cursor:pointer;color:var(--text);
|
||||||
|
padding:6px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||||
|
flex-shrink:0;
|
||||||
|
}
|
||||||
|
.mob-nav-btn:hover,.mob-back-btn:hover{background:var(--surface3)}
|
||||||
|
.mob-nav-btn svg,.mob-back-btn svg{width:20px;height:20px;fill:currentColor}
|
||||||
|
.mob-title{font-family:'DM Serif Display',serif;font-size:15px;overflow:hidden;
|
||||||
|
text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||||
|
|
||||||
|
/* Push content below topbar */
|
||||||
|
body.app-page{overflow:hidden}
|
||||||
|
.app{flex-direction:column;height:100dvh;height:100vh;padding-top:50px}
|
||||||
|
|
||||||
|
/* Sidebar becomes a drawer */
|
||||||
|
.sidebar{
|
||||||
|
position:fixed;top:50px;left:0;bottom:0;z-index:150;
|
||||||
|
transform:translateX(-100%);transition:transform .25s ease;
|
||||||
|
width:280px;max-width:85vw;
|
||||||
|
}
|
||||||
|
.sidebar.mob-open{transform:translateX(0)}
|
||||||
|
.mob-sidebar-backdrop{
|
||||||
|
display:none;position:fixed;inset:0;top:50px;z-index:140;
|
||||||
|
background:rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop.mob-open{display:block}
|
||||||
|
|
||||||
|
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
|
||||||
|
.sidebar-header .compose-btn{display:none}
|
||||||
|
|
||||||
|
/* Message list panel: full width, shown/hidden by data-mob-view */
|
||||||
|
.message-list-panel{width:100%;border-right:none;flex-shrink:0}
|
||||||
|
.message-detail{width:100%}
|
||||||
|
|
||||||
|
/* View switching via data-mob-view on #app-root */
|
||||||
|
#app-root[data-mob-view="list"] .message-list-panel{display:flex}
|
||||||
|
#app-root[data-mob-view="list"] .message-detail{display:none}
|
||||||
|
#app-root[data-mob-view="detail"] .message-list-panel{display:none}
|
||||||
|
#app-root[data-mob-view="detail"] .message-detail{display:flex}
|
||||||
|
|
||||||
|
/* Compose dialog: full screen on mobile */
|
||||||
|
.compose-dialog{
|
||||||
|
position:fixed!important;
|
||||||
|
top:50px!important;left:0!important;right:0!important;bottom:0!important;
|
||||||
|
width:100%!important;height:calc(100dvh - 50px)!important;
|
||||||
|
border-radius:0!important;resize:none!important;
|
||||||
|
}
|
||||||
|
/* Hide floating minimised bar on mobile, use back button instead */
|
||||||
|
.compose-minimised{display:none!important}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Contacts ──────────────────────────────────────────────────────────── */
|
||||||
|
.contact-card{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:8px;
|
||||||
|
cursor:pointer;transition:background .1s;border-bottom:1px solid var(--border)}
|
||||||
|
.contact-card:hover{background:var(--surface3)}
|
||||||
|
.contact-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;
|
||||||
|
justify-content:center;font-size:15px;font-weight:600;color:white;flex-shrink:0}
|
||||||
|
.contact-info{flex:1;min-width:0}
|
||||||
|
.contact-name{font-size:14px;font-weight:500;color:var(--text)}
|
||||||
|
.contact-meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
/* ── Calendar ──────────────────────────────────────────────────────────── */
|
||||||
|
.cal-grid-month{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid var(--border);border-top:1px solid var(--border)}
|
||||||
|
.cal-day-header{text-align:center;font-size:11px;font-weight:600;text-transform:uppercase;
|
||||||
|
letter-spacing:.5px;color:var(--muted);padding:6px 0;background:var(--surface);
|
||||||
|
border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||||
|
.cal-day{min-height:90px;padding:4px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);
|
||||||
|
vertical-align:top;background:var(--surface);transition:background .1s;position:relative}
|
||||||
|
.cal-day:hover{background:var(--surface3)}
|
||||||
|
.cal-day.today{background:var(--accent-dim)}
|
||||||
|
.cal-day.other-month{opacity:.45}
|
||||||
|
.cal-day-num{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:2px;cursor:pointer;
|
||||||
|
width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
|
||||||
|
.cal-day-num:hover{background:var(--border2)}
|
||||||
|
.cal-day.today .cal-day-num{background:var(--accent);color:white}
|
||||||
|
.cal-event{font-size:11px;padding:2px 5px;border-radius:3px;margin-bottom:2px;
|
||||||
|
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:white;
|
||||||
|
transition:opacity .1s}
|
||||||
|
.cal-event:hover{opacity:.85}
|
||||||
|
.cal-more{font-size:10px;color:var(--muted);cursor:pointer;padding:1px 4px}
|
||||||
|
.cal-more:hover{color:var(--accent)}
|
||||||
|
|
||||||
|
/* Week view */
|
||||||
|
.cal-week-grid{display:grid;grid-template-columns:52px repeat(7,1fr);border-left:1px solid var(--border)}
|
||||||
|
.cal-week-header{text-align:center;padding:6px 2px;font-size:12px;border-right:1px solid var(--border);
|
||||||
|
border-bottom:1px solid var(--border);background:var(--surface)}
|
||||||
|
.cal-week-header.today-col{color:var(--accent);font-weight:600}
|
||||||
|
.cal-time-col{font-size:10px;color:var(--muted);text-align:right;padding-right:4px;
|
||||||
|
border-right:1px solid var(--border);border-bottom:1px solid var(--border);height:40px;
|
||||||
|
display:flex;align-items:flex-start;justify-content:flex-end;padding-top:2px}
|
||||||
|
.cal-week-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border);
|
||||||
|
height:40px;position:relative;transition:background .1s}
|
||||||
|
.cal-week-cell:hover{background:var(--surface3)}
|
||||||
|
|
||||||
|
/* CalDAV token row */
|
||||||
|
.caldav-token-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.caldav-token-url{font-size:11px;font-family:monospace;color:var(--muted);overflow:hidden;
|
||||||
|
text-overflow:ellipsis;white-space:nowrap;flex:1;cursor:pointer}
|
||||||
|
.caldav-token-url:hover{color:var(--text)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// GoMail Admin SPA
|
// GoWebMail Admin SPA
|
||||||
|
|
||||||
const adminRoutes = {
|
const adminRoutes = {
|
||||||
'/admin': renderUsers,
|
'/admin': renderUsers,
|
||||||
'/admin/settings': renderSettings,
|
'/admin/settings': renderSettings,
|
||||||
'/admin/audit': renderAudit,
|
'/admin/audit': renderAudit,
|
||||||
|
'/admin/security': renderSecurity,
|
||||||
};
|
};
|
||||||
|
|
||||||
function navigate(path) {
|
function navigate(path) {
|
||||||
@@ -26,7 +27,7 @@ async function renderUsers() {
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="admin-page-header">
|
<div class="admin-page-header">
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<p>Manage GoMail accounts and permissions.</p>
|
<p>Manage GoWebMail accounts and permissions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<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) { 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; }
|
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||||
el.innerHTML = `<table class="data-table">
|
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 => `
|
<tbody>${r.map(u => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-weight:500">${esc(u.username)}</td>
|
<td style="font-weight:500">${esc(u.username)}</td>
|
||||||
<td style="color:var(--muted)">${esc(u.email)}</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.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.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="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="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>
|
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
|
|||||||
else toast((r && r.error) || 'Delete failed', 'error');
|
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
|
// 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' },
|
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Security Notifications',
|
||||||
|
fields: [
|
||||||
|
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
|
||||||
|
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
|
||||||
|
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Brute Force Protection',
|
||||||
|
fields: [
|
||||||
|
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
|
||||||
|
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
|
||||||
|
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
|
||||||
|
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
|
||||||
|
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Geo Blocking',
|
||||||
|
fields: [
|
||||||
|
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
|
||||||
|
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
|
||||||
|
]
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function renderSettings() {
|
async function renderSettings() {
|
||||||
@@ -308,4 +357,135 @@ function eventBadge(evt) {
|
|||||||
navigate(a.getAttribute('href'));
|
navigate(a.getAttribute('href'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
// ============================================================
|
||||||
|
// Security — IP Blocks & Login Attempts
|
||||||
|
// ============================================================
|
||||||
|
async function renderSecurity() {
|
||||||
|
const el = document.getElementById('admin-content');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="admin-page-header">
|
||||||
|
<h1>Security</h1>
|
||||||
|
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card" style="margin-bottom:24px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
|
||||||
|
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
|
||||||
|
</div>
|
||||||
|
<div id="blocks-table"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
|
||||||
|
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="attempts-table"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="add-block-modal">
|
||||||
|
<div class="modal" style="max-width:420px">
|
||||||
|
<h2>Block IP Address</h2>
|
||||||
|
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
|
||||||
|
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
|
||||||
|
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
loadIPBlocks();
|
||||||
|
loadLoginAttempts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIPBlocks() {
|
||||||
|
const el = document.getElementById('blocks-table');
|
||||||
|
if (!el) return;
|
||||||
|
const r = await api('GET', '/admin/ip-blocks');
|
||||||
|
const blocks = r?.blocks || [];
|
||||||
|
if (!blocks.length) {
|
||||||
|
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||||
|
<thead><tr>
|
||||||
|
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${blocks.map(b => `<tr>
|
||||||
|
<td><code>${esc(b.ip)}</code></td>
|
||||||
|
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
|
||||||
|
<td>${esc(b.reason)}</td>
|
||||||
|
<td>${b.attempts||0}</td>
|
||||||
|
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
|
||||||
|
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLoginAttempts() {
|
||||||
|
const el = document.getElementById('attempts-table');
|
||||||
|
if (!el) return;
|
||||||
|
const r = await api('GET', '/admin/login-attempts');
|
||||||
|
const attempts = r?.attempts || [];
|
||||||
|
if (!attempts.length) {
|
||||||
|
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||||
|
<thead><tr>
|
||||||
|
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
|
||||||
|
<td><code>${esc(a.ip)}</code></td>
|
||||||
|
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
|
||||||
|
<td>${a.total}</td>
|
||||||
|
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
|
||||||
|
<td style="font-size:11px">${a.last_seen||'—'}</td>
|
||||||
|
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddBlock() { openModal('add-block-modal'); }
|
||||||
|
|
||||||
|
async function submitAddBlock() {
|
||||||
|
const ip = document.getElementById('block-ip').value.trim();
|
||||||
|
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
|
||||||
|
const hours = parseInt(document.getElementById('block-hours').value) || 0;
|
||||||
|
if (!ip) { toast('IP address required', 'error'); return; }
|
||||||
|
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
|
||||||
|
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
|
||||||
|
else toast(r?.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblockIP(ip) {
|
||||||
|
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
|
||||||
|
else toast(data?.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockFromAttempt(ip) {
|
||||||
|
document.getElementById('block-ip').value = ip;
|
||||||
|
document.getElementById('block-reason').value = 'Manual block from login attempts';
|
||||||
|
openModal('add-block-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').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 ----
|
// ---- API helper ----
|
||||||
async function api(method, path, body) {
|
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>
|
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||||
Audit Log
|
Audit Log
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/security" id="nav-security">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||||
|
Security
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -35,5 +39,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js"></script>
|
<script src="/static/js/admin.js?v=25"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -3,7 +3,20 @@
|
|||||||
{{define "body_class"}}app-page{{end}}
|
{{define "body_class"}}app-page{{end}}
|
||||||
|
|
||||||
{{define "body"}}
|
{{define "body"}}
|
||||||
<div class="app">
|
<div class="app" id="app-root" data-mob-view="list">
|
||||||
|
<!-- Mobile top bar (hidden on desktop) -->
|
||||||
|
<div class="mob-topbar" id="mob-topbar">
|
||||||
|
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" style="display:none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="mob-title" id="mob-title">GoWebMail</span>
|
||||||
|
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
|
||||||
|
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab">↗</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -11,7 +24,16 @@
|
|||||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="compose-btn" onclick="openCompose()">+ New</button>
|
<div style="position:relative;display:inline-flex">
|
||||||
|
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
|
||||||
|
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
|
||||||
|
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
|
||||||
|
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
|
||||||
|
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
@@ -24,6 +46,14 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||||
Starred
|
Starred
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
|
||||||
|
Contacts
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
|
||||||
|
Calendar
|
||||||
|
</div>
|
||||||
<div id="folders-by-account"></div>
|
<div id="folders-by-account"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +75,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<!-- Mobile sidebar backdrop -->
|
||||||
|
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
|
||||||
|
|
||||||
<!-- Message list -->
|
<!-- Message list -->
|
||||||
<div class="message-list-panel">
|
<div class="message-list-panel">
|
||||||
@@ -58,13 +90,14 @@
|
|||||||
<span id="filter-label">Filter</span>
|
<span id="filter-label">Filter</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
|
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
|
||||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||||
<div class="filter-sep-line"></div>
|
<div class="filter-sep-line"></div>
|
||||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||||
|
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
|
||||||
<div class="filter-sep-line"></div>
|
<div class="filter-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-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +121,105 @@
|
|||||||
<p>Choose a message from the list to read it</p>
|
<p>Choose a message from the list to read it</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
|
||||||
|
<div class="panel-header" style="padding:14px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
|
||||||
|
<span style="font-family:'DM Serif Display',serif;font-size:17px;flex:1">Contacts</span>
|
||||||
|
<input id="contacts-search" type="search" placeholder="Search contacts…" oninput="filterContacts(this.value)"
|
||||||
|
style="padding:5px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--surface3);color:var(--text);font-size:13px;width:200px">
|
||||||
|
<button class="btn-secondary" onclick="openContactForm()" style="font-size:12px">+ New Contact</button>
|
||||||
|
</div>
|
||||||
|
<div id="contacts-list" style="flex:1;overflow-y:auto;padding:12px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
|
||||||
|
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||||
|
<button class="icon-btn" onclick="calNav(-1)" title="Previous">‹</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||||
@@ -178,12 +310,27 @@
|
|||||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||||
<div class="provider-btns">
|
<div class="provider-btns">
|
||||||
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
Gmail
|
Gmail
|
||||||
</button>
|
</button>
|
||||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
<!-- Microsoft 365 icon -->
|
||||||
Outlook
|
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
|
||||||
|
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
|
||||||
|
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
|
||||||
|
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
|
||||||
|
</svg>
|
||||||
|
Microsoft 365
|
||||||
|
</button>
|
||||||
|
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
|
||||||
|
<!-- Outlook icon (blue envelope) -->
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="24" height="24" rx="3" fill="#0078D4"/>
|
||||||
|
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
|
||||||
|
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
|
||||||
|
</svg>
|
||||||
|
Outlook Personal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||||
@@ -223,15 +370,31 @@
|
|||||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||||
<input type="hidden" id="edit-account-id">
|
<input type="hidden" id="edit-account-id">
|
||||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
|
||||||
<div class="modal-row">
|
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
|
||||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
<div id="edit-oauth-section" style="display:none">
|
||||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
|
||||||
|
⚠️ Access token has expired — sync and send will fail until you reconnect.
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
|
||||||
|
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-row">
|
|
||||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
|
||||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
<div id="edit-creds-section">
|
||||||
|
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||||
|
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||||
|
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>Email history to sync</label>
|
<label>Email history to sync</label>
|
||||||
@@ -263,12 +426,34 @@
|
|||||||
|
|
||||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||||
<div class="modal-overlay" id="settings-modal">
|
<div class="modal-overlay" id="settings-modal">
|
||||||
<div class="modal" style="width:520px">
|
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||||
<h2 style="margin-bottom:0">Settings</h2>
|
<h2 style="margin-bottom:0">Settings</h2>
|
||||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Profile</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Username</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||||
|
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Email Sync</div>
|
<div class="settings-group-title">Email Sync</div>
|
||||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||||
@@ -299,6 +484,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="mfa-panel">Loading...</div>
|
<div id="mfa-panel">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">IP Access Rules</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
|
||||||
|
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
|
||||||
|
<option value="disabled">Disabled — use global settings</option>
|
||||||
|
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
|
||||||
|
<option value="allow_only">Allow only — only listed IPs can log in</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
|
||||||
|
<div class="modal-field" id="ip-rule-list-field">
|
||||||
|
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
|
||||||
|
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,5 +514,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{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}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
|
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
|
||||||
{{block "head_extra" .}}{{end}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{end}}
|
{{block "body" .}}{{end}}
|
||||||
<script src="/static/js/gowebmail.js?v=12"></script>
|
<script src="/static/js/gowebmail.js?v=58"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
220
web/templates/compose.html
Normal file
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}}
|
{{end}}
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||||
const k=new URLSearchParams(location.search).get('error');
|
const k=new URLSearchParams(location.search).get('error');
|
||||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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