mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-15 00:00:36 +01:00
Compare commits
13
Commits
latest
..
d005cbb931
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d005cbb931 | ||
|
|
f506183b6d | ||
|
|
d470c8b71f | ||
|
|
9e7e87d11b | ||
|
|
a9c7f4c575 | ||
|
|
1e08d5f50f | ||
|
|
015c00251b | ||
|
|
68c81ebaed | ||
|
|
f122d29282 | ||
|
|
d6e987f66c | ||
|
|
ef85246806 | ||
|
|
948e111cc6 | ||
|
|
ac43075d62 |
+8
-1
@@ -3,4 +3,11 @@ data/*.db
|
||||
data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gowebmail.conf
|
||||
data/*.txt
|
||||
data/*.txt
|
||||
gowebmail-devplan.md
|
||||
testrun/
|
||||
webmail.code-workspace
|
||||
|
||||
graphify-out
|
||||
GEMINI.md
|
||||
tests/
|
||||
+224
-4
@@ -1,13 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/handlers"
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
|
||||
@@ -47,6 +51,16 @@ func main() {
|
||||
}
|
||||
runDisableMFA(args[1])
|
||||
return
|
||||
case "--blocklist":
|
||||
runBlockList()
|
||||
return
|
||||
case "--unblock":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||
os.Exit(1)
|
||||
}
|
||||
runUnblock(args[1])
|
||||
return
|
||||
case "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
@@ -62,6 +76,18 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("config load: %v", err)
|
||||
}
|
||||
logger.Init(cfg.Debug)
|
||||
|
||||
// Install a filtered log writer that suppresses harmless go-imap v1 parser
|
||||
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
|
||||
// on Gmail connections due to non-standard server responses. These don't affect
|
||||
// functionality — go-imap recovers and continues syncing correctly.
|
||||
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
|
||||
"imap/client:",
|
||||
"atom contains forbidden",
|
||||
"atom contains bad",
|
||||
"bad brackets nesting",
|
||||
}})
|
||||
|
||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
@@ -73,7 +99,7 @@ func main() {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
sc := syncer.New(database)
|
||||
sc := syncer.New(database, cfg)
|
||||
sc.Start()
|
||||
defer sc.Stop()
|
||||
|
||||
@@ -85,10 +111,27 @@ func main() {
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(cfg.HostCheckMiddleware)
|
||||
|
||||
// Custom error handlers for non-API paths
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||
})
|
||||
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||
})
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(
|
||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||
)
|
||||
// Legacy /app path redirect — some browsers bookmark this; redirect to root
|
||||
// which RequireAuth will then forward to login if not signed in.
|
||||
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
|
||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||
if err != nil {
|
||||
@@ -103,7 +146,7 @@ func main() {
|
||||
// Public auth routes
|
||||
auth := r.PathPrefix("/auth").Subrouter()
|
||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
||||
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||
|
||||
// MFA (session exists but mfa_verified=0)
|
||||
@@ -119,11 +162,15 @@ func main() {
|
||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||
|
||||
// App
|
||||
app := r.PathPrefix("").Subrouter()
|
||||
app.Use(middleware.RequireAuth(database, cfg))
|
||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
||||
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
|
||||
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
|
||||
|
||||
// Admin UI
|
||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||
@@ -133,6 +180,7 @@ func main() {
|
||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||
|
||||
// API
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
@@ -141,10 +189,13 @@ func main() {
|
||||
|
||||
// Profile / auth
|
||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||
|
||||
// Providers (which OAuth providers are configured)
|
||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||
@@ -153,6 +204,7 @@ func main() {
|
||||
api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET")
|
||||
api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST")
|
||||
api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST")
|
||||
api.HandleFunc("/accounts/trust-cert", h.API.TrustCertificate).Methods("POST")
|
||||
api.HandleFunc("/accounts/detect", h.API.DetectMailSettings).Methods("POST")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT")
|
||||
@@ -169,8 +221,17 @@ func main() {
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
||||
api.HandleFunc("/messages/by-label/{id:[0-9]+}", h.API.MessagesByLabel).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.AssignLabel).Methods("POST")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.UnassignLabel).Methods("DELETE")
|
||||
api.HandleFunc("/labels", h.API.ListLabels).Methods("GET")
|
||||
api.HandleFunc("/labels", h.API.CreateLabel).Methods("POST")
|
||||
api.HandleFunc("/labels/{id:[0-9]+}", h.API.UpdateLabel).Methods("PUT")
|
||||
api.HandleFunc("/labels/{id:[0-9]+}", h.API.DeleteLabel).Methods("DELETE")
|
||||
|
||||
// Remote content whitelist
|
||||
api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET")
|
||||
@@ -180,6 +241,9 @@ func main() {
|
||||
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
|
||||
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
||||
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
|
||||
api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST")
|
||||
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
|
||||
api.HandleFunc("/draft/discard", h.API.DiscardDraft).Methods("POST")
|
||||
|
||||
// Folders
|
||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
||||
@@ -189,18 +253,77 @@ func main() {
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
|
||||
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
||||
api.HandleFunc("/accounts/{account_id:[0-9]+}/folders", h.API.CreateFolder).Methods("POST")
|
||||
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
||||
api.HandleFunc("/new-messages", h.API.NewMessagesSince).Methods("GET")
|
||||
|
||||
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
||||
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
|
||||
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
|
||||
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
|
||||
|
||||
// Search
|
||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
||||
|
||||
// Contacts
|
||||
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
|
||||
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
|
||||
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
|
||||
|
||||
// Calendar events
|
||||
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
|
||||
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
|
||||
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
|
||||
|
||||
// CalDAV API tokens
|
||||
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
|
||||
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
|
||||
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
|
||||
|
||||
// CalDAV public feed — token-authenticated, no session needed
|
||||
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
|
||||
|
||||
// Mail rules (filters)
|
||||
api.HandleFunc("/rules", h.API.ListRules).Methods("GET")
|
||||
api.HandleFunc("/rules", h.API.CreateRule).Methods("POST")
|
||||
api.HandleFunc("/rules/{id:[0-9]+}", h.API.UpdateRule).Methods("PUT")
|
||||
api.HandleFunc("/rules/{id:[0-9]+}", h.API.DeleteRule).Methods("DELETE")
|
||||
|
||||
// Signatures
|
||||
api.HandleFunc("/signatures", h.API.ListSignatures).Methods("GET")
|
||||
api.HandleFunc("/signatures", h.API.CreateSignature).Methods("POST")
|
||||
api.HandleFunc("/signatures/{id:[0-9]+}", h.API.UpdateSignature).Methods("PUT")
|
||||
api.HandleFunc("/signatures/{id:[0-9]+}", h.API.DeleteSignature).Methods("DELETE")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}/signature-defaults", h.API.SetSignatureDefaults).Methods("PUT")
|
||||
|
||||
// S/MIME certificates
|
||||
api.HandleFunc("/smime/identity", h.API.SMIMEIdentity).Methods("GET")
|
||||
api.HandleFunc("/smime/identity", h.API.SMIMEGenerate).Methods("POST")
|
||||
api.HandleFunc("/smime/identity/import", h.API.SMIMEImport).Methods("POST")
|
||||
api.HandleFunc("/smime/identity/{id:[0-9]+}", h.API.SMIMERemoveIdentity).Methods("DELETE")
|
||||
api.HandleFunc("/smime/contacts", h.API.SMIMEContacts).Methods("GET")
|
||||
api.HandleFunc("/smime/contacts", h.API.SMIMEAddContact).Methods("POST")
|
||||
api.HandleFunc("/smime/contacts/{id:[0-9]+}", h.API.SMIMERemoveContact).Methods("DELETE")
|
||||
|
||||
// PGP keys
|
||||
api.HandleFunc("/pgp/identity", h.API.PGPIdentity).Methods("GET")
|
||||
api.HandleFunc("/pgp/identity", h.API.PGPGenerate).Methods("POST")
|
||||
api.HandleFunc("/pgp/identity/import", h.API.PGPImport).Methods("POST")
|
||||
api.HandleFunc("/pgp/identity/{id:[0-9]+}", h.API.PGPRemoveIdentity).Methods("DELETE")
|
||||
api.HandleFunc("/pgp/unlock", h.API.PGPUnlock).Methods("POST")
|
||||
api.HandleFunc("/pgp/contacts", h.API.PGPContacts).Methods("GET")
|
||||
api.HandleFunc("/pgp/contacts", h.API.PGPAddContact).Methods("POST")
|
||||
api.HandleFunc("/pgp/contacts/{id:[0-9]+}", h.API.PGPRemoveContact).Methods("DELETE")
|
||||
|
||||
// Admin API
|
||||
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
||||
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
||||
@@ -213,6 +336,19 @@ func main() {
|
||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
|
||||
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
|
||||
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
|
||||
|
||||
// Periodically purge expired IP blocks
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
database.PurgeExpiredBlocks()
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
@@ -305,22 +441,106 @@ func runDisableMFA(username string) {
|
||||
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||
}
|
||||
|
||||
func runBlockList() {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
blocks, err := database.ListIPBlocksWithUsername()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
fmt.Println("No blocked IPs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
|
||||
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||
"--", "-------------", "-----", "----------", "-------", "---------")
|
||||
for _, b := range blocks {
|
||||
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
var expires, remaining string
|
||||
if b.IsPermanent || b.ExpiresAt == nil {
|
||||
expires = "permanent"
|
||||
remaining = "∞ (manual unblock)"
|
||||
} else {
|
||||
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||
left := time.Until(*b.ExpiresAt)
|
||||
if left <= 0 {
|
||||
remaining = "expired (purge pending)"
|
||||
} else {
|
||||
h := int(left.Hours())
|
||||
m := int(left.Minutes()) % 60
|
||||
s := int(left.Seconds()) % 60
|
||||
if h > 0 {
|
||||
remaining = fmt.Sprintf("%dh %dm", h, m)
|
||||
} else if m > 0 {
|
||||
remaining = fmt.Sprintf("%dm %ds", m, s)
|
||||
} else {
|
||||
remaining = fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
username := b.LastUsername
|
||||
if username == "" {
|
||||
username = "(unknown)"
|
||||
}
|
||||
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
|
||||
b.IP, username, b.Attempts, blockedAt, expires, remaining)
|
||||
}
|
||||
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
|
||||
}
|
||||
|
||||
func runUnblock(ip string) {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
if err := database.UnblockIP(ip); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("IP %s has been unblocked.\n", ip)
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`GoMail — Admin CLI
|
||||
fmt.Print(`GoWebMail — Admin CLI
|
||||
|
||||
Usage:
|
||||
gowebmail Start the mail server
|
||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||
gowebmail --mfa-off <username> Disable MFA for an admin account
|
||||
gowebmail --blocklist List all currently blocked IP addresses
|
||||
gowebmail --unblock <ip> Remove block for a specific IP address
|
||||
|
||||
Examples:
|
||||
./gowebmail --list-admin
|
||||
./gowebmail --pw admin "NewSecurePass123"
|
||||
./gowebmail --mfa-off admin
|
||||
./gowebmail --blocklist
|
||||
./gowebmail --unblock 1.2.3.4
|
||||
|
||||
Note: These commands only work on admin accounts.
|
||||
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
||||
Regular user management is done through the web UI.
|
||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||
`)
|
||||
}
|
||||
|
||||
// filteredWriter wraps an io.Writer and drops log lines containing any of the
|
||||
// suppress substrings. Used to silence harmless go-imap internal parser errors.
|
||||
type filteredWriter struct {
|
||||
w io.Writer
|
||||
suppress []string
|
||||
}
|
||||
|
||||
func (f *filteredWriter) Write(p []byte) (n int, err error) {
|
||||
line := string(bytes.TrimSpace(p))
|
||||
for _, s := range f.suppress {
|
||||
if strings.Contains(line, s) {
|
||||
return len(p), nil // silently drop
|
||||
}
|
||||
}
|
||||
return f.w.Write(p)
|
||||
}
|
||||
|
||||
+222
-11
@@ -1,4 +1,4 @@
|
||||
// Package config loads and persists GoMail configuration from data/gowebmail.conf
|
||||
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -21,6 +21,9 @@ type Config struct {
|
||||
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
||||
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
||||
|
||||
// Debug
|
||||
Debug bool // set DEBUG=true in config to enable verbose logging
|
||||
|
||||
// Security
|
||||
EncryptionKey []byte // 32 bytes / AES-256
|
||||
SessionSecret []byte
|
||||
@@ -28,6 +31,23 @@ type Config struct {
|
||||
SessionMaxAge int
|
||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
||||
|
||||
// Notification SMTP (outbound alerts — separate from user mail accounts)
|
||||
NotifyEnabled bool
|
||||
NotifySMTPHost string
|
||||
NotifySMTPPort int
|
||||
NotifyFrom string
|
||||
NotifyUser string // optional — leave blank for unauthenticated relay
|
||||
NotifyPass string // optional
|
||||
|
||||
// Brute force protection
|
||||
BruteEnabled bool
|
||||
BruteMaxAttempts int
|
||||
BruteWindowMins int
|
||||
BruteBanHours int
|
||||
BruteWhitelist []net.IP // IPs exempt from blocking
|
||||
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
|
||||
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
|
||||
|
||||
// Storage
|
||||
DBPath string
|
||||
|
||||
@@ -59,7 +79,7 @@ var allFields = []configField{
|
||||
defVal: "localhost",
|
||||
comments: []string{
|
||||
"--- Server ---",
|
||||
"Public hostname of this GoMail instance (no port, no protocol).",
|
||||
"Public hostname of this GoWebMail instance (no port, no protocol).",
|
||||
"Examples: localhost | mail.example.com | 192.168.1.10",
|
||||
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
||||
"Also used in security checks to reject requests with unexpected Host headers.",
|
||||
@@ -92,7 +112,7 @@ var allFields = []configField{
|
||||
key: "SECURE_COOKIE",
|
||||
defVal: "false",
|
||||
comments: []string{
|
||||
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
|
||||
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
|
||||
"Marks session cookies as Secure so browsers only send them over TLS.",
|
||||
},
|
||||
},
|
||||
@@ -109,7 +129,7 @@ var allFields = []configField{
|
||||
comments: []string{
|
||||
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
||||
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
|
||||
"which GoMail uses to determine the real client IP and whether TLS is in use.",
|
||||
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
|
||||
" Examples:",
|
||||
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
||||
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
||||
@@ -118,6 +138,108 @@ var allFields = []configField{
|
||||
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Security Notifications ---",
|
||||
"Send email alerts to users when their account is targeted by brute-force attacks.",
|
||||
"Set to false to disable all security notification emails.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_HOST",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP server hostname for sending security notification emails.",
|
||||
"Example: smtp.example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_PORT",
|
||||
defVal: "587",
|
||||
comments: []string{
|
||||
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_FROM",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Sender address for security notification emails. Example: security@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_USER",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_PASS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Brute Force Protection ---",
|
||||
"Enable automatic IP blocking after repeated failed logins.",
|
||||
"Set to false to disable entirely.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_MAX_ATTEMPTS",
|
||||
defVal: "5",
|
||||
comments: []string{
|
||||
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WINDOW_MINUTES",
|
||||
defVal: "30",
|
||||
comments: []string{
|
||||
"Time window in minutes for counting failed login attempts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_BAN_HOURS",
|
||||
defVal: "12",
|
||||
comments: []string{
|
||||
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WHITELIST_IPS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
|
||||
"Example: 192.168.1.1,10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_BLOCK_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
|
||||
"Comma-separated 2-letter ISO country codes to DENY access from.",
|
||||
"Example: CN,RU,KP",
|
||||
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_ALLOW_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
|
||||
"Example: SK,CZ,DE",
|
||||
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "DB_PATH",
|
||||
defVal: "./data/gowebmail.db",
|
||||
@@ -184,10 +306,15 @@ var allFields = []configField{
|
||||
},
|
||||
{
|
||||
key: "MICROSOFT_TENANT_ID",
|
||||
defVal: "common",
|
||||
defVal: "consumers",
|
||||
comments: []string{
|
||||
"Use 'common' to allow any Microsoft account,",
|
||||
"or your Azure tenant ID to restrict to one organisation.",
|
||||
"Tenant endpoint to use for Microsoft OAuth2.",
|
||||
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
|
||||
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
|
||||
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
|
||||
" Use if registered as 'Personal accounts only'.",
|
||||
" organizations - Work/school Microsoft 365 accounts only.",
|
||||
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -228,7 +355,7 @@ func Load() (*Config, error) {
|
||||
|
||||
// get returns env var if set, else file value, else ""
|
||||
get := func(key string) string {
|
||||
// Only check env vars that are explicitly GoMail-namespaced or well-known.
|
||||
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
|
||||
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
||||
// picking up cloud-platform env vars unintentionally.
|
||||
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
||||
@@ -307,18 +434,34 @@ func Load() (*Config, error) {
|
||||
Hostname: hostname,
|
||||
BaseURL: baseURL,
|
||||
DBPath: get("DB_PATH"),
|
||||
Debug: atobool(get("DEBUG"), false),
|
||||
EncryptionKey: encKey,
|
||||
SessionSecret: []byte(sessSecret),
|
||||
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||
TrustedProxies: trustedProxies,
|
||||
|
||||
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
|
||||
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
|
||||
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
|
||||
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
|
||||
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
|
||||
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
|
||||
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
|
||||
|
||||
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
|
||||
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
|
||||
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
|
||||
NotifyFrom: get("NOTIFY_FROM"),
|
||||
NotifyUser: get("NOTIFY_USER"),
|
||||
NotifyPass: get("NOTIFY_PASS"),
|
||||
|
||||
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||
GoogleRedirectURL: googleRedirect,
|
||||
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
||||
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
|
||||
MicrosoftRedirectURL: outlookRedirect,
|
||||
}
|
||||
|
||||
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
|
||||
func (c *Config) IsIPWhitelisted(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, w := range c.BruteWhitelist {
|
||||
if w.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
|
||||
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
|
||||
func (c *Config) IsCountryAllowed(code string) bool {
|
||||
code = strings.ToUpper(code)
|
||||
if len(c.GeoBlockCountries) > 0 {
|
||||
for _, bc := range c.GeoBlockCountries {
|
||||
if bc == code {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.GeoAllowCountries) > 0 {
|
||||
for _, ac := range c.GeoAllowCountries {
|
||||
if ac == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
||||
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
||||
@@ -443,7 +622,7 @@ func readConfigFile(path string) (map[string]string, error) {
|
||||
|
||||
func writeConfigFile(path string, values map[string]string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# GoMail Configuration\n")
|
||||
sb.WriteString("# GoWebMail Configuration\n")
|
||||
sb.WriteString("# =====================\n")
|
||||
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
||||
sb.WriteString("# Edit freely — your values are always preserved.\n")
|
||||
@@ -576,7 +755,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
|
||||
}
|
||||
|
||||
func logStartupInfo(cfg *Config) {
|
||||
fmt.Printf("GoMail starting:\n")
|
||||
fmt.Printf("GoWebMail starting:\n")
|
||||
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
||||
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
||||
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
||||
@@ -587,6 +766,38 @@ func logStartupInfo(cfg *Config) {
|
||||
}
|
||||
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
||||
}
|
||||
if cfg.GoogleClientID != "" {
|
||||
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
|
||||
}
|
||||
if cfg.MicrosoftClientID != "" {
|
||||
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
|
||||
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPList(s string) []net.IP {
|
||||
var ips []net.IP
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func parseCountryList(s string) []string {
|
||||
var codes []string
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||
if len(raw) == 2 {
|
||||
codes = append(codes, raw)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func mustHex(n int) string {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# GoMail Configuration
|
||||
# =====================
|
||||
# Auto-generated and updated on each startup.
|
||||
# Edit freely — your values are always preserved.
|
||||
# Environment variables (or GOMAIL_<KEY>) override values here.
|
||||
#
|
||||
|
||||
# --- Server ---
|
||||
# Public hostname of this GoMail instance (no port, no protocol).
|
||||
# Examples: localhost | mail.example.com | 192.168.1.10
|
||||
# Used to build BASE_URL and OAuth redirect URIs automatically.
|
||||
# Also used in security checks to reject requests with unexpected Host headers.
|
||||
HOSTNAME = localhost
|
||||
|
||||
# Address and port to listen on. Format: [host]:port
|
||||
# :8080 — all interfaces, port 8080
|
||||
# 0.0.0.0:8080 — all interfaces (explicit)
|
||||
# 127.0.0.1:8080 — localhost only
|
||||
LISTEN_ADDR = :8080
|
||||
|
||||
# Public URL of this instance (no trailing slash). Leave blank to auto-build
|
||||
# from HOSTNAME and LISTEN_ADDR port (recommended).
|
||||
# Auto-build examples:
|
||||
# HOSTNAME=localhost + :8080 → http://localhost:8080
|
||||
# HOSTNAME=mail.example.com + :443 → https://mail.example.com
|
||||
# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080
|
||||
# Override here only if you need a custom path prefix or your proxy rewrites the URL.
|
||||
BASE_URL =
|
||||
|
||||
# Set to true when GoMail is served over HTTPS (directly or via proxy).
|
||||
# Marks session cookies as Secure so browsers only send them over TLS.
|
||||
SECURE_COOKIE = false
|
||||
|
||||
# How long a login session lasts, in seconds. Default: 604800 (7 days).
|
||||
SESSION_MAX_AGE = 604800
|
||||
|
||||
# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.
|
||||
# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,
|
||||
# which GoMail uses to determine the real client IP and whether TLS is in use.
|
||||
# Examples:
|
||||
# 127.0.0.1 (loopback only — Nginx/Traefik on same host)
|
||||
# 10.0.0.0/8,172.16.0.0/12 (private networks)
|
||||
# 192.168.1.50,192.168.1.51 (specific IPs)
|
||||
# Leave blank to disable proxy trust (requests are taken at face value).
|
||||
# NOTE: Do not add untrusted IPs — clients could spoof their source address.
|
||||
TRUSTED_PROXIES =
|
||||
|
||||
# --- Storage ---
|
||||
# Path to the SQLite database file.
|
||||
DB_PATH = ./data/gowebmail.db
|
||||
|
||||
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
|
||||
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.
|
||||
# NOTE: Back this up. Losing it makes the entire database permanently unreadable.
|
||||
# openssl rand -hex 32
|
||||
ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97
|
||||
|
||||
# Secret used to sign session cookies. Auto-generated on first run.
|
||||
# Changing this invalidates all active sessions (everyone gets logged out).
|
||||
SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e
|
||||
|
||||
# --- Gmail / Google OAuth2 ---
|
||||
# Create at: https://console.cloud.google.com/apis/credentials
|
||||
# Application type : Web application
|
||||
# Required scope : https://mail.google.com/
|
||||
# Redirect URI : <BASE_URL>/auth/gmail/callback
|
||||
GOOGLE_CLIENT_ID =
|
||||
|
||||
GOOGLE_CLIENT_SECRET =
|
||||
|
||||
# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Google Cloud Console.
|
||||
GOOGLE_REDIRECT_URL =
|
||||
|
||||
# --- Outlook / Microsoft 365 OAuth2 ---
|
||||
# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
|
||||
# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email
|
||||
# Redirect URI : <BASE_URL>/auth/outlook/callback
|
||||
MICROSOFT_CLIENT_ID =
|
||||
|
||||
MICROSOFT_CLIENT_SECRET =
|
||||
|
||||
# Use 'common' to allow any Microsoft account,
|
||||
# or your Azure tenant ID to restrict to one organisation.
|
||||
MICROSOFT_TENANT_ID = common
|
||||
|
||||
# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Azure.
|
||||
MICROSOFT_REDIRECT_URL =
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
module github.com/ghostersk/gowebmail
|
||||
|
||||
go 1.26
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.4.1
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
go.mozilla.org/pkcs7 v0.10.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg=
|
||||
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
+201
-17
@@ -3,11 +3,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/microsoft"
|
||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
||||
|
||||
// ---- Microsoft / Outlook OAuth2 ----
|
||||
|
||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
||||
var OutlookScopes = []string{
|
||||
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||
// audience for IMAP XOAUTH2 authentication.
|
||||
var OutlookAuthScopes = []string{
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
||||
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||
// "common" routes personal accounts through login.live.com which returns
|
||||
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookScopes,
|
||||
Scopes: OutlookAuthScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
||||
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||
// authorization and exchanges it for an access token scoped to the Outlook
|
||||
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||
// The two-step approach is necessary because:
|
||||
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", "refresh_token")
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("client_secret", clientSecret)
|
||||
params.Set("refresh_token", refreshToken)
|
||||
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||
}
|
||||
|
||||
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||
preview := result.AccessToken
|
||||
if len(preview) > 30 {
|
||||
preview = preview[:30] + "..."
|
||||
}
|
||||
parts := strings.Count(result.AccessToken, ".") + 1
|
||||
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||
parts, preview, params.Get("scope"))
|
||||
|
||||
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
return &oauth2.Token{
|
||||
AccessToken: result.AccessToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||
type MicrosoftUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName"` // Graph field
|
||||
Name string `json:"name"` // ID token claim
|
||||
Mail string `json:"mail"`
|
||||
EmailClaim string `json:"email"` // ID token claim
|
||||
UserPrincipalName string `json:"userPrincipalName"`
|
||||
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||
}
|
||||
|
||||
// Email returns the best available email address.
|
||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
||||
if m.Mail != "" {
|
||||
return m.Mail
|
||||
}
|
||||
if m.EmailClaim != "" {
|
||||
return m.EmailClaim
|
||||
}
|
||||
if m.PreferredUsername != "" {
|
||||
return m.PreferredUsername
|
||||
}
|
||||
return m.UserPrincipalName
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
||||
// BestName returns the best available display name.
|
||||
func (m *MicrosoftUserInfo) BestName() string {
|
||||
if m.DisplayName != "" {
|
||||
return m.DisplayName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||
// The ID token is issued alongside the access token and contains email/name claims.
|
||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
if idToken == "" {
|
||||
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||
}
|
||||
|
||||
// JWT structure: header.payload.signature — decode the payload only
|
||||
parts := strings.SplitN(idToken, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var info MicrosoftUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if info.Email() == "" {
|
||||
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) ----
|
||||
|
||||
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||
// Mail is accessed via Graph REST API instead of IMAP.
|
||||
var OutlookPersonalScopes = []string{
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookPersonalScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token refresh helpers ----
|
||||
|
||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||
return ts.Token()
|
||||
}
|
||||
|
||||
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
|
||||
// Pass the credentials for both providers; the correct ones are selected based
|
||||
// on provider ("gmail" or "outlook").
|
||||
func RefreshAccountToken(ctx context.Context,
|
||||
provider, refreshToken, baseURL,
|
||||
googleClientID, googleClientSecret,
|
||||
msClientID, msClientSecret, msTenantID string,
|
||||
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||
|
||||
switch provider {
|
||||
case "gmail":
|
||||
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||
case "outlook":
|
||||
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
case "outlook_personal":
|
||||
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||
baseURL+"/auth/outlook-personal/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
default:
|
||||
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package caldav pulls calendar events and contacts from a remote
|
||||
// CalDAV/CardDAV server so they can be mirrored into gowebmail's local DB.
|
||||
// One-way (server -> gowebmail) read sync only.
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-vcard"
|
||||
dav "github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"github.com/emersion/go-webdav/carddav"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
const timeout = 30 * time.Second
|
||||
|
||||
// SyncCalendar fetches all VEVENTs from the calendar collection at url
|
||||
// (HTTP basic auth) and returns them as CalendarEvent rows tagged with accountID.
|
||||
func SyncCalendar(ctx context.Context, url, user, pass string, accountID int64) ([]*models.CalendarEvent, error) {
|
||||
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
|
||||
c, err := caldav.NewClient(hc, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caldav client: %w", err)
|
||||
}
|
||||
objs, err := c.QueryCalendar(ctx, "", &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{AllProps: true, AllComps: true},
|
||||
CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caldav query: %w", err)
|
||||
}
|
||||
|
||||
var out []*models.CalendarEvent
|
||||
for _, obj := range objs {
|
||||
if obj.Data == nil {
|
||||
continue
|
||||
}
|
||||
for _, ev := range obj.Data.Events() {
|
||||
ev := ev
|
||||
out = append(out, eventFromICal(&ev, accountID))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func eventFromICal(ev *ical.Event, accountID int64) *models.CalendarEvent {
|
||||
uid, _ := ev.Props.Text(ical.PropUID)
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
desc, _ := ev.Props.Text(ical.PropDescription)
|
||||
loc, _ := ev.Props.Text(ical.PropLocation)
|
||||
|
||||
allDay := false
|
||||
if p := ev.Props.Get(ical.PropDateTimeStart); p != nil {
|
||||
allDay = p.ValueType() == ical.ValueDate
|
||||
}
|
||||
start, _ := ev.DateTimeStart(time.UTC)
|
||||
end, _ := ev.DateTimeEnd(time.UTC)
|
||||
if end.IsZero() {
|
||||
end = start
|
||||
}
|
||||
|
||||
status := ""
|
||||
if s, err := ev.Status(); err == nil {
|
||||
status = strings.ToLower(string(s))
|
||||
}
|
||||
|
||||
organizer := ""
|
||||
if p := ev.Props.Get(ical.PropOrganizer); p != nil {
|
||||
organizer = strings.TrimPrefix(p.Value, "mailto:")
|
||||
}
|
||||
var attendees []string
|
||||
for _, p := range ev.Props.Values(ical.PropAttendee) {
|
||||
attendees = append(attendees, strings.TrimPrefix(p.Value, "mailto:"))
|
||||
}
|
||||
|
||||
rrule := ""
|
||||
if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil {
|
||||
rrule = p.Value
|
||||
}
|
||||
|
||||
return &models.CalendarEvent{
|
||||
AccountID: &accountID,
|
||||
UID: uid,
|
||||
Title: summary,
|
||||
Description: desc,
|
||||
Location: loc,
|
||||
StartTime: formatEventTime(start, allDay),
|
||||
EndTime: formatEventTime(end, allDay),
|
||||
AllDay: allDay,
|
||||
RecurrenceRule: rrule,
|
||||
Status: status,
|
||||
OrganizerEmail: organizer,
|
||||
Attendees: strings.Join(attendees, ", "),
|
||||
}
|
||||
}
|
||||
|
||||
func formatEventTime(t time.Time, allDay bool) string {
|
||||
if allDay {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return t.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
// SyncContacts fetches all vCards from the address book collection at url
|
||||
// (HTTP basic auth) and returns them as Contact rows tagged with accountID.
|
||||
func SyncContacts(ctx context.Context, url, user, pass string, accountID int64) ([]*models.Contact, error) {
|
||||
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
|
||||
c, err := carddav.NewClient(hc, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("carddav client: %w", err)
|
||||
}
|
||||
objs, err := c.QueryAddressBook(ctx, "", &carddav.AddressBookQuery{
|
||||
DataRequest: carddav.AddressDataRequest{AllProp: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("carddav query: %w", err)
|
||||
}
|
||||
|
||||
var out []*models.Contact
|
||||
for _, obj := range objs {
|
||||
if obj.Card == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, contactFromVCard(obj.Card, obj.Path, accountID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func contactFromVCard(card vcard.Card, path string, accountID int64) *models.Contact {
|
||||
uid := card.PreferredValue(vcard.FieldUID)
|
||||
if uid == "" {
|
||||
// vCard UID is only a SHOULD in vCard 3.0 — fall back to the stable
|
||||
// resource path so contacts without one don't collide on upsert.
|
||||
uid = path
|
||||
}
|
||||
name := card.PreferredValue(vcard.FieldFormattedName)
|
||||
org := card.PreferredValue(vcard.FieldOrganization)
|
||||
if i := strings.Index(org, ";"); i >= 0 {
|
||||
org = org[:i]
|
||||
}
|
||||
return &models.Contact{
|
||||
AccountID: &accountID,
|
||||
UID: uid,
|
||||
DisplayName: name,
|
||||
Email: card.PreferredValue(vcard.FieldEmail),
|
||||
Phone: card.PreferredValue(vcard.FieldTelephone),
|
||||
Company: org,
|
||||
Notes: card.PreferredValue(vcard.FieldNote),
|
||||
}
|
||||
}
|
||||
+1629
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- PGP identities ----
|
||||
// private_key_armor relies on OpenPGP's own native S2K passphrase protection (no app-layer
|
||||
// encryption needed here, unlike smime.go's key_pem) — stored exactly as produced.
|
||||
|
||||
func (d *DB) ListPGPIdentities(accountID int64) ([]models.PGPIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? ORDER BY created_at DESC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPIdentity
|
||||
for rows.Next() {
|
||||
var p models.PGPIdentity
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetPGPIdentity(accountID, id int64) (*models.PGPIdentity, error) {
|
||||
p := &models.PGPIdentity{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (d *DB) CreatePGPIdentity(accountID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_identities (account_id, label, email, fingerprint, public_key_armor, private_key_armor) VALUES (?,?,?,?,?,?)`,
|
||||
accountID, label, email, fingerprint, publicKeyArmor, privateKeyArmor,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- PGP contact public keys (per-user address book) ----
|
||||
|
||||
func (d *DB) ListPGPContacts(userID int64) ([]models.PGPContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPContact
|
||||
for rows.Next() {
|
||||
var c models.PGPContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPGPContactByEmail looks up a contact's public key by address. Returns nil, nil if not found.
|
||||
func (d *DB) GetPGPContactByEmail(userID int64, email string) (*models.PGPContact, error) {
|
||||
c := &models.PGPContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (d *DB) UpsertPGPContact(userID int64, email, label, fingerprint, publicKeyArmor string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_contacts (user_id, email, label, fingerprint, public_key_armor) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET label=excluded.label, fingerprint=excluded.fingerprint, public_key_armor=excluded.public_key_armor`,
|
||||
userID, email, label, fingerprint, publicKeyArmor,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Rules (filters) ----
|
||||
|
||||
func scanRule(rowConditions, rowActionOptions string, r *models.Rule) {
|
||||
_ = json.Unmarshal([]byte(rowConditions), &r.Conditions)
|
||||
_ = json.Unmarshal([]byte(rowActionOptions), &r.ActionOptions)
|
||||
}
|
||||
|
||||
// ListRules returns all rules for an account, ordered by priority (lowest first, then id).
|
||||
func (d *DB) ListRules(accountID int64) ([]models.Rule, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? ORDER BY priority ASC, id ASC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Rule
|
||||
for rows.Next() {
|
||||
var r models.Rule
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
if err := rows.Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, &r)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListActiveRules returns only is_active rules for an account, same ordering as ListRules.
|
||||
func (d *DB) ListActiveRules(accountID int64) ([]models.Rule, error) {
|
||||
all, err := d.ListRules(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var active []models.Rule
|
||||
for _, r := range all {
|
||||
if r.IsActive {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// GetRule fetches a single rule scoped to an account (so one user can't touch another's rule by id).
|
||||
func (d *DB) GetRule(accountID, id int64) (*models.Rule, error) {
|
||||
r := &models.Rule{}
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, r)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CreateRule inserts a new rule and returns its id.
|
||||
func (d *DB) CreateRule(r *models.Rule) (int64, error) {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
if r.MatchType == "" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO rules (account_id, name, priority, conditions, match_type, action, action_value, action_options, is_active)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
r.AccountID, r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateRule replaces an existing rule's fields (scoped to account_id).
|
||||
func (d *DB) UpdateRule(r *models.Rule) error {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
_, err = d.sql.Exec(
|
||||
`UPDATE rules SET name=?, priority=?, conditions=?, match_type=?, action=?, action_value=?, action_options=?, is_active=?
|
||||
WHERE id=? AND account_id=?`,
|
||||
r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
r.ID, r.AccountID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRule removes a rule (scoped to account_id).
|
||||
func (d *DB) DeleteRule(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM rules WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// HasRecentAutoReply reports whether an auto-reply was already sent to recipientEmail
|
||||
// for this rule within the last 24h, to prevent auto-reply loops.
|
||||
func (d *DB) HasRecentAutoReply(accountID, ruleID int64, recipientEmail string) (bool, error) {
|
||||
var n int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM auto_reply_log
|
||||
WHERE account_id=? AND rule_id=? AND recipient_email=? COLLATE NOCASE
|
||||
AND sent_at > datetime('now', '-1 day')`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// LogAutoReply records that an auto-reply was just sent, for HasRecentAutoReply's window check.
|
||||
func (d *DB) LogAutoReply(accountID, ruleID int64, recipientEmail string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO auto_reply_log (account_id, rule_id, recipient_email) VALUES (?,?,?)`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Signatures ----
|
||||
|
||||
// ListSignatures returns all signatures owned by a user.
|
||||
func (d *DB) ListSignatures(userID int64) ([]models.Signature, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? ORDER BY name`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Signature
|
||||
for rows.Next() {
|
||||
var s models.Signature
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSignature fetches one signature scoped to its owning user.
|
||||
func (d *DB) GetSignature(userID, id int64) (*models.Signature, error) {
|
||||
s := &models.Signature{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? AND id=?`, userID, id,
|
||||
).Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// CreateSignature inserts a new signature and returns its id.
|
||||
func (d *DB) CreateSignature(userID int64, name, contentHTML string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO signatures (user_id, name, content_html) VALUES (?,?,?)`, userID, name, contentHTML,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateSignature updates name/content of a signature (scoped to owner).
|
||||
func (d *DB) UpdateSignature(userID, id int64, name, contentHTML string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`UPDATE signatures SET name=?, content_html=? WHERE id=? AND user_id=?`, name, contentHTML, id, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSignature removes a signature (scoped to owner). Any signature_defaults rows
|
||||
// pointing at it are cleared automatically via ON DELETE SET NULL.
|
||||
func (d *DB) DeleteSignature(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM signatures WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSignatureDefaults returns the default-new/default-reply signature ids for an account.
|
||||
// Returns a zero-value struct (no error) if the account has no defaults row yet.
|
||||
func (d *DB) GetSignatureDefaults(accountID int64) (models.SignatureDefaults, error) {
|
||||
sd := models.SignatureDefaults{AccountID: accountID}
|
||||
var newID, replyID sql.NullInt64
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT default_new_id, default_reply_id FROM signature_defaults WHERE account_id=?`, accountID,
|
||||
).Scan(&newID, &replyID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sd, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sd, err
|
||||
}
|
||||
sd.DefaultNewID = newID.Int64
|
||||
sd.DefaultReplyID = replyID.Int64
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
// SetSignatureDefaults upserts which signature is default-for-new / default-for-reply on an account.
|
||||
// A ProviderID of 0 clears that default (stored as NULL).
|
||||
func (d *DB) SetSignatureDefaults(accountID, defaultNewID, defaultReplyID int64) error {
|
||||
var newVal, replyVal interface{}
|
||||
if defaultNewID > 0 {
|
||||
newVal = defaultNewID
|
||||
}
|
||||
if defaultReplyID > 0 {
|
||||
replyVal = defaultReplyID
|
||||
}
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO signature_defaults (account_id, default_new_id, default_reply_id) VALUES (?,?,?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET default_new_id=excluded.default_new_id, default_reply_id=excluded.default_reply_id`,
|
||||
accountID, newVal, replyVal,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- S/MIME identities ----
|
||||
// key_pem is encrypted at rest via d.enc (internal/crypto.Encryptor), same as OAuth tokens elsewhere.
|
||||
|
||||
// ListSMIMEIdentities returns all S/MIME identities for an account (key_pem decrypted).
|
||||
func (d *DB) ListSMIMEIdentities(accountID int64) ([]models.SMIMEIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? ORDER BY created_at DESC`,
|
||||
accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEIdentity
|
||||
for rows.Next() {
|
||||
var s models.SMIMEIdentity
|
||||
var keyEnc string
|
||||
if err := rows.Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEIdentity fetches one S/MIME identity scoped to its account (key_pem decrypted).
|
||||
func (d *DB) GetSMIMEIdentity(accountID, id int64) (*models.SMIMEIdentity, error) {
|
||||
s := &models.SMIMEIdentity{}
|
||||
var keyEnc string
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? AND id=?`,
|
||||
accountID, id,
|
||||
).Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// CreateSMIMEIdentity encrypts keyPEM at rest and inserts a new identity, returning its id.
|
||||
func (d *DB) CreateSMIMEIdentity(accountID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
|
||||
keyEnc, err := d.enc.Encrypt(keyPEM)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO smime_identities (account_id, cert_pem, key_pem, not_after) VALUES (?,?,?,?)`,
|
||||
accountID, certPEM, keyEnc, notAfter,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteSMIMEIdentity removes an identity (scoped to account_id).
|
||||
func (d *DB) DeleteSMIMEIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- S/MIME contact certs (per-user address book, unencrypted — public certs only) ----
|
||||
|
||||
func (d *DB) ListSMIMEContacts(userID int64) ([]models.SMIMEContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEContact
|
||||
for rows.Next() {
|
||||
var c models.SMIMEContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEContactByEmail looks up a contact's cert by address (used when signer/encryptor
|
||||
// needs to know if a recipient has a cert on file). Returns nil, nil if not found.
|
||||
func (d *DB) GetSMIMEContactByEmail(userID int64, email string) (*models.SMIMEContact, error) {
|
||||
c := &models.SMIMEContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// UpsertSMIMEContact adds or replaces a contact's cert for an email address.
|
||||
func (d *DB) UpsertSMIMEContact(userID int64, email, certPEM string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO smime_contacts (user_id, email, cert_pem) VALUES (?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET cert_pem=excluded.cert_pem`,
|
||||
userID, email, certPEM,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSMIMEContact removes a contact cert (scoped to owner).
|
||||
func (d *DB) DeleteSMIMEContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
+666
-54
@@ -4,26 +4,76 @@ package email
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
netmail "net/mail"
|
||||
"net/smtp"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
|
||||
gomailModels "github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// defaultNetTimeout bounds any dial/command whose caller passed a context
|
||||
// with no deadline (e.g. TestConnection). Callers with a deadline (deltaSync,
|
||||
// idleWatcher) get that deadline instead — see dialTimeout.
|
||||
const defaultNetTimeout = 20 * time.Second
|
||||
|
||||
func dialTimeout(ctx context.Context) time.Duration {
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
if d := time.Until(dl); d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return defaultNetTimeout
|
||||
}
|
||||
|
||||
// connectIMAP dials host:port, trying implicit TLS first — this covers both
|
||||
// the standard 993 port and non-standard implicit-SSL ports (e.g. 40993).
|
||||
// If the server isn't speaking TLS at all (tls.RecordHeaderError), it falls
|
||||
// back to plaintext + STARTTLS. A genuine TLS error (bad/self-signed cert)
|
||||
// is NOT retried in plaintext — it's returned so callers can surface it.
|
||||
// The dial and every subsequent IMAP command are bounded by timeout, so a
|
||||
// misconfigured or unreachable server can never hang a caller forever.
|
||||
func connectIMAP(ctx context.Context, host string, port int) (*client.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
timeout := dialTimeout(ctx)
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
|
||||
c, err := client.DialWithDialerTLS(dialer, addr, &tls.Config{ServerName: host})
|
||||
if err != nil {
|
||||
if _, notTLS := err.(tls.RecordHeaderError); !notTLS {
|
||||
return nil, err
|
||||
}
|
||||
c, err = client.DialWithDialer(dialer, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
c.Logout()
|
||||
return nil, fmt.Errorf("STARTTLS: %w", err)
|
||||
}
|
||||
}
|
||||
c.Timeout = timeout
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
|
||||
switch provider {
|
||||
case gomailModels.ProviderGmail:
|
||||
@@ -54,7 +104,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
|
||||
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
||||
return "XOAUTH2", []byte(payload), nil
|
||||
}
|
||||
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
|
||||
|
||||
// Next handles the XOAUTH2 challenge from the server.
|
||||
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
||||
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
||||
// final tagged NO response and returns a proper error.
|
||||
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
||||
if len(challenge) > 0 {
|
||||
// Decode and log the error from Microsoft so it appears in server logs
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
||||
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
||||
} else {
|
||||
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
||||
}
|
||||
// Send empty response to let the server send the final error
|
||||
return []byte("\x01"), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type xoauth2SMTP struct{ user, token string }
|
||||
|
||||
@@ -80,6 +147,9 @@ type Client struct {
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
||||
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
||||
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
||||
}
|
||||
host, port := imapHostFor(account.Provider)
|
||||
if account.IMAPHost != "" {
|
||||
host = account.IMAPHost
|
||||
@@ -89,25 +159,40 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
||||
return nil, fmt.Errorf("IMAP host not configured for account %s", account.EmailAddress)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
var c *client.Client
|
||||
var err error
|
||||
|
||||
if port == 993 {
|
||||
c, err = client.DialTLS(addr, &tls.Config{ServerName: host})
|
||||
} else {
|
||||
c, err = client.Dial(addr)
|
||||
if err == nil {
|
||||
// Attempt STARTTLS; ignore error if server doesn't support it
|
||||
_ = c.StartTLS(&tls.Config{ServerName: host})
|
||||
}
|
||||
}
|
||||
c, err := connectIMAP(ctx, host, port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP connect %s: %w", addr, err)
|
||||
return nil, fmt.Errorf("IMAP connect %s:%d: %w", host, port, err)
|
||||
}
|
||||
|
||||
switch account.Provider {
|
||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
||||
tokenPreview := account.AccessToken
|
||||
if len(tokenPreview) > 20 {
|
||||
tokenPreview = tokenPreview[:20] + "..."
|
||||
}
|
||||
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
||||
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||
var claims struct {
|
||||
Aud interface{} `json:"aud"`
|
||||
Scp string `json:"scp"`
|
||||
Upn string `json:"upn"`
|
||||
}
|
||||
if json.Unmarshal(payload, &claims) == nil {
|
||||
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
|
||||
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
|
||||
account.EmailAddress, string(payload), tokenPreview)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
|
||||
account.EmailAddress, tokenPreview)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
|
||||
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
||||
}
|
||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||
if err := c.Authenticate(sasl); err != nil {
|
||||
c.Logout()
|
||||
@@ -123,6 +208,15 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
||||
return &Client{imap: c, account: account}, nil
|
||||
}
|
||||
|
||||
// TestConnectionError details why a connection failed.
|
||||
type TestConnectionError struct {
|
||||
Type string `json:"type"` // "connection_error", "cert_error", "auth_error"
|
||||
Message string `json:"message"`
|
||||
CertPEM string `json:"cert_pem,omitempty"`
|
||||
CertHash string `json:"cert_hash,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
func TestConnection(account *gomailModels.EmailAccount) error {
|
||||
c, err := Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
@@ -132,12 +226,104 @@ func TestConnection(account *gomailModels.EmailAccount) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnectionDetailed returns structured error info including cert details on failure.
|
||||
func TestConnectionDetailed(account *gomailModels.EmailAccount, db interface{}) *TestConnectionError {
|
||||
host, port := imapHostFor(account.Provider)
|
||||
if account.IMAPHost != "" {
|
||||
host = account.IMAPHost
|
||||
port = account.IMAPPort
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultNetTimeout)
|
||||
defer cancel()
|
||||
c, err := connectIMAP(ctx, host, port)
|
||||
|
||||
if err != nil {
|
||||
errInfo := &TestConnectionError{Message: err.Error()}
|
||||
|
||||
// Check if it's a cert error
|
||||
if certErr, ok := err.(tls.RecordHeaderError); ok && certErr.Msg != "" {
|
||||
errInfo.Type = "cert_error"
|
||||
errInfo.Hostname = host
|
||||
// Try to dial and capture the cert for display
|
||||
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
|
||||
if conn != nil {
|
||||
if len(conn.ConnectionState().PeerCertificates) > 0 {
|
||||
cert := conn.ConnectionState().PeerCertificates[0]
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
errInfo.CertHash = hex.EncodeToString(hash[:])
|
||||
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
} else {
|
||||
// Check if underlying error is a cert error
|
||||
if e, ok := err.(*x509.UnknownAuthorityError); ok {
|
||||
errInfo.Type = "cert_error"
|
||||
errInfo.Hostname = host
|
||||
if cert := e.Cert; cert != nil {
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
errInfo.CertHash = hex.EncodeToString(hash[:])
|
||||
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
|
||||
}
|
||||
} else if strings.Contains(err.Error(), "certificate") {
|
||||
errInfo.Type = "cert_error"
|
||||
errInfo.Hostname = host
|
||||
// Dial insecurely to get the cert
|
||||
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
|
||||
if conn != nil && len(conn.ConnectionState().PeerCertificates) > 0 {
|
||||
cert := conn.ConnectionState().PeerCertificates[0]
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
errInfo.CertHash = hex.EncodeToString(hash[:])
|
||||
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
|
||||
conn.Close()
|
||||
}
|
||||
} else if strings.Contains(err.Error(), "auth") {
|
||||
errInfo.Type = "auth_error"
|
||||
} else {
|
||||
errInfo.Type = "connection_error"
|
||||
}
|
||||
}
|
||||
return errInfo
|
||||
}
|
||||
|
||||
// Try auth
|
||||
switch account.Provider {
|
||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||
if err := c.Authenticate(sasl); err != nil {
|
||||
c.Logout()
|
||||
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("OAuth auth failed: %v", err)}
|
||||
}
|
||||
default:
|
||||
if err := c.Login(account.EmailAddress, account.AccessToken); err != nil {
|
||||
c.Logout()
|
||||
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("Login failed: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
c.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustEncodeCert(derBytes []byte) []byte {
|
||||
// Encode DER to PEM
|
||||
return []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
|
||||
base64.StdEncoding.EncodeToString(derBytes)))
|
||||
}
|
||||
|
||||
func (c *Client) Close() { c.imap.Logout() }
|
||||
|
||||
func (c *Client) DeleteMailbox(name string) error {
|
||||
return c.imap.Delete(name)
|
||||
}
|
||||
|
||||
func (c *Client) CreateMailbox(name string) error {
|
||||
return c.imap.Create(name)
|
||||
}
|
||||
|
||||
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
|
||||
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
|
||||
if _, err := c.imap.Select(srcMailbox, false); err != nil {
|
||||
@@ -392,8 +578,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
|
||||
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||
return parseMIME(raw)
|
||||
}
|
||||
|
||||
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
||||
// text/plain, text/html and attachment metadata.
|
||||
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
|
||||
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
@@ -405,12 +597,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
|
||||
ct = "text/plain"
|
||||
}
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
|
||||
// cidMap: Content-ID → base64 data URI for inline images
|
||||
cidMap := make(map[string]string)
|
||||
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
|
||||
|
||||
// Rewrite cid: references in HTML to data: URIs
|
||||
if html != "" && len(cidMap) > 0 {
|
||||
html = rewriteCIDReferences(html, cidMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
|
||||
func rewriteCIDReferences(html string, cidMap map[string]string) string {
|
||||
for cid, dataURI := range cidMap {
|
||||
// Match both with and without angle brackets
|
||||
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
|
||||
// Some clients wrap CID in angle brackets in the src attribute
|
||||
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
// parsePart recursively handles a MIME part.
|
||||
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||
return parsePartIndexed(contentType, transferEncoding, body, []int{})
|
||||
}
|
||||
|
||||
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
|
||||
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return string(body), "", nil
|
||||
}
|
||||
mediaType = strings.ToLower(mediaType)
|
||||
decoded := decodeTransfer(transferEncoding, body)
|
||||
|
||||
switch {
|
||||
case mediaType == "text/plain":
|
||||
text = decodeCharset(params["charset"], decoded)
|
||||
case mediaType == "text/html":
|
||||
html = decodeCharset(params["charset"], decoded)
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return string(decoded), "", nil
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||
partIdx := 0
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
partIdx++
|
||||
childPath := append(append([]int{}, path...), partIdx)
|
||||
|
||||
partBody, _ := io.ReadAll(part)
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
if partCT == "" {
|
||||
partCT = "text/plain"
|
||||
}
|
||||
partTE := part.Header.Get("Content-Transfer-Encoding")
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
|
||||
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
filename = part.FileName()
|
||||
}
|
||||
if filename != "" {
|
||||
wd := mime.WordDecoder{}
|
||||
if dec, e := wd.DecodeHeader(filename); e == nil {
|
||||
filename = dec
|
||||
}
|
||||
}
|
||||
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
partMediaLower := strings.ToLower(partMedia)
|
||||
|
||||
// Inline image with Content-ID → embed as data URI for cid: resolution
|
||||
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
|
||||
decodedPart := decodeTransfer(partTE, partBody)
|
||||
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
|
||||
cidMap[contentID] = dataURI
|
||||
// Don't add as attachment chip — it's inline
|
||||
continue
|
||||
}
|
||||
|
||||
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
||||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
|
||||
!strings.HasPrefix(partMediaLower, "multipart/"))
|
||||
|
||||
if isAttachment {
|
||||
if filename == "" {
|
||||
filename = "attachment"
|
||||
}
|
||||
mimePartPath := mimePathString(childPath)
|
||||
attachments = append(attachments, gomailModels.Attachment{
|
||||
Filename: filename,
|
||||
ContentType: partMedia,
|
||||
Size: int64(len(partBody)),
|
||||
ContentID: mimePartPath,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
|
||||
if text == "" && t != "" {
|
||||
text = t
|
||||
}
|
||||
if html == "" && h != "" {
|
||||
html = h
|
||||
}
|
||||
attachments = append(attachments, atts...)
|
||||
}
|
||||
default:
|
||||
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||
filename := mtParams["name"]
|
||||
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
||||
wd := mime.WordDecoder{}
|
||||
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
||||
filename = dec
|
||||
}
|
||||
attachments = append(attachments, gomailModels.Attachment{
|
||||
Filename: filename,
|
||||
ContentType: mt,
|
||||
Size: int64(len(decoded)),
|
||||
ContentID: mimePathString(path),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
|
||||
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return string(body), "", nil
|
||||
@@ -430,11 +754,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
||||
return string(decoded), "", nil
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||
partIdx := 0
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
partIdx++
|
||||
childPath := append(append([]int{}, path...), partIdx)
|
||||
|
||||
partBody, _ := io.ReadAll(part)
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
if partCT == "" {
|
||||
@@ -444,24 +772,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||
|
||||
if strings.EqualFold(dispType, "attachment") {
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
filename = part.FileName()
|
||||
// Filename from Content-Disposition or Content-Type params
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
filename = part.FileName()
|
||||
}
|
||||
// Decode RFC 2047 encoded filename
|
||||
if filename != "" {
|
||||
wd := mime.WordDecoder{}
|
||||
if dec, err := wd.DecodeHeader(filename); err == nil {
|
||||
filename = dec
|
||||
}
|
||||
}
|
||||
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
|
||||
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
||||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
|
||||
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
|
||||
|
||||
if isAttachment {
|
||||
if filename == "" {
|
||||
filename = "attachment"
|
||||
}
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
// Build MIME part path string e.g. "1.2" for nested
|
||||
mimePartPath := mimePathString(childPath)
|
||||
attachments = append(attachments, gomailModels.Attachment{
|
||||
Filename: filename,
|
||||
ContentType: partMedia,
|
||||
Size: int64(len(partBody)),
|
||||
ContentID: mimePartPath, // reuse ContentID to store part path
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
t, h, atts := parsePart(partCT, partTE, partBody)
|
||||
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
|
||||
if text == "" && t != "" {
|
||||
text = t
|
||||
}
|
||||
@@ -471,13 +816,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
||||
attachments = append(attachments, atts...)
|
||||
}
|
||||
default:
|
||||
// Any other type – treat as attachment if it has a filename
|
||||
mt, _, _ := mime.ParseMediaType(contentType)
|
||||
_ = mt
|
||||
// Any other non-text type with a filename → treat as attachment
|
||||
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||
filename := mtParams["name"]
|
||||
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
||||
wd := mime.WordDecoder{}
|
||||
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
||||
filename = dec
|
||||
}
|
||||
attachments = append(attachments, gomailModels.Attachment{
|
||||
Filename: filename,
|
||||
ContentType: mt,
|
||||
Size: int64(len(decoded)),
|
||||
ContentID: mimePathString(path),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// mimePathString converts an int path like [1,2] to "1.2".
|
||||
func mimePathString(path []int) string {
|
||||
parts := make([]string, len(path))
|
||||
for i, n := range path {
|
||||
parts[i] = fmt.Sprintf("%d", n)
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func decodeTransfer(encoding string, data []byte) []byte {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "base64":
|
||||
@@ -654,9 +1021,37 @@ func authSMTP(c *smtp.Client, account *gomailModels.EmailAccount, host string) e
|
||||
}
|
||||
}
|
||||
|
||||
// Signer optionally S/MIME-signs and/or PGP-encrypts the raw outgoing MIME message before
|
||||
// it is sent. Implemented in internal/handlers using loaded S/MIME/PGP identities and
|
||||
// contacts — kept as an interface here so this package never needs to import internal/db.
|
||||
// A nil Signer (the common case: no certs configured) is a no-op.
|
||||
type Signer interface {
|
||||
SignAndEncrypt(account *gomailModels.EmailAccount, recipients []string, raw []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// SendMessageFull sends an email via SMTP using the account's configured server.
|
||||
// It also appends the sent message to the IMAP Sent folder.
|
||||
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) error {
|
||||
// It also appends the sent message to the IMAP Sent folder. signer may be nil.
|
||||
// BuildRawMessage assembles the RFC822 message body for req (optionally signed/
|
||||
// encrypted via signer), shared by the SMTP (SendMessageFull) and JMAP
|
||||
// (SendMessageJMAP) send paths.
|
||||
func BuildRawMessage(account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buildMIMEMessage(&buf, account, req)
|
||||
rawMsg := buf.Bytes()
|
||||
|
||||
if signer != nil {
|
||||
allRecipients := append(append([]string{}, req.To...), req.CC...)
|
||||
allRecipients = append(allRecipients, req.BCC...)
|
||||
signed, err := signer.SignAndEncrypt(account, allRecipients, rawMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign/encrypt: %w", err)
|
||||
}
|
||||
rawMsg = signed
|
||||
}
|
||||
return rawMsg, nil
|
||||
}
|
||||
|
||||
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
|
||||
host, port := smtpHostFor(account.Provider)
|
||||
if account.SMTPHost != "" {
|
||||
host = account.SMTPHost
|
||||
@@ -666,26 +1061,36 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
return fmt.Errorf("SMTP host not configured")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buildMIMEMessage(&buf, account, req)
|
||||
rawMsg := buf.Bytes()
|
||||
rawMsg, err := BuildRawMessage(account, req, signer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
timeout := dialTimeout(ctx)
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
|
||||
var c *smtp.Client
|
||||
var err error
|
||||
|
||||
if port == 465 {
|
||||
// Implicit TLS (SMTPS)
|
||||
conn, err2 := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("SMTPS dial %s: %w", addr, err2)
|
||||
}
|
||||
c, err = smtp.NewClient(conn, host)
|
||||
// Try implicit TLS first — covers both the standard 465 port and
|
||||
// non-standard implicit-SSL ports (e.g. 40465). Fall back to plaintext +
|
||||
// STARTTLS only if the server isn't speaking TLS at all; a genuine TLS
|
||||
// error (bad/self-signed cert) is returned as-is, not retried in plaintext.
|
||||
tlsConn, tlsErr := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: host})
|
||||
if tlsErr == nil {
|
||||
tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
c, err = smtp.NewClient(tlsConn, host)
|
||||
} else if _, notTLS := tlsErr.(tls.RecordHeaderError); !notTLS {
|
||||
return fmt.Errorf("SMTPS dial %s: %w", addr, tlsErr)
|
||||
} else {
|
||||
// Plain SMTP then upgrade with STARTTLS (port 587 / 25)
|
||||
c, err = smtp.Dial(addr)
|
||||
conn, dialErr := dialer.Dial("tcp", addr)
|
||||
if dialErr != nil {
|
||||
return fmt.Errorf("SMTP dial %s: %w", addr, dialErr)
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
c, err = smtp.NewClient(conn, host)
|
||||
if err == nil {
|
||||
// EHLO with sender's domain (not "localhost") to avoid rejection by strict MTAs
|
||||
senderDomain := "localhost"
|
||||
@@ -712,7 +1117,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
if err := authSMTP(c, account, host); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
||||
}
|
||||
log.Printf("[SMTP] auth OK")
|
||||
logger.Debug("[SMTP] auth OK")
|
||||
|
||||
if err := c.Mail(account.EmailAddress); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
||||
@@ -742,7 +1147,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
// DATA close is where the server accepts or rejects the message
|
||||
return fmt.Errorf("SMTP server rejected message: %w", err)
|
||||
}
|
||||
log.Printf("[SMTP] message accepted by server")
|
||||
logger.Debug("[SMTP] message accepted by server")
|
||||
_ = c.Quit()
|
||||
|
||||
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
||||
@@ -763,7 +1168,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
|
||||
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
||||
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
||||
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
|
||||
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
|
||||
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
|
||||
// Use the sender's actual domain for Message-ID so it passes spam filters
|
||||
domain := account.EmailAddress
|
||||
if at := strings.Index(domain, "@"); at >= 0 {
|
||||
@@ -781,24 +1187,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
||||
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
||||
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
// Plain text part
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
qpw := quotedprintable.NewWriter(buf)
|
||||
hasAttachments := len(req.Attachments) > 0
|
||||
|
||||
if hasAttachments {
|
||||
// Outer multipart/mixed wraps body + attachments
|
||||
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
|
||||
buf.WriteString("--" + mixedBoundary + "\r\n")
|
||||
}
|
||||
|
||||
// Inner multipart/alternative: text/plain + text/html
|
||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
|
||||
|
||||
plainText := req.BodyText
|
||||
if plainText == "" && req.BodyHTML != "" {
|
||||
plainText = htmlToPlainText(req.BodyHTML)
|
||||
}
|
||||
|
||||
buf.WriteString("--" + altBoundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
qpw := quotedprintable.NewWriter(buf)
|
||||
qpw.Write([]byte(plainText))
|
||||
qpw.Close()
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("--" + altBoundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
qpw2 := quotedprintable.NewWriter(buf)
|
||||
@@ -809,8 +1223,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
||||
}
|
||||
qpw2.Close()
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("--" + altBoundary + "--\r\n")
|
||||
|
||||
if hasAttachments {
|
||||
for _, att := range req.Attachments {
|
||||
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
|
||||
ct := att.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
|
||||
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
|
||||
encoded := base64.StdEncoding.EncodeToString(att.Data)
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
buf.WriteString(encoded[i:end] + "\r\n")
|
||||
}
|
||||
}
|
||||
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
|
||||
}
|
||||
|
||||
buf.WriteString("--" + boundary + "--\r\n")
|
||||
return msgID
|
||||
}
|
||||
|
||||
@@ -875,6 +1312,168 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
|
||||
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
||||
}
|
||||
|
||||
// draftsMailboxName finds the account's Drafts folder, or "" if none exists.
|
||||
func (c *Client) draftsMailboxName() (string, error) {
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, mb := range mailboxes {
|
||||
if InferFolderType(mb.Name, mb.Attributes) == "drafts" {
|
||||
return mb.Name, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND. When prevUID
|
||||
// is non-zero, that earlier draft copy is deleted first, so repeated autosaves of the same
|
||||
// in-progress compose replace the draft in place instead of piling up duplicates. Returns
|
||||
// the folder name (for sync purposes) and the new draft's UID (0 if it couldn't be
|
||||
// determined — e.g. concurrent mailbox activity — in which case the next save just
|
||||
// appends another copy rather than risk deleting the wrong message).
|
||||
//
|
||||
// UID lookup is done via a plain UID SEARCH ALL (already used elsewhere for sync) rather
|
||||
// than SEARCH HEADER on a custom marker header: some real-world IMAP servers (observed:
|
||||
// centrum.sk) reject arbitrary HEADER search keys with "Unsupported search key", which
|
||||
// would silently break both the replace-in-place and the discard-on-close paths.
|
||||
func (c *Client) AppendToDrafts(rawMsg []byte, prevUID uint32) (string, uint32, error) {
|
||||
draftsName, err := c.draftsMailboxName()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if draftsName == "" {
|
||||
return "", 0, nil // no Drafts folder, skip silently
|
||||
}
|
||||
if prevUID != 0 {
|
||||
_ = c.DeleteByUID(draftsName, prevUID, "")
|
||||
}
|
||||
flags := []string{imap.DraftFlag, imap.SeenFlag}
|
||||
now := time.Now()
|
||||
if err := c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg)); err != nil {
|
||||
return draftsName, 0, err
|
||||
}
|
||||
uids, err := c.ListAllUIDs(draftsName)
|
||||
if err != nil || len(uids) == 0 {
|
||||
return draftsName, 0, nil
|
||||
}
|
||||
newUID := uids[0]
|
||||
for _, u := range uids {
|
||||
if u > newUID {
|
||||
newUID = u
|
||||
}
|
||||
}
|
||||
return draftsName, newUID, nil
|
||||
}
|
||||
|
||||
// DiscardDraftUID deletes a previously-autosaved draft by UID — used when the user closes
|
||||
// an in-progress compose and chooses not to keep the draft that autosave already wrote to
|
||||
// the server.
|
||||
func (c *Client) DiscardDraftUID(uid uint32) error {
|
||||
if uid == 0 {
|
||||
return nil
|
||||
}
|
||||
draftsName, err := c.draftsMailboxName()
|
||||
if err != nil || draftsName == "" {
|
||||
return err
|
||||
}
|
||||
return c.DeleteByUID(draftsName, uid, "")
|
||||
}
|
||||
|
||||
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
|
||||
// raw message and parsing the requested MIME part path.
|
||||
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
|
||||
raw, err := c.FetchRawByUID(mailboxName, uid)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
|
||||
}
|
||||
|
||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("parse message: %w", err)
|
||||
}
|
||||
|
||||
ct := msg.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "text/plain"
|
||||
}
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
|
||||
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return data, filename, contentType, nil
|
||||
}
|
||||
|
||||
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
|
||||
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
|
||||
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
|
||||
}
|
||||
|
||||
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
|
||||
}
|
||||
decoded := decodeTransfer(transferEncoding, body)
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return nil, "", "", fmt.Errorf("no boundary")
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||
partIdx := 0
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
partIdx++
|
||||
childPath := append(append([]int{}, currentPath...), partIdx)
|
||||
childPathStr := mimePathString(childPath)
|
||||
|
||||
partBody, _ := io.ReadAll(part)
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
if partCT == "" {
|
||||
partCT = "text/plain"
|
||||
}
|
||||
partTE := part.Header.Get("Content-Transfer-Encoding")
|
||||
|
||||
if childPathStr == targetPath {
|
||||
// Found it
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
_, dispParams, _ := mime.ParseMediaType(disposition)
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
filename = part.FileName()
|
||||
}
|
||||
wd2 := mime.WordDecoder{}
|
||||
if dec, e := wd2.DecodeHeader(filename); e == nil {
|
||||
filename = dec
|
||||
}
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
return decodeTransfer(partTE, partBody), filename, partMedia, nil
|
||||
}
|
||||
// Recurse into multipart children
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
|
||||
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
|
||||
return data, fn, ct2, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
||||
}
|
||||
|
||||
// Leaf node — only matches if path is root (empty)
|
||||
if targetPath == "" || targetPath == "1" {
|
||||
return decoded, "", strings.ToLower(mediaType), nil
|
||||
}
|
||||
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
@@ -961,6 +1560,19 @@ func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetFolderCounts returns the true total/unread message counts for a mailbox straight from
|
||||
// the server (IMAP STATUS), independent of how much history has been synced locally — a
|
||||
// SELECT's response doesn't carry a real unseen count (only the sequence number of the
|
||||
// first unseen message), so this needs its own STATUS query. STATUS doesn't disturb the
|
||||
// currently selected mailbox, so it's safe to call alongside GetFolderStatus/syncFolder.
|
||||
func (c *Client) GetFolderCounts(mailboxName string) (total, unread uint32, err error) {
|
||||
status, err := c.imap.Status(mailboxName, []imap.StatusItem{imap.StatusMessages, imap.StatusUnseen})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return status.Messages, status.Unseen, nil
|
||||
}
|
||||
|
||||
// ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection.
|
||||
func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/jmap"
|
||||
gomailModels "github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// SendMessageJMAP sends via the account's JMAP server instead of SMTP — used
|
||||
// for ProviderJMAP accounts. Builds the same RFC822 body as SendMessageFull
|
||||
// (optionally signed/encrypted via signer), then uploads + imports + submits
|
||||
// it over JMAP; the import into Sent replaces SMTP's separate append-to-Sent
|
||||
// step, since JMAP's Email/import already files the message.
|
||||
func SendMessageJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
|
||||
rawMsg, err := BuildRawMessage(account, req, signer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
|
||||
sentID, err := jc.FindMailboxByRole(ctx, "sent")
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap find Sent folder: %w", err)
|
||||
}
|
||||
return jc.Send(ctx, sentID, rawMsg)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Package graph provides Microsoft Graph API mail access for personal
|
||||
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
|
||||
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
|
||||
// so we use the Graph REST API instead with the JWT access token.
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
const baseURL = "https://graph.microsoft.com/v1.0/me"
|
||||
|
||||
// Client wraps Graph API calls for a single account.
|
||||
type Client struct {
|
||||
token string
|
||||
account *models.EmailAccount
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New creates a Graph client for the given account.
|
||||
func New(account *models.EmailAccount) *Client {
|
||||
return &Client{
|
||||
token: account.AccessToken,
|
||||
account: account,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
|
||||
fullURL := path
|
||||
if !strings.HasPrefix(path, "https://") {
|
||||
fullURL = baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
|
||||
strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) deleteReq(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Folders ----
|
||||
|
||||
// GraphFolder represents a mail folder from Graph API.
|
||||
type GraphFolder struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TotalCount int `json:"totalItemCount"`
|
||||
UnreadCount int `json:"unreadItemCount"`
|
||||
WellKnown string `json:"wellKnownName"`
|
||||
}
|
||||
|
||||
type foldersResp struct {
|
||||
Value []GraphFolder `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListFolders returns all mail folders for the account.
|
||||
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
|
||||
var all []GraphFolder
|
||||
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
|
||||
for path != "" {
|
||||
var resp foldersResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ---- Messages ----
|
||||
|
||||
// EmailAddress wraps a Graph email address object.
|
||||
type EmailAddress struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
|
||||
// GraphMessage represents a mail message from Graph API.
|
||||
type GraphMessage struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
IsRead bool `json:"isRead"`
|
||||
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
|
||||
ReceivedDateTime time.Time `json:"receivedDateTime"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
From *EmailAddress `json:"from"`
|
||||
ToRecipients []EmailAddress `json:"toRecipients"`
|
||||
CcRecipients []EmailAddress `json:"ccRecipients"`
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"contentType"`
|
||||
} `json:"body"`
|
||||
InternetMessageID string `json:"internetMessageId"`
|
||||
}
|
||||
|
||||
// IsFlagged returns true if the message is flagged.
|
||||
func (m *GraphMessage) IsFlagged() bool {
|
||||
return m.Flag.Status == "flagged"
|
||||
}
|
||||
|
||||
// FromName returns the sender display name.
|
||||
func (m *GraphMessage) FromName() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Name
|
||||
}
|
||||
|
||||
// FromEmail returns the sender email address.
|
||||
func (m *GraphMessage) FromEmail() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Address
|
||||
}
|
||||
|
||||
// ToList returns a comma-separated list of recipients.
|
||||
func (m *GraphMessage) ToList() string {
|
||||
var parts []string
|
||||
for _, r := range m.ToRecipients {
|
||||
parts = append(parts, r.EmailAddress.Address)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
type messagesResp struct {
|
||||
Value []GraphMessage `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListMessages returns messages in a folder, optionally filtered by received date.
|
||||
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
|
||||
filter := ""
|
||||
if !since.IsZero() {
|
||||
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
|
||||
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
|
||||
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
|
||||
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
|
||||
}
|
||||
top := 50
|
||||
if maxResults > 0 && maxResults < top {
|
||||
top = maxResults
|
||||
}
|
||||
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
|
||||
folderID, top, filter)
|
||||
|
||||
var all []GraphMessage
|
||||
for path != "" {
|
||||
var resp messagesResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a single message with full body.
|
||||
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
|
||||
var msg GraphMessage
|
||||
err := c.get(ctx, "/messages/"+msgID+
|
||||
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
|
||||
&msg)
|
||||
return &msg, err
|
||||
}
|
||||
|
||||
// GetMessageRaw returns the raw RFC 822 message bytes.
|
||||
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
baseURL+"/messages/"+msgID+"/$value", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// MarkRead sets the isRead flag on a message.
|
||||
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears the flag on a message.
|
||||
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
|
||||
status := "notFlagged"
|
||||
if flagged {
|
||||
status = "flagged"
|
||||
}
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
|
||||
"flag": map[string]string{"flagStatus": status},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessage moves a message to Deleted Items (soft delete).
|
||||
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
|
||||
return c.deleteReq(ctx, "/messages/"+msgID)
|
||||
}
|
||||
|
||||
// MoveMessage moves a message to a different folder.
|
||||
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
|
||||
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph move returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
|
||||
// WellKnown field is not selectable via $select — we infer from displayName instead.
|
||||
func InferFolderType(displayName string) string {
|
||||
switch strings.ToLower(displayName) {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent items", "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "deleted items", "trash", "bin":
|
||||
return "trash"
|
||||
case "junk email", "spam", "junk":
|
||||
return "spam"
|
||||
case "archive":
|
||||
return "archive"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// WellKnownToFolderType kept for compatibility.
|
||||
func WellKnownToFolderType(wk string) string {
|
||||
return InferFolderType(wk)
|
||||
}
|
||||
|
||||
// ---- Send mail ----
|
||||
|
||||
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
|
||||
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
|
||||
func stripHTML(s string) string {
|
||||
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
|
||||
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").Replace(s)
|
||||
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// SendMail sends an email via Graph API POST /me/sendMail.
|
||||
// Sets both HTML and plain-text body to improve deliverability (spam filters
|
||||
// penalise HTML-only messages with no text/plain alternative).
|
||||
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
|
||||
// Build body: always provide both HTML and plain text for better deliverability
|
||||
body := map[string]string{
|
||||
"contentType": "HTML",
|
||||
"content": req.BodyHTML,
|
||||
}
|
||||
if req.BodyHTML == "" {
|
||||
body["contentType"] = "Text"
|
||||
body["content"] = req.BodyText
|
||||
}
|
||||
|
||||
// Set explicit from with display name
|
||||
var fromField interface{}
|
||||
if c.account.DisplayName != "" {
|
||||
fromField = map[string]interface{}{
|
||||
"emailAddress": map[string]string{
|
||||
"address": c.account.EmailAddress,
|
||||
"name": c.account.DisplayName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"subject": req.Subject,
|
||||
"body": body,
|
||||
"toRecipients": graphRecipients(req.To),
|
||||
"ccRecipients": graphRecipients(req.CC),
|
||||
"bccRecipients": graphRecipients(req.BCC),
|
||||
}
|
||||
if fromField != nil {
|
||||
msg["from"] = fromField
|
||||
}
|
||||
|
||||
if len(req.Attachments) > 0 {
|
||||
var atts []map[string]interface{}
|
||||
for _, a := range req.Attachments {
|
||||
atts = append(atts, map[string]interface{}{
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": a.Filename,
|
||||
"contentType": a.ContentType,
|
||||
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
|
||||
})
|
||||
}
|
||||
msg["attachments"] = atts
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"message": msg,
|
||||
"saveToSentItems": true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sendMail: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/sendMail", strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build sendMail request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendMail request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func graphRecipients(addrs []string) []map[string]interface{} {
|
||||
result := []map[string]interface{}{}
|
||||
for _, a := range addrs {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
result = append(result, map[string]interface{}{
|
||||
"emailAddress": map[string]string{"address": a},
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -108,9 +109,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
|
||||
|
||||
var req struct {
|
||||
IsActive *bool `json:"is_active"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
DisableMFA bool `json:"disable_mfa"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.DisableMFA {
|
||||
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
||||
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- IP Blocks ----
|
||||
|
||||
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
blocks, err := h.db.ListIPBlocks()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
|
||||
return
|
||||
}
|
||||
if blocks == nil {
|
||||
blocks = []db.IPBlock{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
BanHours int `json:"ban_hours"` // 0 = permanent
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.IP == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
// Try geo lookup for the IP being manually blocked
|
||||
g := geo.Lookup(req.IP)
|
||||
if req.Reason == "" {
|
||||
req.Reason = "Manual admin block"
|
||||
}
|
||||
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
ip := mux.Vars(r)["ip"]
|
||||
if ip == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
if err := h.db.UnblockIP(ip); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "unblock failed")
|
||||
return
|
||||
}
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Login Attempts ----
|
||||
|
||||
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
|
||||
return
|
||||
}
|
||||
if stats == nil {
|
||||
stats = []db.LoginAttemptStat{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"attempts": stats})
|
||||
}
|
||||
|
||||
+785
-61
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,13 @@ type AppHandler struct {
|
||||
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "app", nil)
|
||||
}
|
||||
|
||||
// ViewMessage renders a single message in a full browser tab.
|
||||
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "message", nil)
|
||||
}
|
||||
|
||||
// ComposePage renders the compose form in a full browser tab.
|
||||
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "compose", nil)
|
||||
}
|
||||
|
||||
+345
-11
@@ -4,9 +4,15 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||
@@ -14,6 +20,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/internal/mfa"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -23,6 +30,8 @@ type AuthHandler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
renderer *Renderer
|
||||
syncer interface{ TriggerReconcile() }
|
||||
pgpCache *pgp.Cache
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
@@ -53,6 +62,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||
case "deny":
|
||||
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||
return
|
||||
case "skip_brute":
|
||||
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||
w.Header().Set("X-Skip-Brute", "1")
|
||||
}
|
||||
|
||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||
uid := user.ID
|
||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||
@@ -83,6 +103,9 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent())
|
||||
}
|
||||
h.db.DeleteSession(cookie.Value)
|
||||
if h.pgpCache != nil {
|
||||
h.pgpCache.ClearSession(cookie.Value)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "gomail_session", Value: "", MaxAge: -1, Path: "/",
|
||||
@@ -142,8 +165,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
|
||||
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
|
||||
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
|
||||
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -299,12 +322,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
if err := h.db.CreateAccount(account); err != nil {
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
|
||||
action := "gmail:" + userInfo.Email
|
||||
if !created {
|
||||
action = "gmail-reconnect:" + userInfo.Email
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -319,13 +350,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
||||
state := encodeOAuthState(userID, "outlook")
|
||||
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
// Microsoft returns ?error=...&error_description=... instead of ?code=...
|
||||
// when the user denies consent or the app has misconfigured permissions.
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2>
|
||||
<pre>%s</pre>
|
||||
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
|
||||
<ul>
|
||||
<li>In Azure portal → API Permissions → Add a permission</li>
|
||||
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
|
||||
<li>Search: <strong>Office 365 Exchange Online</strong></li>
|
||||
<li>Delegated permissions → add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
|
||||
<li>Then click <strong>Grant admin consent</strong></li>
|
||||
<li>Do NOT use Microsoft Graph versions of these scopes</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
@@ -335,29 +404,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
|
||||
// Show the raw error in the browser so the user can diagnose the problem
|
||||
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
|
||||
<h2>Outlook OAuth Token Exchange Failed</h2>
|
||||
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
|
||||
<pre>%s</pre>
|
||||
<hr>
|
||||
<p><strong>Things to check:</strong></p>
|
||||
<ul>
|
||||
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
|
||||
<li>Tenant ID in config: <code>%s</code> — must match your app's "Supported account types"</li>
|
||||
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
|
||||
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
|
||||
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
|
||||
return
|
||||
}
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
|
||||
|
||||
// Exchange initial token for one scoped to https://outlook.office.com
|
||||
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
|
||||
imapToken, err := goauth.ExchangeForIMAPToken(
|
||||
r.Context(),
|
||||
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, token.RefreshToken,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
|
||||
imapToken = token
|
||||
} else {
|
||||
logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
|
||||
if imapToken.RefreshToken == "" {
|
||||
imapToken.RefreshToken = token.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlook,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
|
||||
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
if err := h.db.CreateAccount(account); err != nil {
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
|
||||
action := "outlook:" + userInfo.Email()
|
||||
if !created {
|
||||
action = "outlook-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -403,3 +523,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---- Profile Updates ----
|
||||
|
||||
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Field string `json:"field"` // "email" | "username"
|
||||
Value string `json:"value"`
|
||||
Password string `json:"password"` // current password required for confirmation
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if req.Value == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "value required")
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
|
||||
return
|
||||
}
|
||||
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
|
||||
writeJSONError(w, http.StatusForbidden, "incorrect password")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Field {
|
||||
case "email":
|
||||
// Check uniqueness
|
||||
existing, _ := h.db.GetUserByEmail(req.Value)
|
||||
if existing != nil && existing.ID != userID {
|
||||
writeJSONError(w, http.StatusConflict, "email already in use")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
|
||||
return
|
||||
}
|
||||
case "username":
|
||||
existing, _ := h.db.GetUserByUsername(req.Value)
|
||||
if existing != nil && existing.ID != userID {
|
||||
writeJSONError(w, http.StatusConflict, "username already in use")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
|
||||
return
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r)
|
||||
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Per-User IP Rules ----
|
||||
|
||||
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
rule, err := h.db.GetUserIPRule(userID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(rule)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
|
||||
IPList string `json:"ip_list"` // comma-separated
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
|
||||
if !validModes[req.Mode] {
|
||||
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate IPs
|
||||
for _, rawIP := range db.SplitIPList(req.IPList) {
|
||||
if net.ParseIP(rawIP) == nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Mode == "disabled" {
|
||||
h.db.DeleteUserIPRule(userID)
|
||||
} else {
|
||||
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r)
|
||||
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) OAuth2 ----
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.MicrosoftClientID == "" {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
|
||||
return
|
||||
}
|
||||
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
|
||||
userID := middleware.GetUserID(r)
|
||||
state := encodeOAuthState(userID, "outlook_personal")
|
||||
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2><pre>%s</pre>
|
||||
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
|
||||
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
|
||||
<p><a href="/">← Back</a></p></body></html>`,
|
||||
html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
if code == "" {
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook_personal" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from ID token
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
|
||||
tokenParts := len(strings.Split(token.AccessToken, "."))
|
||||
logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d",
|
||||
userInfo.Email(), tokenParts)
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlookPersonal,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
action := "outlook-personal:" + userInfo.Email()
|
||||
if !created {
|
||||
action = "outlook-personal-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
"github.com/ghostersk/gowebmail/internal/smime"
|
||||
)
|
||||
|
||||
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
|
||||
// PGP contact keys the sending account/user actually has on file. Implements
|
||||
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
|
||||
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
|
||||
// design: "S/MIME certificates sign... PGP keys encrypt...".
|
||||
type dbSigner struct {
|
||||
db *db.DB
|
||||
userID int64
|
||||
}
|
||||
|
||||
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
|
||||
out := raw
|
||||
|
||||
identities, err := s.db.ListSMIMEIdentities(account.ID)
|
||||
if err == nil && len(identities) > 0 {
|
||||
id := identities[0]
|
||||
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smime sign: %w", err)
|
||||
}
|
||||
out = signed
|
||||
}
|
||||
|
||||
if len(recipients) > 0 {
|
||||
var pgpEntities []*openpgp.Entity
|
||||
allHaveKeys := true
|
||||
for _, addr := range recipients {
|
||||
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
|
||||
if err != nil || contact == nil {
|
||||
allHaveKeys = false
|
||||
break
|
||||
}
|
||||
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
|
||||
if err != nil {
|
||||
allHaveKeys = false
|
||||
break
|
||||
}
|
||||
pgpEntities = append(pgpEntities, entity)
|
||||
}
|
||||
if allHaveKeys && len(pgpEntities) > 0 {
|
||||
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgp encrypt: %w", err)
|
||||
}
|
||||
out = encrypted
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
|
||||
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
|
||||
func (h *APIHandler) newSigner(userID int64) *dbSigner {
|
||||
return &dbSigner{db: h.db, userID: userID}
|
||||
}
|
||||
|
||||
// ---- S/MIME handlers ----
|
||||
|
||||
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identities, err := h.db.ListSMIMEIdentities(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, identities)
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
return
|
||||
}
|
||||
if !h.ownAccount(w, r, req.AccountID) {
|
||||
return
|
||||
}
|
||||
account, _ := h.db.GetAccount(req.AccountID)
|
||||
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
|
||||
return
|
||||
}
|
||||
cert, _ := smime.ParseCertPEM(certPEM)
|
||||
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
||||
accountID = a
|
||||
}
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("p12_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "p12_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
password := r.FormValue("p12_password")
|
||||
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
||||
return
|
||||
}
|
||||
cert, _ := smime.ParseCertPEM(certPEM)
|
||||
notAfter := time.Now().Add(smime.DefaultValidity)
|
||||
if cert != nil {
|
||||
notAfter = cert.NotAfter
|
||||
}
|
||||
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
contacts, err := h.db.ListSMIMEContacts(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, contacts)
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "email required")
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("cert_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "cert_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
if _, err := smime.ParseCertPEM(data); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// ---- PGP handlers ----
|
||||
|
||||
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identities, err := h.db.ListPGPIdentities(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, identities)
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Label string `json:"label"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Confirm string `json:"passphrase_confirm"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
return
|
||||
}
|
||||
if !h.ownAccount(w, r, req.AccountID) {
|
||||
return
|
||||
}
|
||||
if len(req.Passphrase) < 8 {
|
||||
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if req.Passphrase != req.Confirm {
|
||||
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
|
||||
return
|
||||
}
|
||||
account, _ := h.db.GetAccount(req.AccountID)
|
||||
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
|
||||
return
|
||||
}
|
||||
entity, _ := pgp.ParsePublicKey(pubArmor)
|
||||
fingerprint := ""
|
||||
if entity != nil {
|
||||
fingerprint = pgp.Fingerprint(entity)
|
||||
}
|
||||
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
||||
accountID = a
|
||||
}
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
passphrase := r.FormValue("passphrase")
|
||||
label := r.FormValue("label")
|
||||
file, _, err := r.FormFile("key_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "key_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
||||
return
|
||||
}
|
||||
entity, _ := pgp.ParsePublicKey(pubArmor)
|
||||
email, fingerprint := "", ""
|
||||
if entity != nil {
|
||||
fingerprint = pgp.Fingerprint(entity)
|
||||
for name := range entity.Identities {
|
||||
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
|
||||
email = id.UserId.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
account, _ := h.db.GetAccount(accountID)
|
||||
if email == "" && account != nil {
|
||||
email = account.EmailAddress
|
||||
}
|
||||
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
|
||||
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
|
||||
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
|
||||
// logout (AuthHandler.Logout).
|
||||
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IdentityID int64 `json:"identity_id"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "identity_id required")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
|
||||
if err != nil || identity == nil {
|
||||
h.writeError(w, http.StatusNotFound, "identity not found")
|
||||
return
|
||||
}
|
||||
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
|
||||
return
|
||||
}
|
||||
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
|
||||
return
|
||||
}
|
||||
if h.pgpCache != nil {
|
||||
if cookie, err := r.Cookie("gomail_session"); err == nil {
|
||||
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
contacts, err := h.db.ListPGPContacts(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, contacts)
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "email required")
|
||||
return
|
||||
}
|
||||
label := r.FormValue("label")
|
||||
file, _, err := r.FormFile("key_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "key_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
entity, err := pgp.ParsePublicKey(data)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeletePGPContact(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,14 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
|
||||
log.Fatalf("failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
// Shared unlocked-PGP-key cache: populated by APIHandler.PGPUnlock, cleared by
|
||||
// AuthHandler.Logout. No TTL — memory-bounded by active sessions (see internal/pgp.Cache).
|
||||
pgpCache := pgp.NewCache()
|
||||
|
||||
return &Handlers{
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc, pgpCache: pgpCache},
|
||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc, pgpCache: pgpCache},
|
||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
||||
"login.html",
|
||||
"mfa.html",
|
||||
"admin.html",
|
||||
"message.html",
|
||||
"compose.html",
|
||||
}
|
||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
var validRuleFields = map[string]bool{
|
||||
"from": true, "to": true, "subject": true, "body": true, "has_attachment": true, "recipient_type": true,
|
||||
}
|
||||
var validRuleOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||
var validRuleActions = map[string]bool{
|
||||
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
|
||||
}
|
||||
|
||||
// ownAccount verifies accountID belongs to the current user, writing a 404 and returning false if not.
|
||||
func (h *APIHandler) ownAccount(w http.ResponseWriter, r *http.Request, accountID int64) bool {
|
||||
userID := middleware.GetUserID(r)
|
||||
account, err := h.db.GetAccount(accountID)
|
||||
if err != nil || account == nil || account.UserID != userID {
|
||||
h.writeError(w, http.StatusNotFound, "account not found")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Rules ----
|
||||
|
||||
func (h *APIHandler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
rules, err := h.db.ListRules(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list rules")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, rules)
|
||||
}
|
||||
|
||||
func validateRule(r *models.Rule) string {
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
return "name required"
|
||||
}
|
||||
if len(r.Conditions) == 0 {
|
||||
return "at least one condition required"
|
||||
}
|
||||
for _, c := range r.Conditions {
|
||||
if !validRuleFields[c.Field] {
|
||||
return "invalid condition field: " + c.Field
|
||||
}
|
||||
if !validRuleOps[c.Op] {
|
||||
return "invalid condition op: " + c.Op
|
||||
}
|
||||
if strings.TrimSpace(c.Value) == "" {
|
||||
return "condition value required"
|
||||
}
|
||||
}
|
||||
if r.MatchType != "any" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
if !validRuleActions[r.Action] {
|
||||
return "invalid action: " + r.Action
|
||||
}
|
||||
if r.Action == "move_to_folder" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "folder name required for move_to_folder"
|
||||
}
|
||||
if r.Action == "forward" && !strings.Contains(r.ActionValue, "@") {
|
||||
return "valid forward address required"
|
||||
}
|
||||
if r.Action == "auto_reply" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "auto-reply subject required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateRule(&req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetRule(req.AccountID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.db.UpdateRule(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteRule(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
)
|
||||
|
||||
func (h *APIHandler) ListSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
sigs, err := h.db.ListSignatures(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list signatures")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, sigs)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateSignature(userID, req.Name, req.ContentHTML)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetSignature(userID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateSignature(userID, id, req.Name, req.ContentHTML); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteSignature(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// SetSignatureDefaults sets which signature (if any) is default-for-new / default-for-reply
|
||||
// on one account. A ProviderID of 0 in the request clears that default.
|
||||
func (h *APIHandler) SetSignatureDefaults(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := pathInt64(r, "id")
|
||||
if !h.ownAccount(w, r, accountID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DefaultNewID int64 `json:"default_new_id"`
|
||||
DefaultReplyID int64 `json:"default_reply_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
// A signature id of 0 is "clear this default" — otherwise verify the user actually owns it.
|
||||
userID := middleware.GetUserID(r)
|
||||
if req.DefaultNewID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultNewID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_new_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.DefaultReplyID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultReplyID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_reply_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.db.SetSignatureDefaults(accountID, req.DefaultNewID, req.DefaultReplyID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to set defaults")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Package jmap is a minimal JMAP (RFC 8620 Core + RFC 8621 Mail) client for
|
||||
// ProviderJMAP accounts — an alternative to IMAP/SMTP for mail servers that
|
||||
// speak JMAP instead. It follows internal/graph's shape (a thin REST/JSON
|
||||
// wrapper), since both are HTTP+JSON providers unlike IMAP's binary protocol.
|
||||
//
|
||||
// Authenticated via HTTP Basic (mailbox email + app password), matching the
|
||||
// reference server this was built against — see tests/jmap-client.md.
|
||||
// One HTTP call per JMAP method call: no request batching or back-references,
|
||||
// since sync here isn't latency-sensitive enough to justify that complexity.
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps JMAP API calls for a single mailbox account.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
accountID string // resolved lazily from /jmap/session
|
||||
apiURL string
|
||||
uploadURL string
|
||||
}
|
||||
|
||||
// New creates a JMAP client. baseURL is the server's base URL, e.g.
|
||||
// "https://mail.example.com:8443" (no trailing slash needed).
|
||||
func New(baseURL, username, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
username: username,
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
// Force HTTP/1.1: the reference server (tests/jmap-client.md)
|
||||
// closes the connection with no response over HTTP/2 — verified
|
||||
// live (curl negotiates h2 by default and gets a broken pipe;
|
||||
// --http1.1 works). TLSNextProto disables Go's automatic h2 ALPN
|
||||
// upgrade for HTTPS requests.
|
||||
Transport: &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doReq(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
url := path
|
||||
if !strings.HasPrefix(path, "http") {
|
||||
url = c.baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("jmap %s %s returned %d: %s", method, path, resp.StatusCode, string(b))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Session is the RFC 8620 §2 session resource.
|
||||
type Session struct {
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
Username string `json:"username"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// Session fetches /jmap/session and resolves the mail account id + API/upload
|
||||
// URLs. Also serves as a pure connectivity/auth test (used by TestConnection).
|
||||
func (c *Client) Session(ctx context.Context) (*Session, error) {
|
||||
resp, err := c.doReq(ctx, http.MethodGet, "/jmap/session", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var s Session
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("decode jmap session: %w", err)
|
||||
}
|
||||
c.accountID = s.PrimaryAccounts["urn:ietf:params:jmap:mail"]
|
||||
if c.accountID == "" {
|
||||
return nil, fmt.Errorf("jmap session: no mail account found")
|
||||
}
|
||||
c.apiURL = s.APIURL
|
||||
c.uploadURL = strings.ReplaceAll(s.UploadURL, "{accountId}", c.accountID)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureSession(ctx context.Context) error {
|
||||
if c.accountID != "" {
|
||||
return nil
|
||||
}
|
||||
_, err := c.Session(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
type apiRequest struct {
|
||||
Using []string `json:"using"`
|
||||
MethodCalls [][3]interface{} `json:"methodCalls"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
MethodResponses [][]json.RawMessage `json:"methodResponses"`
|
||||
}
|
||||
|
||||
// call makes a single JMAP method call and decodes its result args into out
|
||||
// (which may be nil if the caller doesn't need the response body).
|
||||
func (c *Client) call(ctx context.Context, method string, args map[string]interface{}, out interface{}) error {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
body := apiRequest{
|
||||
Using: []string{
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
},
|
||||
MethodCalls: [][3]interface{}{{method, args, "c1"}},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.apiURL, bytes.NewReader(b), "application/json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var ar apiResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
|
||||
return fmt.Errorf("decode jmap response: %w", err)
|
||||
}
|
||||
if len(ar.MethodResponses) == 0 || len(ar.MethodResponses[0]) < 2 {
|
||||
return fmt.Errorf("jmap %s: empty or malformed response", method)
|
||||
}
|
||||
first := ar.MethodResponses[0]
|
||||
var name string
|
||||
json.Unmarshal(first[0], &name)
|
||||
if name == "error" {
|
||||
return fmt.Errorf("jmap %s error: %s", method, string(first[1]))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(first[1], out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) withAccount(args map[string]interface{}) map[string]interface{} {
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
args["accountId"] = c.accountID
|
||||
return args
|
||||
}
|
||||
|
||||
// ---- Mailboxes ----
|
||||
|
||||
// Mailbox is a JMAP folder.
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parentId"`
|
||||
Role string `json:"role"` // "inbox","sent","drafts","trash","junk", or "" for custom folders
|
||||
TotalEmails int `json:"totalEmails"`
|
||||
UnreadEmails int `json:"unreadEmails"`
|
||||
}
|
||||
|
||||
// InferFolderType maps a JMAP Mailbox role to gowebmail's folder type.
|
||||
func InferFolderType(role string) string {
|
||||
switch role {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "trash":
|
||||
return "trash"
|
||||
case "junk":
|
||||
return "spam"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// ListMailboxes returns every mailbox (folder) for the account.
|
||||
func (c *Client) ListMailboxes(ctx context.Context) ([]Mailbox, error) {
|
||||
var out struct {
|
||||
List []Mailbox `json:"list"`
|
||||
}
|
||||
if err := c.call(ctx, "Mailbox/get", c.withAccount(nil), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// FindMailboxByRole returns the id of the mailbox with the given role (e.g.
|
||||
// "sent", "inbox"), or an error if none is found.
|
||||
func (c *Client) FindMailboxByRole(ctx context.Context, role string) (string, error) {
|
||||
boxes, err := c.ListMailboxes(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range boxes {
|
||||
if b.Role == role {
|
||||
return b.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no mailbox with role %q", role)
|
||||
}
|
||||
|
||||
// ---- Emails ----
|
||||
|
||||
// EmailAddr is a JMAP EmailAddress object.
|
||||
type EmailAddr struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// BodyPart is an entry in an Email's textBody/htmlBody list.
|
||||
type BodyPart struct {
|
||||
PartID string `json:"partId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// BodyValue is the decoded content for one BodyPart, keyed by partId in Email.BodyValues.
|
||||
type BodyValue struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Email is a JMAP message. Keywords/mailboxIds mirror IMAP flags/folder
|
||||
// membership, except a message here lives in exactly one mailbox (see
|
||||
// tests/jmap-client.md — "single-mailbox membership").
|
||||
type Email struct {
|
||||
ID string `json:"id"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
Keywords map[string]bool `json:"keywords"`
|
||||
Size int `json:"size"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Subject string `json:"subject"`
|
||||
From []EmailAddr `json:"from"`
|
||||
To []EmailAddr `json:"to"`
|
||||
Preview string `json:"preview"`
|
||||
HasAttachment bool `json:"hasAttachment"`
|
||||
TextBody []BodyPart `json:"textBody"`
|
||||
HTMLBody []BodyPart `json:"htmlBody"`
|
||||
BodyValues map[string]BodyValue `json:"bodyValues"`
|
||||
}
|
||||
|
||||
func (e *Email) FromName() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Name
|
||||
}
|
||||
|
||||
func (e *Email) FromEmail() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Email
|
||||
}
|
||||
|
||||
func (e *Email) ToList() string {
|
||||
parts := make([]string, 0, len(e.To))
|
||||
for _, t := range e.To {
|
||||
parts = append(parts, t.Email)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (e *Email) IsRead() bool { return e.Keywords["$seen"] }
|
||||
func (e *Email) IsFlagged() bool { return e.Keywords["$flagged"] }
|
||||
|
||||
// TextValue returns the plain-text body, if fetched via GetEmailBody.
|
||||
func (e *Email) TextValue() string {
|
||||
for _, p := range e.TextBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HTMLValue returns the HTML body, if fetched via GetEmailBody.
|
||||
func (e *Email) HTMLValue() string {
|
||||
for _, p := range e.HTMLBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListEmails returns cheap-field emails in mailboxID. Newest-first order is
|
||||
// not guaranteed (the reference server's Email/query sort support is
|
||||
// undocumented — see tests/jmap-client.md — so no sort is requested; callers
|
||||
// that need a specific order should sort client-side).
|
||||
func (c *Client) ListEmails(ctx context.Context, mailboxID string, limit int) ([]Email, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var qout struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
qargs := c.withAccount(map[string]interface{}{
|
||||
"filter": map[string]string{"inMailbox": mailboxID},
|
||||
"limit": limit,
|
||||
})
|
||||
if err := c.call(ctx, "Email/query", qargs, &qout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qout.IDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return c.GetEmails(ctx, qout.IDs, false)
|
||||
}
|
||||
|
||||
// GetEmails fetches full Email objects for ids. withBody also fetches
|
||||
// text/html body content (an expensive decrypt+MIME-parse server-side).
|
||||
func (c *Client) GetEmails(ctx context.Context, ids []string, withBody bool) ([]Email, error) {
|
||||
var out struct {
|
||||
List []Email `json:"list"`
|
||||
}
|
||||
args := c.withAccount(map[string]interface{}{"ids": ids})
|
||||
if withBody {
|
||||
args["fetchTextBodyValues"] = true
|
||||
args["fetchHTMLBodyValues"] = true
|
||||
}
|
||||
if err := c.call(ctx, "Email/get", args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// GetEmailBody fetches a single email with its full text/html body.
|
||||
func (c *Client) GetEmailBody(ctx context.Context, id string) (*Email, error) {
|
||||
list, err := c.GetEmails(ctx, []string{id}, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("email %s not found", id)
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// SetKeyword sets or clears a single keyword (e.g. "$seen", "$flagged") on a message.
|
||||
func (c *Client) SetKeyword(ctx context.Context, emailID, keyword string, on bool) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"keywords/" + keyword: on},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap keyword update rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveEmail reassigns a message to a different (single) mailbox.
|
||||
func (c *Client) MoveEmail(ctx context.Context, emailID, destMailboxID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"mailboxIds": map[string]bool{destMailboxID: true}},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap move rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEmail hard-deletes a message. Unlike Mailbox/set destroy (soft, see
|
||||
// tests/jmap-client.md), Email/set destroy is a real, unrecoverable delete.
|
||||
func (c *Client) DeleteEmail(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{"destroy": []string{emailID}})
|
||||
var out struct {
|
||||
NotDestroyed map[string]json.RawMessage `json:"notDestroyed"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotDestroyed[emailID]; bad {
|
||||
return fmt.Errorf("jmap delete rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Sending ----
|
||||
|
||||
// UploadBlob uploads raw message bytes and returns the blob id.
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte) (string, error) {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.uploadURL, bytes.NewReader(data), "message/rfc822")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
BlobID string `json:"blobId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("decode jmap upload response: %w", err)
|
||||
}
|
||||
return out.BlobID, nil
|
||||
}
|
||||
|
||||
// ImportEmail imports an uploaded blob as a message into mailboxID, returning
|
||||
// the new email id. There is no Email/set create (see tests/jmap-client.md) —
|
||||
// this upload+import step is the only way to add a message.
|
||||
func (c *Client) ImportEmail(ctx context.Context, blobID, mailboxID string) (string, error) {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"emails": map[string]interface{}{
|
||||
"c1": map[string]interface{}{
|
||||
"blobId": blobID,
|
||||
"mailboxIds": map[string]bool{mailboxID: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
Created map[string]struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"created"`
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/import", args, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created, ok := out.Created["c1"]; ok {
|
||||
return created.ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("jmap import failed: %s", out.NotCreated["c1"])
|
||||
}
|
||||
|
||||
// Submit sends a previously-imported message via EmailSubmission/set.
|
||||
func (c *Client) Submit(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"create": map[string]interface{}{
|
||||
"s1": map[string]interface{}{"emailId": emailID},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "EmailSubmission/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotCreated["s1"]; bad {
|
||||
return fmt.Errorf("jmap submission rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send uploads rawMessage, imports it into mailboxID (typically the Sent
|
||||
// mailbox — the server doesn't auto-file after submission), and submits it
|
||||
// for delivery.
|
||||
func (c *Client) Send(ctx context.Context, mailboxID string, rawMessage []byte) error {
|
||||
blobID, err := c.UploadBlob(ctx, rawMessage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap upload: %w", err)
|
||||
}
|
||||
emailID, err := c.ImportEmail(ctx, blobID, mailboxID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap import: %w", err)
|
||||
}
|
||||
if err := c.Submit(ctx, emailID); err != nil {
|
||||
return fmt.Errorf("jmap submit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package logger provides a conditional debug logger controlled by config.Debug.
|
||||
package logger
|
||||
|
||||
import "log"
|
||||
|
||||
var debugEnabled bool
|
||||
|
||||
// Init sets whether debug logging is active. Call once at startup.
|
||||
func Init(debug bool) {
|
||||
debugEnabled = debug
|
||||
if debug {
|
||||
log.Println("[logger] debug logging enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logs a message only when debug mode is on.
|
||||
func Debug(format string, args ...interface{}) {
|
||||
if debugEnabled {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns true if debug logging is on.
|
||||
func IsEnabled() bool { return debugEnabled }
|
||||
@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
|
||||
}
|
||||
|
||||
// OTPAuthURL builds an otpauth:// URI for QR code generation.
|
||||
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
|
||||
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
|
||||
func OTPAuthURL(issuer, accountName, secret string) string {
|
||||
v := url.Values{}
|
||||
v.Set("secret", secret)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Package middleware provides HTTP middleware for GoMail.
|
||||
// Package middleware provides HTTP middleware for GoWebMail.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,7 +13,9 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/notify"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||
if role != models.RoleAdmin {
|
||||
if isAPIPath(r) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `{"error":"forbidden"}`)
|
||||
} else {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"You don't have permission to access this page. Admin privileges are required.")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
|
||||
// It must be called with the raw handler so it can intercept BEFORE auth.
|
||||
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
|
||||
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
|
||||
if cfg.IsIPWhitelisted(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve country for geo-block and attempt recording.
|
||||
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
|
||||
geoResult := geo.Lookup(ip)
|
||||
|
||||
// --- Geo block (apply to all requests) ---
|
||||
if geoResult.CountryCode != "" {
|
||||
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
|
||||
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"Access from your country is not permitted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.BruteEnabled || r.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
if database.IsIPBlocked(ip) {
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"IP Address Blocked",
|
||||
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap the response writer to detect a failed login (redirect to error vs success)
|
||||
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Determine success: a redirect away from login = success
|
||||
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
|
||||
username := r.FormValue("username")
|
||||
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
|
||||
|
||||
if !success && !rw.skipBrute {
|
||||
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
|
||||
if failures >= cfg.BruteMaxAttempts {
|
||||
reason := "Too many failed logins"
|
||||
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
|
||||
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
|
||||
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
|
||||
|
||||
// Send security notification to the targeted user (non-blocking goroutine)
|
||||
go func(targetUsername string) {
|
||||
user, _ := database.GetUserByUsername(targetUsername)
|
||||
if user == nil {
|
||||
user, _ = database.GetUserByEmail(targetUsername)
|
||||
}
|
||||
if user != nil && user.Email != "" {
|
||||
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
|
||||
Username: user.Username,
|
||||
ToEmail: user.Email,
|
||||
AttackerIP: ip,
|
||||
Country: geoResult.Country,
|
||||
CountryCode: geoResult.CountryCode,
|
||||
Attempts: failures,
|
||||
BlockedAt: time.Now().UTC(),
|
||||
BanHours: cfg.BruteBanHours,
|
||||
Hostname: cfg.Hostname,
|
||||
})
|
||||
}
|
||||
}(username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
|
||||
type loginResponseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
location string
|
||||
skipBrute bool
|
||||
}
|
||||
|
||||
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||
lrc.statusCode = code
|
||||
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||
if lrc.Header().Get("X-Skip-Brute") == "1" {
|
||||
lrc.skipBrute = true
|
||||
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
|
||||
}
|
||||
lrc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
|
||||
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
renderErrorPage(w, r, status, title, message)
|
||||
}
|
||||
|
||||
// renderErrorPage writes a themed HTML error page for browser requests,
|
||||
// or a JSON error for API paths.
|
||||
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
if isAPIPath(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||
return
|
||||
}
|
||||
// Back-button destination: always send to "/" which RequireAuth will
|
||||
// transparently forward to /auth/login if the session is absent or invalid.
|
||||
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
|
||||
backHref := "/"
|
||||
backLabel := "← Go Back"
|
||||
|
||||
data := struct {
|
||||
Status int
|
||||
Title string
|
||||
Message string
|
||||
BackHref string
|
||||
BackLabel string
|
||||
}{status, title, message, backHref, backLabel}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := errorPageTmpl.Execute(w, data); err != nil {
|
||||
// Last-resort plain text fallback
|
||||
fmt.Fprintf(w, "%d %s: %s", status, title, message)
|
||||
}
|
||||
}
|
||||
|
||||
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Status}} – {{.Title}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg, #18191b);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
.error-card {
|
||||
background: var(--surface, #232428);
|
||||
border: 1px solid var(--border, #2e2f34);
|
||||
border-radius: 16px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||
}
|
||||
.error-code {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #6b8afd);
|
||||
line-height: 1;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e9ed);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #8b8d97);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.error-back {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: var(--accent, #6b8afd);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.error-back:hover { opacity: .85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-card">
|
||||
<div class="error-code">{{.Status}}</div>
|
||||
<h1 class="error-title">{{.Title}}</h1>
|
||||
<p class="error-message">{{.Message}}</p>
|
||||
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
+172
-6
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
// UserRole controls access level within GoMail.
|
||||
// UserRole controls access level within GoWebMail.
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
@@ -12,7 +12,7 @@ const (
|
||||
RoleUser UserRole = "user"
|
||||
)
|
||||
|
||||
// User represents a GoMail application user.
|
||||
// User represents a GoWebMail application user.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -83,9 +83,11 @@ type AuditPage struct {
|
||||
type AccountProvider string
|
||||
|
||||
const (
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
ProviderJMAP AccountProvider = "jmap" // generic JMAP (RFC 8620/8621) server
|
||||
)
|
||||
|
||||
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
||||
@@ -99,11 +101,18 @@ type EmailAccount struct {
|
||||
AccessToken string `json:"-"`
|
||||
RefreshToken string `json:"-"`
|
||||
TokenExpiry time.Time `json:"-"`
|
||||
// IMAP/SMTP settings (optional, stored encrypted)
|
||||
// IMAP/SMTP settings (optional, stored encrypted).
|
||||
// For ProviderJMAP accounts, IMAPHost holds the JMAP server base URL
|
||||
// (e.g. "https://mail.example.com:8443") and AccessToken holds the app
|
||||
// password — IMAPPort/SMTPHost/SMTPPort are unused for that provider.
|
||||
IMAPHost string `json:"imap_host,omitempty"`
|
||||
IMAPPort int `json:"imap_port,omitempty"`
|
||||
SMTPHost string `json:"smtp_host,omitempty"`
|
||||
SMTPPort int `json:"smtp_port,omitempty"`
|
||||
// CalDAV/CardDAV sync — optional, works alongside any provider above.
|
||||
// Blank = disabled. Uses EmailAddress + AccessToken for HTTP basic auth.
|
||||
CalDAVURL string `json:"caldav_url,omitempty"`
|
||||
CardDAVURL string `json:"carddav_url,omitempty"`
|
||||
// Sync settings
|
||||
SyncDays int `json:"sync_days"` // how many days back to fetch (0 = all)
|
||||
SyncMode string `json:"sync_mode"` // "days" or "all"
|
||||
@@ -113,9 +122,20 @@ type EmailAccount struct {
|
||||
// Display
|
||||
Color string `json:"color"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
LastSync time.Time `json:"last_sync"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
// Label is a user-defined organizational tag, local to gowebmail (not synced to the mail
|
||||
// provider — labels don't have a reliable cross-provider equivalent: Gmail's are IMAP-
|
||||
// extension-specific, Outlook's Categories need the Graph API, plain IMAP has none).
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"` // hex, e.g. "#5b8def"
|
||||
}
|
||||
|
||||
// Folder represents a mailbox folder or Gmail label.
|
||||
type Folder struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -178,6 +198,7 @@ type Message struct {
|
||||
IsDraft bool `json:"is_draft"`
|
||||
HasAttachment bool `json:"has_attachment"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Labels []Label `json:"labels,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -197,6 +218,8 @@ type MessageSummary struct {
|
||||
IsRead bool `json:"is_read"`
|
||||
IsStarred bool `json:"is_starred"`
|
||||
HasAttachment bool `json:"has_attachment"`
|
||||
Size int64 `json:"size,omitempty"` // approximate; only populated by search results
|
||||
Labels []Label `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// ---- Compose ----
|
||||
@@ -213,6 +236,12 @@ type ComposeRequest struct {
|
||||
// For reply/forward
|
||||
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
||||
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
||||
// Attachments: populated from multipart/form-data or inline base64
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
// DraftUID is the IMAP UID of this compose session's previously-autosaved draft (0 if
|
||||
// never saved). A resave deletes that copy before appending the new one, so repeated
|
||||
// autosaves replace the draft in place instead of piling up duplicates.
|
||||
DraftUID uint32 `json:"draft_uid,omitempty"`
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
@@ -239,3 +268,140 @@ type PagedMessages struct {
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// ---- Contacts ----
|
||||
|
||||
type Contact struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
AccountID *int64 `json:"account_id,omitempty"` // set when synced from an account's CardDAV server
|
||||
UID string `json:"uid,omitempty"` // CardDAV UID, or "gwm-..." for locally-created contacts
|
||||
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"`
|
||||
}
|
||||
|
||||
// ---- Rules (filters) ----
|
||||
|
||||
// RuleCondition is one field/op/value test within a Rule.
|
||||
type RuleCondition struct {
|
||||
Field string `json:"field"` // from|to|subject|body|has_attachment|recipient_type
|
||||
Op string `json:"op"` // contains|equals|starts_with
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// RuleActionOptions holds action-specific extra settings, stored as JSON.
|
||||
type RuleActionOptions struct {
|
||||
KeepCopy bool `json:"keep_copy,omitempty"` // forward action
|
||||
Body string `json:"body,omitempty"` // auto_reply action
|
||||
}
|
||||
|
||||
// Rule is a mail filter evaluated against newly-synced messages for one account.
|
||||
type Rule struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Conditions []RuleCondition `json:"conditions"`
|
||||
MatchType string `json:"match_type"` // all|any
|
||||
Action string `json:"action"` // move_to_folder|delete|mark_read|mark_as_spam|forward|auto_reply
|
||||
ActionValue string `json:"action_value"`
|
||||
ActionOptions RuleActionOptions `json:"action_options"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ---- Signatures ----
|
||||
|
||||
type Signature struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// SignatureDefaults maps an account to its default-for-new/default-for-reply signature.
|
||||
type SignatureDefaults struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
DefaultNewID int64 `json:"default_new_id,omitempty"`
|
||||
DefaultReplyID int64 `json:"default_reply_id,omitempty"`
|
||||
}
|
||||
|
||||
// ---- S/MIME ----
|
||||
|
||||
type SMIMEIdentity struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"-"` // never serialized to API responses
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type SMIMEContact struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ---- PGP ----
|
||||
|
||||
type PGPIdentity struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Label string `json:"label"`
|
||||
Email string `json:"email"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKeyArmor string `json:"public_key_armor"`
|
||||
PrivateKeyArmor string `json:"-"` // never serialized to API responses
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type PGPContact struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Label string `json:"label"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKeyArmor string `json:"public_key_armor"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
// Cache holds unlocked (passphrase-decrypted) PGP identities in memory, scoped to the
|
||||
// session that unlocked them — never written to disk. No TTL: memory-bounded by active
|
||||
// sessions, cleared only on explicit logout (see internal/handlers/auth.go Logout).
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
byTok map[string]map[int64]*openpgp.Entity // sessionToken -> identityID -> unlocked entity
|
||||
}
|
||||
|
||||
// NewCache creates an empty unlocked-key cache.
|
||||
func NewCache() *Cache {
|
||||
return &Cache{byTok: make(map[string]map[int64]*openpgp.Entity)}
|
||||
}
|
||||
|
||||
// Get returns the unlocked entity for identityID under sessionToken, if present.
|
||||
func (c *Cache) Get(sessionToken string, identityID int64) (*openpgp.Entity, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
e, ok := m[identityID]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Put stores an unlocked entity under sessionToken.
|
||||
func (c *Cache) Put(sessionToken string, identityID int64, entity *openpgp.Entity) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
m = make(map[int64]*openpgp.Entity)
|
||||
c.byTok[sessionToken] = m
|
||||
}
|
||||
m[identityID] = entity
|
||||
}
|
||||
|
||||
// ClearSession discards every unlocked identity for a session (call on logout).
|
||||
func (c *Cache) ClearSession(sessionToken string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.byTok, sessionToken)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestEncryptMIMERoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("frank@example.com", "hunter2hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte(
|
||||
"Message-ID: <1.frank.example.com@example.com>\r\n" +
|
||||
"From: Frank <frank@example.com>\r\n" +
|
||||
"To: grace@example.com\r\n" +
|
||||
"Subject: Secret\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: quoted-printable\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Grace! This is secret.\r\n")
|
||||
|
||||
encryptedMsg, err := EncryptMIME(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptMIME: %v", err)
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(encryptedMsg))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Secret" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive encryption)", got, "Secret")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/encrypted" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/encrypted", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
if _, err := mr.NextPart(); err != nil { // control part: application/pgp-encrypted, Version: 1
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
armored, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if err := UnlockPrivateKey(privEntity, "hunter2hunter2"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(armored, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
want := "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello, Grace! This is secret.\r\n"
|
||||
if string(decrypted) != want {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
|
||||
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
|
||||
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
|
||||
// "unsafe by design". This package is encryption-only: no PGP signature generation
|
||||
// or verification (S/MIME, internal/smime, handles signing).
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
func defaultConfig() *packet.Config {
|
||||
return &packet.Config{
|
||||
DefaultCipher: packet.CipherAES256, // library default is AES-128
|
||||
RSABits: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
|
||||
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
|
||||
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
|
||||
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
config := defaultConfig()
|
||||
entity, err := openpgp.NewEntity(email, "", email, config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate entity: %w", err)
|
||||
}
|
||||
if err := lockEntity(entity, passphrase); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor, err = serializePrivate(entity, config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
func lockEntity(entity *openpgp.Entity, passphrase string) error {
|
||||
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock primary key: %w", err)
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey == nil {
|
||||
continue
|
||||
}
|
||||
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := entity.Serialize(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
|
||||
// which requires the (now-encrypted) private key and fails once it's locked.
|
||||
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
|
||||
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
|
||||
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
entity, err := ParsePrivateKey(armoredData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Verify the passphrase actually unlocks it before accepting the import.
|
||||
if err := UnlockPrivateKey(entity, passphrase); err != nil {
|
||||
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor = armoredData
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
// ParsePublicKey reads a single armored public key.
|
||||
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
// ParsePrivateKey reads a single armored private key. The key remains locked
|
||||
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
|
||||
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
|
||||
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return nil, fmt.Errorf("no key found in armored data")
|
||||
}
|
||||
return entities[0], nil
|
||||
}
|
||||
|
||||
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
|
||||
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
|
||||
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock primary key: %w", err)
|
||||
}
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
||||
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
|
||||
func Fingerprint(entity *openpgp.Entity) string {
|
||||
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
|
||||
}
|
||||
|
||||
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
|
||||
// recipients' public keys.
|
||||
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
if _, err := pt.Write(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pt.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := aw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptEntity opens an armored PGP message using an already-unlocked identity
|
||||
// (see UnlockPrivateKey).
|
||||
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
|
||||
block, err := armor.Decode(bytes.NewReader(armored))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode armor: %w", err)
|
||||
}
|
||||
keyring := openpgp.EntityList{unlockedIdentity}
|
||||
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
return io.ReadAll(md.UnverifiedBody)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
|
||||
|
||||
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
|
||||
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
|
||||
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
|
||||
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
|
||||
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
|
||||
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
|
||||
// what was encrypted regardless of a trailing CRLF.
|
||||
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted, err := EncryptEntity(entity, recipients)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
|
||||
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
|
||||
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
|
||||
out.Write(encrypted)
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
|
||||
// stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
|
||||
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
|
||||
// Encoding/Content-Disposition headers plus blank line plus body.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("carol@example.com", "correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("the secret message body")
|
||||
encrypted, err := EncryptEntity(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptEntity: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if !privEntity.PrivateKey.Encrypted {
|
||||
t.Fatal("private key should be Encrypted (passphrase-protected) before unlocking")
|
||||
}
|
||||
|
||||
// Wrong passphrase must fail.
|
||||
if err := UnlockPrivateKey(privEntity, "wrong-passphrase"); err == nil {
|
||||
t.Error("UnlockPrivateKey succeeded with wrong passphrase, want error")
|
||||
}
|
||||
|
||||
if err := UnlockPrivateKey(privEntity, "correct-horse-battery-staple"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(encrypted, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
c := NewCache()
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected empty cache miss")
|
||||
}
|
||||
e := &openpgp.Entity{}
|
||||
c.Put("tok1", 1, e)
|
||||
got, ok := c.Get("tok1", 1)
|
||||
if !ok || got != e {
|
||||
t.Fatal("expected cache hit for tok1/1")
|
||||
}
|
||||
c.ClearSession("tok1")
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected cache miss after ClearSession")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package rules implements mail-filter matching: given a message and an account's
|
||||
// active rules (already ordered by priority), find the first rule that matches.
|
||||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
// Condition is one field/op/value test. Mirrors models.RuleCondition but this package
|
||||
// stays free of the models/db dependency so Match is trivially unit-testable.
|
||||
type Condition struct {
|
||||
Field string
|
||||
Op string
|
||||
Value string
|
||||
}
|
||||
|
||||
// MessageFields is the subset of a message's data rules can match against.
|
||||
type MessageFields struct {
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
HasAttachment bool
|
||||
RecipientType string // "to" | "cc" | "bcc"
|
||||
}
|
||||
|
||||
// Rule is one filter: conditions (AND'd or OR'd per MatchType) plus an action.
|
||||
type Rule struct {
|
||||
ID int64
|
||||
Priority int
|
||||
Conditions []Condition
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
Action string
|
||||
ActionValue string
|
||||
ActionOptions map[string]any
|
||||
}
|
||||
|
||||
// Match returns the first rule (by priority, ascending) whose conditions match msg,
|
||||
// or nil if none match. Callers must pass rules pre-filtered to is_active and pre-sorted
|
||||
// by priority ascending (ListActiveRules already does this).
|
||||
func Match(msg MessageFields, activeRules []Rule) *Rule {
|
||||
for i := range activeRules {
|
||||
if ruleMatches(&activeRules[i], msg) {
|
||||
return &activeRules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ruleMatches(r *Rule, msg MessageFields) bool {
|
||||
if len(r.Conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
if r.MatchType == "any" {
|
||||
for _, c := range r.Conditions {
|
||||
if conditionMatches(c, msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// default "all" (AND)
|
||||
for _, c := range r.Conditions {
|
||||
if !conditionMatches(c, msg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionMatches(c Condition, msg MessageFields) bool {
|
||||
var target string
|
||||
switch c.Field {
|
||||
case "from":
|
||||
target = msg.From
|
||||
case "to":
|
||||
target = msg.To
|
||||
case "subject":
|
||||
target = msg.Subject
|
||||
case "body":
|
||||
target = msg.Body
|
||||
case "has_attachment":
|
||||
if msg.HasAttachment {
|
||||
target = "yes"
|
||||
} else {
|
||||
target = "no"
|
||||
}
|
||||
case "recipient_type":
|
||||
target = msg.RecipientType
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return matchOp(c.Op, c.Value, target)
|
||||
}
|
||||
|
||||
func matchOp(op, value, target string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
target = strings.ToLower(target)
|
||||
switch op {
|
||||
case "contains":
|
||||
return value != "" && strings.Contains(target, value)
|
||||
case "equals":
|
||||
return target == value
|
||||
case "starts_with":
|
||||
return value != "" && strings.HasPrefix(target, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
msg := MessageFields{
|
||||
From: "boss@work.com",
|
||||
To: "me@example.com",
|
||||
Subject: "Weekly Report Due",
|
||||
Body: "please see attached",
|
||||
HasAttachment: true,
|
||||
RecipientType: "to",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rules []Rule
|
||||
want string // expected matched rule action value marker, "" for no match
|
||||
}{
|
||||
{
|
||||
name: "single contains condition matches",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "work.com"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "equals is case-insensitive and exact",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "to", Op: "equals", Value: "ME@EXAMPLE.COM"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "starts_with no match",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "subject", Op: "starts_with", Value: "URGENT"}}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type all requires every condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "work.com"},
|
||||
{Field: "subject", Op: "contains", Value: "NOPE"},
|
||||
}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type any needs only one condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "any", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "NOPE"},
|
||||
{Field: "subject", Op: "contains", Value: "Report"},
|
||||
}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "has_attachment field",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "has_attachment", Op: "equals", Value: "yes"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "first matching rule in list order wins, later matching rules ignored",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 5, MatchType: "all", ActionValue: "nope",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "does-not-appear"}}},
|
||||
{ID: 2, Priority: 0, MatchType: "all", ActionValue: "first",
|
||||
Conditions: []Condition{{Field: "to", Op: "contains", Value: "example"}}},
|
||||
{ID: 3, Priority: 10, MatchType: "all", ActionValue: "second",
|
||||
Conditions: []Condition{{Field: "subject", Op: "contains", Value: "Report"}}},
|
||||
},
|
||||
want: "first", // Match trusts caller ordering (ListActiveRules sorts by priority ASC before calling)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Match(msg, tc.rules)
|
||||
gotVal := ""
|
||||
if got != nil {
|
||||
gotVal = got.ActionValue
|
||||
}
|
||||
if gotVal != tc.want {
|
||||
t.Errorf("Match() = %q, want %q", gotVal, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignMIMERoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("dave@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
// CTE deliberately "7bit", not "quoted-printable": Go's mime/multipart.Part.Read
|
||||
// auto-decodes quoted-printable/base64 parts, which would make this test compare
|
||||
// decoded bytes against the raw wire bytes that were actually signed — a test-harness
|
||||
// footgun, not a production concern (a spec-compliant S/MIME verifier signs/checks the
|
||||
// encoded wire octets, never the decoded form).
|
||||
raw := []byte(
|
||||
"Message-ID: <1.dave.example.com@example.com>\r\n" +
|
||||
"From: Dave <dave@example.com>\r\n" +
|
||||
"To: eve@example.com\r\n" +
|
||||
"Subject: Hello\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: 7bit\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Eve!\r\n")
|
||||
|
||||
signed, err := SignMIME(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("SignMIME: %v", err)
|
||||
}
|
||||
|
||||
// Parse it back like a real mail client would: read top-level headers, find the
|
||||
// multipart/signed boundary, split into the two parts, and verify.
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(signed))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Hello" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive signing)", got, "Hello")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/signed" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/signed", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
part1, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part1Headers := "Content-Type: " + part1.Header.Get("Content-Type") + "\r\n"
|
||||
if cte := part1.Header.Get("Content-Transfer-Encoding"); cte != "" {
|
||||
part1Headers += "Content-Transfer-Encoding: " + cte + "\r\n"
|
||||
}
|
||||
part1Body, err := io.ReadAll(part1)
|
||||
if err != nil {
|
||||
t.Fatalf("read first part: %v", err)
|
||||
}
|
||||
entity := append([]byte(part1Headers+"\r\n"), part1Body...)
|
||||
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
sigB64, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigB64)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature base64: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(entity, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.EmailAddresses[0] != "dave@example.com" {
|
||||
t.Errorf("signer = %v, want dave@example.com", signer.EmailAddresses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package smime provides S/MIME certificate generation, signing, and encryption
|
||||
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
|
||||
//
|
||||
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
|
||||
// signature matches the given certificate, not that the certificate is trusted by any
|
||||
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
|
||||
// that want a "known sender" UI hint should compare against the user's own S/MIME
|
||||
// contact address book, not treat a successful Verify as proof of identity.
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
|
||||
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
|
||||
}
|
||||
|
||||
// DefaultValidity is the lifetime used for a freshly self-signed identity.
|
||||
const DefaultValidity = 365 * 24 * time.Hour
|
||||
|
||||
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
|
||||
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: email},
|
||||
EmailAddresses: []string{email},
|
||||
NotBefore: time.Now().Add(-5 * time.Minute),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
|
||||
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
|
||||
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
|
||||
key, cert, err := pkcs12.Decode(data, password)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decode p12: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
|
||||
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid certificate PEM")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
|
||||
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid key PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
|
||||
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sd, err := pkcs7.NewSignedData(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new signed data: %w", err)
|
||||
}
|
||||
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
|
||||
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
|
||||
return nil, fmt.Errorf("add signer: %w", err)
|
||||
}
|
||||
sd.Detach()
|
||||
return sd.Finish()
|
||||
}
|
||||
|
||||
// VerifySigned checks a detached signature against the original content and returns the
|
||||
// signer's certificate. It does NOT validate the certificate against any trust store —
|
||||
// see the package doc comment.
|
||||
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
|
||||
p7, err := pkcs7.Parse(signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse signature: %w", err)
|
||||
}
|
||||
p7.Content = raw
|
||||
if err := p7.Verify(); err != nil {
|
||||
return nil, fmt.Errorf("verify: %w", err)
|
||||
}
|
||||
signer := p7.GetOnlySigner()
|
||||
if signer == nil {
|
||||
return nil, errors.New("no signer certificate found in signature")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
|
||||
// smime-type=enveloped-data) for the given recipient certificates.
|
||||
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
|
||||
return pkcs7.Encrypt(raw, recipients)
|
||||
}
|
||||
|
||||
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
|
||||
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p7, err := pkcs7.Parse(enveloped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse enveloped data: %w", err)
|
||||
}
|
||||
return p7.Decrypt(cert, key)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
|
||||
//
|
||||
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
|
||||
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
|
||||
// above only handle the CMS blob itself.
|
||||
|
||||
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
|
||||
// original message's Content-Type + body become the first part, and a detached CMS
|
||||
// signature over that part becomes the second. All other top-level headers (From, To,
|
||||
// Subject, Date, Message-ID, ...) are preserved unchanged.
|
||||
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
|
||||
// the delimiter, not the signed content — a compliant multipart parser hands back the
|
||||
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
|
||||
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
|
||||
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
|
||||
sig, err := Sign(certPEM, keyPEM, signedContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.Write(signedContent)
|
||||
out.WriteString("\r\n--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
|
||||
out.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
|
||||
out.WriteString(base64Wrap(sig))
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
|
||||
// not stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
|
||||
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
|
||||
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
|
||||
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
|
||||
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
|
||||
func base64Wrap(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
out.WriteString(encoded[i:end])
|
||||
if end < len(encoded) {
|
||||
out.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("alice@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("this is the raw MIME message body")
|
||||
sig, err := Sign(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(raw, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if len(signer.EmailAddresses) == 0 || signer.EmailAddresses[0] != "alice@example.com" {
|
||||
t.Errorf("signer email = %v, want [alice@example.com]", signer.EmailAddresses)
|
||||
}
|
||||
|
||||
// Tamper one byte of the content — verification must fail.
|
||||
tampered := bytes.Clone(raw)
|
||||
tampered[0] ^= 0xFF
|
||||
if _, err := VerifySigned(tampered, sig); err == nil {
|
||||
t.Error("VerifySigned succeeded against tampered content, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("bob@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertPEM: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("secret message body")
|
||||
enveloped, err := Encrypt(raw, []*x509.Certificate{cert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(enveloped, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("Decrypt() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/rules"
|
||||
)
|
||||
|
||||
// matchRule evaluates a message against an account's active rules (as loaded from the DB)
|
||||
// and returns the matching models.Rule (with full action data), or nil if none match.
|
||||
func matchRule(msg *models.Message, accountEmail string, activeRules []models.Rule) *models.Rule {
|
||||
if len(activeRules) == 0 {
|
||||
return nil
|
||||
}
|
||||
engineRules := make([]rules.Rule, 0, len(activeRules))
|
||||
for _, r := range activeRules {
|
||||
conds := make([]rules.Condition, 0, len(r.Conditions))
|
||||
for _, c := range r.Conditions {
|
||||
conds = append(conds, rules.Condition{Field: c.Field, Op: c.Op, Value: c.Value})
|
||||
}
|
||||
engineRules = append(engineRules, rules.Rule{
|
||||
ID: r.ID, Priority: r.Priority, Conditions: conds, MatchType: r.MatchType,
|
||||
Action: r.Action, ActionValue: r.ActionValue,
|
||||
})
|
||||
}
|
||||
mf := rules.MessageFields{
|
||||
From: msg.FromEmail, To: msg.ToList, Subject: msg.Subject, Body: msg.BodyText,
|
||||
HasAttachment: msg.HasAttachment, RecipientType: recipientType(msg, accountEmail),
|
||||
}
|
||||
matched := rules.Match(mf, engineRules)
|
||||
if matched == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range activeRules {
|
||||
if activeRules[i].ID == matched.ID {
|
||||
return &activeRules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recipientType(msg *models.Message, accountEmail string) string {
|
||||
if msg.CCList != "" && containsAddress(msg.CCList, accountEmail) {
|
||||
return "cc"
|
||||
}
|
||||
if msg.BCCList != "" && containsAddress(msg.BCCList, accountEmail) {
|
||||
return "bcc"
|
||||
}
|
||||
return "to"
|
||||
}
|
||||
|
||||
func containsAddress(list, addr string) bool {
|
||||
// list is comma-separated; a substring check is enough since we only use this
|
||||
// to pick a synthetic recipient_type label, not for anything security-relevant.
|
||||
for _, part := range splitAndTrim(list) {
|
||||
if part == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == ',' {
|
||||
out = append(out, trimLower(cur))
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(r)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, trimLower(cur))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimLower(s string) string {
|
||||
// minimal trim, avoids pulling in strings just for this
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func parseUID(s string) uint32 {
|
||||
var uid uint32
|
||||
fmt.Sscanf(s, "%d", &uid)
|
||||
return uid
|
||||
}
|
||||
|
||||
// ---- IMAP path ----
|
||||
|
||||
func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) {
|
||||
uid := parseUID(msg.RemoteUID)
|
||||
switch rule.Action {
|
||||
case "move_to_folder":
|
||||
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
|
||||
if err != nil || dest == nil {
|
||||
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
|
||||
return
|
||||
}
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: dest.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "mark_as_spam":
|
||||
junk, err := s.db.GetFolderByType(account.ID, "spam")
|
||||
if err != nil || junk == nil {
|
||||
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
|
||||
return
|
||||
}
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "delete":
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "delete", RemoteUID: uid, FolderPath: dbFolder.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "mark_read":
|
||||
if err := c.SetFlagByUID(dbFolder.FullPath, uid, `\Seen`, true); err != nil {
|
||||
log.Printf("[rules] mark_read: %v", err)
|
||||
}
|
||||
case "forward":
|
||||
s.ruleForwardIMAP(account, msg, rule.ActionValue)
|
||||
case "auto_reply":
|
||||
s.ruleAutoReplyIMAP(account, msg, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleForwardIMAP(account *models.EmailAccount, msg *models.Message, to string) {
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{to},
|
||||
Subject: "Fwd: " + msg.Subject,
|
||||
BodyHTML: msg.BodyHTML,
|
||||
BodyText: msg.BodyText,
|
||||
}
|
||||
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
|
||||
log.Printf("[rules] forward to %s: %v", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleAutoReplyIMAP(account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
recipient := msg.FromEmail
|
||||
if recipient == "" {
|
||||
return // never reply to a bounce/empty sender — avoids loops
|
||||
}
|
||||
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
|
||||
return
|
||||
}
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{recipient},
|
||||
Subject: rule.ActionValue,
|
||||
BodyText: rule.ActionOptions.Body,
|
||||
BodyHTML: rule.ActionOptions.Body,
|
||||
}
|
||||
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
|
||||
log.Printf("[rules] auto_reply to %s: %v", recipient, err)
|
||||
return
|
||||
}
|
||||
s.db.LogAutoReply(account.ID, rule.ID, recipient)
|
||||
}
|
||||
|
||||
// ---- Graph (personal Outlook.com) path ----
|
||||
// msg.RemoteUID already holds the opaque Graph message ID (set at construction in graphDeltaSync).
|
||||
|
||||
func (s *Scheduler) applyRuleGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
ctx := context.Background()
|
||||
switch rule.Action {
|
||||
case "move_to_folder":
|
||||
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
|
||||
if err != nil || dest == nil {
|
||||
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
|
||||
return
|
||||
}
|
||||
if err := gc.MoveMessage(ctx, msg.RemoteUID, dest.FullPath); err != nil {
|
||||
log.Printf("[rules] graph move: %v", err)
|
||||
}
|
||||
case "mark_as_spam":
|
||||
junk, err := s.db.GetFolderByType(account.ID, "spam")
|
||||
if err != nil || junk == nil {
|
||||
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
|
||||
return
|
||||
}
|
||||
if err := gc.MoveMessage(ctx, msg.RemoteUID, junk.FullPath); err != nil {
|
||||
log.Printf("[rules] graph move: %v", err)
|
||||
}
|
||||
case "delete":
|
||||
if err := gc.DeleteMessage(ctx, msg.RemoteUID); err != nil {
|
||||
log.Printf("[rules] graph delete: %v", err)
|
||||
}
|
||||
case "mark_read":
|
||||
if err := gc.MarkRead(ctx, msg.RemoteUID, true); err != nil {
|
||||
log.Printf("[rules] graph mark_read: %v", err)
|
||||
}
|
||||
case "forward":
|
||||
s.ruleForwardGraph(gc, account, msg, rule.ActionValue)
|
||||
case "auto_reply":
|
||||
s.ruleAutoReplyGraph(gc, account, msg, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleForwardGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, to string) {
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{to},
|
||||
Subject: "Fwd: " + msg.Subject,
|
||||
BodyHTML: msg.BodyHTML,
|
||||
BodyText: msg.BodyText,
|
||||
}
|
||||
if err := gc.SendMail(context.Background(), req); err != nil {
|
||||
log.Printf("[rules] graph forward to %s: %v", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleAutoReplyGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
recipient := msg.FromEmail
|
||||
if recipient == "" {
|
||||
return
|
||||
}
|
||||
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
|
||||
return
|
||||
}
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{recipient},
|
||||
Subject: rule.ActionValue,
|
||||
BodyText: rule.ActionOptions.Body,
|
||||
BodyHTML: rule.ActionOptions.Body,
|
||||
}
|
||||
if err := gc.SendMail(context.Background(), req); err != nil {
|
||||
log.Printf("[rules] graph auto_reply to %s: %v", recipient, err)
|
||||
return
|
||||
}
|
||||
s.db.LogAutoReply(account.ID, rule.ID, recipient)
|
||||
}
|
||||
+552
-8
@@ -9,31 +9,53 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/logger"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/caldav"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/jmap"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// Scheduler coordinates all background sync activity.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// push channels: accountID -> channel to signal "something changed on server"
|
||||
pushMu sync.Mutex
|
||||
pushCh map[int64]chan struct{}
|
||||
|
||||
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||
reconcileCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
reconcileCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerReconcile asks the main loop to immediately check for new accounts.
|
||||
// Safe to call from any goroutine; non-blocking.
|
||||
func (s *Scheduler) TriggerReconcile() {
|
||||
select {
|
||||
case s.reconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +145,13 @@ func (s *Scheduler) mainLoop() {
|
||||
stopWorker(id)
|
||||
}
|
||||
return
|
||||
case <-s.reconcileCh:
|
||||
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
for id := range workers {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||
case <-ticker.C:
|
||||
// Build active IDs map for reconciliation
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
@@ -178,6 +207,15 @@ func (s *Scheduler) reconcileWorkers(
|
||||
func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
log.Printf("[sync] worker started for %s", account.EmailAddress)
|
||||
|
||||
// CalDAV/CardDAV sync is optional and independent of the mail provider above,
|
||||
// so it runs for every account regardless of which branch below is taken.
|
||||
// davWorker no-ops on each tick if neither URL is configured.
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.davWorker(account, stop)
|
||||
}()
|
||||
|
||||
// Fresh account data function (interval can change at runtime)
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
@@ -187,6 +225,18 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
||||
return a
|
||||
}
|
||||
|
||||
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
s.graphWorker(account, stop, push)
|
||||
return
|
||||
}
|
||||
|
||||
// JMAP accounts use a different sync path (REST/JSON, like Graph)
|
||||
if account.Provider == models.ProviderJMAP {
|
||||
s.jmapWorker(account, stop, push)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial sync on startup
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(getAccount())
|
||||
@@ -258,6 +308,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
cancel()
|
||||
if err != nil {
|
||||
@@ -338,6 +389,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||
@@ -349,7 +401,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not connected") {
|
||||
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||
}
|
||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -380,7 +445,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -405,7 +471,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +484,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
||||
storedValidity, lastSeenUID := s.db.GetFolderSyncState(dbFolder.ID)
|
||||
newMessages := 0
|
||||
|
||||
// Fetched once per folder-sync, not per message — rules rarely change mid-sync.
|
||||
activeRules, _ := s.db.ListActiveRules(account.ID)
|
||||
|
||||
// UIDVALIDITY changed = folder was recreated on server; wipe local and re-fetch all
|
||||
if storedValidity != 0 && status.UIDValidity != storedValidity {
|
||||
log.Printf("[sync] UIDVALIDITY changed for %s/%s — full re-sync", account.EmailAddress, dbFolder.FullPath)
|
||||
@@ -446,6 +515,13 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
||||
msg.FolderID = dbFolder.ID
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
newMessages++
|
||||
// Save attachment metadata if any (enables download)
|
||||
if len(msg.Attachments) > 0 && msg.ID > 0 {
|
||||
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
|
||||
}
|
||||
if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
|
||||
s.applyRuleIMAP(c, account, dbFolder, msg, rule)
|
||||
}
|
||||
}
|
||||
uid := uint32(0)
|
||||
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
||||
@@ -475,7 +551,15 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
||||
|
||||
// Save sync state
|
||||
s.db.SetFolderSyncState(dbFolder.ID, status.UIDValidity, maxUID)
|
||||
s.db.UpdateFolderCounts(dbFolder.ID)
|
||||
|
||||
// Use the server's real total/unread counts (STATUS), not just what's synced locally —
|
||||
// with a limited sync_days window, the local messages table only holds a recent subset,
|
||||
// which would otherwise undercount folders that have older mail sitting on the server.
|
||||
if total, unread, cerr := c.GetFolderCounts(dbFolder.FullPath); cerr == nil {
|
||||
s.db.UpdateFolderCountsDirect(dbFolder.ID, int(total), int(unread))
|
||||
} else {
|
||||
s.db.UpdateFolderCounts(dbFolder.ID)
|
||||
}
|
||||
|
||||
return newMessages, nil
|
||||
}
|
||||
@@ -484,6 +568,11 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
||||
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
|
||||
|
||||
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
// Graph/JMAP accounts don't use the IMAP ops queue — their mutations are
|
||||
// applied synchronously in the API handlers instead (see api.go).
|
||||
if account.Provider == models.ProviderOutlookPersonal || account.Provider == models.ProviderJMAP {
|
||||
return
|
||||
}
|
||||
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
||||
if err != nil || len(ops) == 0 {
|
||||
return
|
||||
@@ -492,6 +581,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
||||
@@ -536,6 +626,62 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth token refresh ----
|
||||
|
||||
// ensureFreshToken checks whether an OAuth account's access token is near
|
||||
// expiry and, if so, exchanges the refresh token for a new one, persists it
|
||||
// to the database, and returns a refreshed account pointer.
|
||||
// For non-OAuth accounts (imap_smtp) it is a no-op.
|
||||
func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.EmailAccount {
|
||||
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
|
||||
return account
|
||||
}
|
||||
// Force refresh if Outlook token is opaque (not a JWT — doesn't contain dots).
|
||||
// Opaque tokens (EwAYBOl3... format) are v1.0 tokens that IMAP rejects.
|
||||
// A valid IMAP token is a 3-part JWT: header.payload.signature
|
||||
isOpaque := account.Provider == models.ProviderOutlook &&
|
||||
strings.Count(account.AccessToken, ".") < 2
|
||||
if !auth.IsTokenExpired(account.TokenExpiry) && !isOpaque {
|
||||
return account
|
||||
}
|
||||
if isOpaque {
|
||||
logger.Debug("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress)
|
||||
}
|
||||
if account.RefreshToken == "" {
|
||||
logger.Debug("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress)
|
||||
return account
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
|
||||
ctx,
|
||||
string(account.Provider),
|
||||
account.RefreshToken,
|
||||
s.cfg.BaseURL,
|
||||
s.cfg.GoogleClientID, s.cfg.GoogleClientSecret,
|
||||
s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Debug("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error())
|
||||
return account // return original; connect will fail and log the error
|
||||
}
|
||||
if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
|
||||
logger.Debug("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
|
||||
return account
|
||||
}
|
||||
|
||||
// Re-fetch so the caller gets the updated access token from the DB.
|
||||
refreshed, fetchErr := s.db.GetAccount(account.ID)
|
||||
if fetchErr != nil || refreshed == nil {
|
||||
return account
|
||||
}
|
||||
logger.Debug("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
|
||||
return refreshed
|
||||
}
|
||||
|
||||
// ---- Public API (called by HTTP handlers) ----
|
||||
|
||||
// SyncAccountNow performs an immediate delta sync of one account.
|
||||
@@ -545,7 +691,15 @@ func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) {
|
||||
return 0, fmt.Errorf("account %d not found", accountID)
|
||||
}
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(account)
|
||||
switch account.Provider {
|
||||
case models.ProviderOutlookPersonal:
|
||||
s.graphDeltaSync(account)
|
||||
case models.ProviderJMAP:
|
||||
s.jmapDeltaSync(account)
|
||||
default:
|
||||
s.deltaSync(account)
|
||||
}
|
||||
s.davSync(account)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -560,8 +714,77 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
return 0, fmt.Errorf("folder %d not found", folderID)
|
||||
}
|
||||
|
||||
// Graph accounts use the Graph sync path, not IMAP
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
account = s.ensureFreshToken(account)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
gc := graph.New(account)
|
||||
// Force full resync of this folder by ignoring the since filter
|
||||
msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("graph list messages: %w", err)
|
||||
}
|
||||
n := 0
|
||||
for _, gm := range msgs {
|
||||
msg := &models.Message{
|
||||
AccountID: account.ID,
|
||||
FolderID: folder.ID,
|
||||
RemoteUID: gm.ID,
|
||||
MessageID: gm.InternetMessageID,
|
||||
Subject: gm.Subject,
|
||||
FromName: gm.FromName(),
|
||||
FromEmail: gm.FromEmail(),
|
||||
ToList: gm.ToList(),
|
||||
Date: gm.ReceivedDateTime,
|
||||
IsRead: gm.IsRead,
|
||||
IsStarred: gm.IsFlagged(),
|
||||
HasAttachment: gm.HasAttachments,
|
||||
}
|
||||
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// Update folder counts
|
||||
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// JMAP accounts use the JMAP sync path, not IMAP
|
||||
if account.Provider == models.ProviderJMAP {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
|
||||
msgs, err := jc.ListEmails(ctx, folder.FullPath, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("jmap list emails: %w", err)
|
||||
}
|
||||
n := 0
|
||||
for _, jm := range msgs {
|
||||
msg := &models.Message{
|
||||
AccountID: account.ID,
|
||||
FolderID: folder.ID,
|
||||
RemoteUID: jm.ID,
|
||||
Subject: jm.Subject,
|
||||
FromName: jm.FromName(),
|
||||
FromEmail: jm.FromEmail(),
|
||||
ToList: jm.ToList(),
|
||||
Date: jm.ReceivedAt,
|
||||
IsRead: jm.IsRead(),
|
||||
IsStarred: jm.IsFlagged(),
|
||||
HasAttachment: jm.HasAttachment,
|
||||
}
|
||||
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
account = s.ensureFreshToken(account)
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -570,3 +793,324 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
|
||||
return s.syncFolder(c, account, folder)
|
||||
}
|
||||
|
||||
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
|
||||
|
||||
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
|
||||
// It polls Graph API instead of using IMAP.
|
||||
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
logger.Debug("[graph] worker started for %s", account.EmailAddress)
|
||||
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
s.graphDeltaSync(getAccount())
|
||||
|
||||
syncTicker := time.NewTicker(30 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
|
||||
return
|
||||
case <-push:
|
||||
acc := getAccount()
|
||||
s.graphDeltaSync(acc)
|
||||
case <-syncTicker.C:
|
||||
acc := getAccount()
|
||||
// Respect sync interval
|
||||
if !acc.LastSync.IsZero() {
|
||||
interval := time.Duration(acc.SyncInterval) * time.Minute
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Minute
|
||||
}
|
||||
if time.Since(acc.LastSync) < interval {
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.graphDeltaSync(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
|
||||
// as the IMAP sync path, so the rest of the app works unchanged.
|
||||
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
|
||||
account = s.ensureFreshToken(account)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
gc := graph.New(account)
|
||||
|
||||
// Fetch folders
|
||||
gFolders, err := gc.ListFolders(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.db.ClearAccountError(account.ID)
|
||||
|
||||
totalNew := 0
|
||||
for _, gf := range gFolders {
|
||||
folderType := graph.InferFolderType(gf.DisplayName)
|
||||
dbFolder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: gf.DisplayName,
|
||||
FullPath: gf.ID, // Graph uses opaque IDs as folder path
|
||||
FolderType: folderType,
|
||||
UnreadCount: gf.UnreadCount,
|
||||
TotalCount: gf.TotalCount,
|
||||
SyncEnabled: true,
|
||||
}
|
||||
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||
continue
|
||||
}
|
||||
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
|
||||
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch latest messages — no since filter, rely on upsert idempotency.
|
||||
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
|
||||
// making date-based filters unreliable across folder types.
|
||||
// Fetching top 100 newest per folder per sync is efficient enough.
|
||||
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetched once per folder-sync, not per message.
|
||||
activeRules, _ := s.db.ListActiveRules(account.ID)
|
||||
|
||||
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++
|
||||
// NOTE: msg.BodyText is never populated here (body is fetched lazily on open,
|
||||
// by design, for perf) — a rule's "body" condition never matches on this path.
|
||||
if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
|
||||
s.applyRuleGraph(gc, account, msg, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JMAP sync ----
|
||||
// jmapWorker is the accountWorker equivalent for ProviderJMAP accounts. It
|
||||
// polls the JMAP server instead of using IMAP — mirrors graphWorker, since
|
||||
// both are REST/JSON providers with no IMAP-style IDLE connection to hold open.
|
||||
|
||||
func (s *Scheduler) jmapWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
logger.Debug("[jmap] worker started for %s", account.EmailAddress)
|
||||
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
s.jmapDeltaSync(getAccount())
|
||||
|
||||
syncTicker := time.NewTicker(30 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
logger.Debug("[jmap] worker stopped for %s", account.EmailAddress)
|
||||
return
|
||||
case <-push:
|
||||
s.jmapDeltaSync(getAccount())
|
||||
case <-syncTicker.C:
|
||||
acc := getAccount()
|
||||
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.jmapDeltaSync(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jmapDeltaSync fetches mail via JMAP and stores it in the same DB tables as
|
||||
// the IMAP/Graph sync paths, so the rest of the app works unchanged.
|
||||
// account.IMAPHost holds the JMAP server base URL (see models.EmailAccount).
|
||||
func (s *Scheduler) jmapDeltaSync(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
|
||||
|
||||
boxes, err := jc.ListMailboxes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[jmap:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, "JMAP error: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.db.ClearAccountError(account.ID)
|
||||
|
||||
totalNew := 0
|
||||
for _, mb := range boxes {
|
||||
folderType := jmap.InferFolderType(mb.Role)
|
||||
dbFolder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: mb.Name,
|
||||
FullPath: mb.ID, // JMAP uses opaque IDs as folder path, like Graph
|
||||
FolderType: folderType,
|
||||
UnreadCount: mb.UnreadEmails,
|
||||
TotalCount: mb.TotalEmails,
|
||||
SyncEnabled: true,
|
||||
}
|
||||
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||
continue
|
||||
}
|
||||
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, mb.ID)
|
||||
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch latest messages — no since filter, rely on upsert idempotency,
|
||||
// same approach as graphDeltaSync (JMAP's Email/query sort isn't
|
||||
// documented as supported — see tests/jmap-client.md).
|
||||
msgs, err := jc.ListEmails(ctx, mb.ID, 100)
|
||||
if err != nil {
|
||||
log.Printf("[jmap:%s] list emails in %s: %v", account.EmailAddress, mb.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, jm := range msgs {
|
||||
// Body is NOT included in list response — fetched lazily on first
|
||||
// open, same as Graph's lazy-body pattern.
|
||||
msg := &models.Message{
|
||||
AccountID: account.ID,
|
||||
FolderID: dbFolderSaved.ID,
|
||||
RemoteUID: jm.ID,
|
||||
Subject: jm.Subject,
|
||||
FromName: jm.FromName(),
|
||||
FromEmail: jm.FromEmail(),
|
||||
ToList: jm.ToList(),
|
||||
Date: jm.ReceivedAt,
|
||||
IsRead: jm.IsRead(),
|
||||
IsStarred: jm.IsFlagged(),
|
||||
HasAttachment: jm.HasAttachment,
|
||||
}
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
totalNew++
|
||||
}
|
||||
}
|
||||
|
||||
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, mb.TotalEmails, mb.UnreadEmails)
|
||||
}
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
logger.Debug("[jmap:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CalDAV/CardDAV sync ----
|
||||
// Optional per-account add-on, independent of the mail provider (IMAP/JMAP/Graph).
|
||||
// Pull-only: mirrors the remote calendar/address book into the local DB.
|
||||
|
||||
func (s *Scheduler) davWorker(account *models.EmailAccount, stop chan struct{}) {
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
s.davSync(getAccount())
|
||||
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.davSync(getAccount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) davSync(account *models.EmailAccount) {
|
||||
if account.CalDAVURL == "" && account.CardDAVURL == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if account.CalDAVURL != "" {
|
||||
events, err := caldav.SyncCalendar(ctx, account.CalDAVURL, account.EmailAddress, account.AccessToken, account.ID)
|
||||
if err != nil {
|
||||
logger.Debug("[caldav:%s] sync: %v", account.EmailAddress, err)
|
||||
} else {
|
||||
uids := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
e.UserID = account.UserID
|
||||
if err := s.db.UpsertCalendarEvent(e); err == nil {
|
||||
uids = append(uids, e.UID)
|
||||
}
|
||||
}
|
||||
s.db.DeleteCalendarEventsNotIn(account.ID, uids)
|
||||
}
|
||||
}
|
||||
|
||||
if account.CardDAVURL != "" {
|
||||
contacts, err := caldav.SyncContacts(ctx, account.CardDAVURL, account.EmailAddress, account.AccessToken, account.ID)
|
||||
if err != nil {
|
||||
logger.Debug("[carddav:%s] sync: %v", account.EmailAddress, err)
|
||||
} else {
|
||||
uids := make([]string, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
c.UserID = account.UserID
|
||||
if err := s.db.UpsertContact(c); err == nil {
|
||||
uids = append(uids, c.UID)
|
||||
}
|
||||
}
|
||||
s.db.DeleteContactsNotIn(account.ID, uids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+282
-60
@@ -47,13 +47,16 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
|
||||
z-index:100;display:flex;align-items:center;justify-content:center;
|
||||
opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
.modal-overlay.open{opacity:1;pointer-events:all}
|
||||
/* Account add/edit modals open from inside the Settings modal and must stack above it,
|
||||
regardless of DOM order, so Settings stays visible (and reachable) underneath. */
|
||||
#add-account-modal,#edit-account-modal{z-index:110}
|
||||
.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2);
|
||||
border:1px solid var(--border2);border-radius:14px;padding:26px;
|
||||
border:1px solid var(--border2);border-radius:10px;padding:22px;
|
||||
transform:scale(.95);transition:transform .2s}
|
||||
.modal-overlay.open .modal{transform:scale(1)}
|
||||
.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px}
|
||||
.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px}
|
||||
.modal-field{margin-bottom:12px}
|
||||
.modal h2{font-family:'DM Serif Display',serif;font-size:19px;font-weight:400;margin-bottom:6px}
|
||||
.modal > p{font-size:13px;color:var(--muted);margin-bottom:16px}
|
||||
.modal-field{margin-bottom:10px}
|
||||
.modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase;
|
||||
letter-spacing:.8px;color:var(--muted);margin-bottom:5px}
|
||||
.modal-field input,.modal-field select,.modal-field textarea{
|
||||
@@ -140,11 +143,57 @@ body.auth-page{display:flex;align-items:center;justify-content:center;min-height
|
||||
body.app-page{overflow:hidden}
|
||||
.app{display:flex;height:100vh}
|
||||
|
||||
/* Mail view wrapper (list + detail) — lets the reading-pane position be
|
||||
flipped from the right (default) to the bottom without touching the
|
||||
sidebar column. */
|
||||
.mail-view{display:flex;flex:1;min-width:0;overflow:hidden}
|
||||
@media (min-width:701px){
|
||||
#app-root[data-reading-pane="bottom"] .mail-view{flex-direction:column}
|
||||
#app-root[data-reading-pane="bottom"] .mail-view .message-list-panel{
|
||||
width:100%;height:38%;min-height:160px;border-right:none;border-bottom:1px solid var(--border)}
|
||||
#app-root[data-reading-pane="bottom"] .mail-view .message-detail{flex:1;min-height:0}
|
||||
}
|
||||
|
||||
/* Drag handle between the message list and reading pane — desktop only (mobile
|
||||
switches full-screen between the two, there's nothing to split). Direction
|
||||
flips with reading-pane position; size is persisted via uiPrefs (server-side,
|
||||
not a cookie, so it follows the user across browsers/devices). */
|
||||
.panel-resize-handle{display:none}
|
||||
@media (min-width:701px){
|
||||
.panel-resize-handle{display:block;flex-shrink:0;width:5px;cursor:col-resize;
|
||||
background:transparent;position:relative;z-index:5}
|
||||
.panel-resize-handle::after{content:'';position:absolute;top:0;bottom:0;left:1px;right:1px;
|
||||
background:var(--border2);transition:background .15s}
|
||||
.panel-resize-handle:hover::after,.panel-resize-handle.dragging::after{background:var(--accent)}
|
||||
#app-root[data-reading-pane="bottom"] .panel-resize-handle{width:100%;height:5px;cursor:row-resize}
|
||||
#app-root[data-reading-pane="bottom"] .panel-resize-handle::after{top:1px;bottom:1px;left:0;right:0}
|
||||
}
|
||||
|
||||
/* Sidebar collapse / auto-hide (desktop only — mobile keeps its own drawer below).
|
||||
#sidebar-expand-btn lives inline in .panel-header (before the folder name) so it
|
||||
never overlaps content — it's only shown while the sidebar itself is hidden. */
|
||||
#sidebar-expand-btn{display:none}
|
||||
@media (min-width:701px){
|
||||
#app-root[data-sidebar="collapsed"] .sidebar,
|
||||
#app-root[data-sidebar="auto"] .sidebar{width:0;min-width:0;border-right:none;padding:0}
|
||||
#app-root[data-sidebar="auto"] .sidebar{
|
||||
position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);
|
||||
transform:translateX(-100%);transition:transform .15s ease;
|
||||
z-index:60;box-shadow:4px 0 24px rgba(0,0,0,.4);border-right:1px solid var(--border)}
|
||||
#app-root[data-sidebar="collapsed"] #sidebar-expand-btn,
|
||||
#app-root[data-sidebar="auto"] #sidebar-expand-btn{display:flex}
|
||||
/* Auto-hide: peek the sidebar in as an overlay while hovering the expand button
|
||||
or the sidebar itself (once revealed), pure CSS via :has() — no JS timers. */
|
||||
#app-root[data-sidebar="auto"]:has(#sidebar-expand-btn:hover) .sidebar,
|
||||
#app-root[data-sidebar="auto"] .sidebar:hover{transform:translateX(0)}
|
||||
}
|
||||
.sidebar-collapse-btn{flex-shrink:0}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface);
|
||||
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.sidebar-header{padding:12px 14px 10px;border-bottom:1px solid var(--border);
|
||||
display:flex;flex-direction:column}
|
||||
.sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)}
|
||||
.logo{display:flex;align-items:center;gap:8px}
|
||||
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
|
||||
@@ -156,17 +205,28 @@ body.app-page{overflow:hidden}
|
||||
.compose-btn:hover{opacity:.85}
|
||||
/* ── Account dot (still used in popup) */
|
||||
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
|
||||
.nav-section{padding:3px 6px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;
|
||||
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
|
||||
.nav-item:hover{background:var(--surface3);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.nav-item svg{width:15px;height:15px;flex-shrink:0}
|
||||
.nav-item svg{width:14px;height:14px;flex-shrink:0}
|
||||
.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}
|
||||
.folder-count-group{margin-left:auto;display:flex;align-items:center;gap:2px;flex-shrink:0}
|
||||
.folder-count-group .unread-badge{margin-left:0}
|
||||
.folder-total-count{font-size:9px;color:var(--muted);font-weight:400}
|
||||
.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}
|
||||
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
||||
color:var(--muted);padding:8px 8px 2px;display:flex;align-items:center;gap:6px;
|
||||
cursor:pointer;user-select:none;border-radius:5px;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:8px 12px;border-top:1px solid var(--border);display:flex;
|
||||
align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.user-name{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -175,11 +235,11 @@ body.app-page{overflow:hidden}
|
||||
/* Message list panel */
|
||||
.message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;background:var(--surface)}
|
||||
.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border);
|
||||
.panel-header{padding:10px 12px 8px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.panel-title{font-family:'DM Serif Display',serif;font-size:17px}
|
||||
.panel-title{font-family:'DM Serif Display',serif;font-size:16px}
|
||||
.panel-count{font-size:12px;color:var(--muted)}
|
||||
.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.search-bar{padding:6px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.search-wrap{position:relative}
|
||||
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);
|
||||
width:13px;height:13px;fill:var(--muted);pointer-events:none}
|
||||
@@ -189,28 +249,76 @@ body.app-page{overflow:hidden}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--muted)}
|
||||
.message-list{flex:1;overflow-y:auto}
|
||||
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item{padding:6px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item:hover{background:var(--surface2)}
|
||||
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
|
||||
/* Unread: lighter background + bold sender so it pops clearly */
|
||||
.message-item.unread{background:rgba(255,255,255,.035)}
|
||||
.message-item.unread:hover{background:rgba(255,255,255,.055)}
|
||||
.message-item.unread .msg-from{color:var(--text);font-weight:600}
|
||||
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
|
||||
/* Read messages: everything dimmed down so unread has something to stand out against */
|
||||
.msg-from{font-size:13px;font-weight:500;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
/* Unread: accent-tinted background + a solid dot + bold bright sender/subject + left bar,
|
||||
so it reads as unread at a glance instead of only on close inspection. */
|
||||
.message-item.unread{background:rgba(91,141,239,.07)}
|
||||
.message-item.unread:hover{background:rgba(91,141,239,.12)}
|
||||
.message-item.unread .msg-from{color:var(--text);font-weight:700}
|
||||
.message-item.unread .msg-subject{font-weight:700;color:var(--text)}
|
||||
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
|
||||
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.message-item.unread.active{background:var(--accent-dim)}
|
||||
.message-item.unread.active::before{display:none}
|
||||
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
|
||||
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.msg-unread-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:transparent}
|
||||
.message-item.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
|
||||
/* Compact 2-line row (default): sender+date, then subject–preview with trailing icons */
|
||||
.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px}
|
||||
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
|
||||
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
|
||||
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
|
||||
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.msg-acct{font-size:10px;color:var(--muted)}
|
||||
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
|
||||
.msg-line2{display:flex;align-items:center;gap:6px}
|
||||
.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4}
|
||||
.msg-subject{color:var(--text2)}
|
||||
.msg-preview{color:var(--muted)}
|
||||
.msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||
.msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0}
|
||||
.msg-size{font-size:10px;color:var(--muted)}
|
||||
.msg-star{color:var(--muted);font-size:15px;cursor:pointer}
|
||||
.msg-star.on{color:var(--star)}
|
||||
|
||||
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
||||
.msg-label-dots{display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||
.msg-label-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.nav-label-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
|
||||
/* Labels dropdown (panel-header, next to Filter) */
|
||||
.label-dropdown-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:5px}
|
||||
.label-dropdown-row:hover{background:var(--surface3)}
|
||||
.label-dropdown-name{flex:1;cursor:pointer;font-size:13px;color:var(--text2)}
|
||||
.label-dropdown-row:hover .label-dropdown-name{color:var(--text)}
|
||||
.label-dropdown-actions{display:flex;gap:2px;opacity:0;transition:opacity .1s;flex-shrink:0}
|
||||
.label-dropdown-row:hover .label-dropdown-actions{opacity:1}
|
||||
.label-dropdown-actions button{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
font-size:11px;padding:3px 5px;border-radius:3px}
|
||||
.label-dropdown-actions button:hover{background:var(--surface2);color:var(--text)}
|
||||
.label-dropdown-new{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;color:var(--accent)}
|
||||
.label-dropdown-new:hover{background:var(--surface3)}
|
||||
.detail-labels{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px}
|
||||
.label-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 4px 2px 8px;border-radius:12px;
|
||||
font-size:11px;font-weight:500;border:1px solid transparent}
|
||||
.label-chip-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.label-chip button{background:none;border:none;color:inherit;opacity:.6;cursor:pointer;font-size:13px;
|
||||
line-height:1;padding:0 3px}
|
||||
.label-chip button:hover{opacity:1}
|
||||
.label-add-btn{font-size:11px;color:var(--muted);background:none;border:1px dashed var(--border2);
|
||||
border-radius:12px;padding:2px 10px;cursor:pointer;transition:border-color .15s,color .15s}
|
||||
.label-add-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.label-picker-item{display:flex;align-items:center;gap:8px}
|
||||
.label-swatches{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
|
||||
.label-swatch{width:24px;height:24px;border-radius:50%;cursor:pointer;border:2px solid transparent;
|
||||
transition:transform .1s,border-color .1s}
|
||||
.label-swatch:hover{transform:scale(1.1)}
|
||||
.label-swatch.selected{border-color:var(--text)}
|
||||
|
||||
/* Comfortable density (opt-in via #app-root[data-density="comfortable"]): restores the
|
||||
roomier 4-line row with account email and larger padding. */
|
||||
#app-root[data-density="comfortable"] .message-item{padding:10px 12px}
|
||||
#app-root[data-density="comfortable"] .msg-top{margin-bottom:2px}
|
||||
#app-root[data-density="comfortable"] .msg-line2{flex-wrap:wrap}
|
||||
#app-root[data-density="comfortable"] .msg-text{white-space:normal;font-size:12px;flex-basis:100%}
|
||||
#app-root[data-density="comfortable"] .msg-icons{margin-left:auto;margin-top:2px}
|
||||
.load-more{padding:10px;text-align:center}
|
||||
.load-more-btn{background:none;border:1px solid var(--border2);color:var(--accent);
|
||||
padding:6px 18px;border-radius:6px;cursor:pointer;font-size:12px;transition:background .15s}
|
||||
@@ -227,19 +335,19 @@ body.app-page{overflow:hidden}
|
||||
.no-message svg{width:48px;height:48px;fill:var(--border2)}
|
||||
.no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)}
|
||||
.no-message p{font-size:13px}
|
||||
.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px}
|
||||
.detail-header{padding:12px 20px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.detail-subject{font-family:'DM Serif Display',serif;font-size:18px;margin-bottom:8px}
|
||||
.detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
||||
.detail-from{font-size:13px}
|
||||
.detail-from strong{color:var(--text)}
|
||||
.detail-from span{color:var(--muted);font-size:12px}
|
||||
.detail-date{font-size:12px;color:var(--muted);flex-shrink:0}
|
||||
.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
|
||||
.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;
|
||||
.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
|
||||
.action-btn{padding:4px 10px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;
|
||||
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s}
|
||||
.action-btn:hover{background:var(--surface3);color:var(--text)}
|
||||
.action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)}
|
||||
.detail-body{flex:1;overflow-y:auto;padding:20px}
|
||||
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
|
||||
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
|
||||
.detail-body iframe{width:100%;border:none;min-height:400px}
|
||||
|
||||
@@ -248,13 +356,13 @@ body.app-page{overflow:hidden}
|
||||
position:fixed;bottom:20px;right:24px;
|
||||
width:540px;height:480px;
|
||||
background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
border-radius:8px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
display:none;flex-direction:column;z-index:200;
|
||||
min-width:360px;min-height:280px;overflow:hidden;
|
||||
user-select:none;
|
||||
}
|
||||
.compose-dialog-header{
|
||||
padding:10px 12px 10px 16px;border-bottom:1px solid var(--border);
|
||||
padding:8px 10px 8px 14px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
cursor:grab;flex-shrink:0;background:var(--surface2);
|
||||
}
|
||||
@@ -264,12 +372,12 @@ body.app-page{overflow:hidden}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
|
||||
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
|
||||
.compose-close:hover{background:var(--surface3);color:var(--text)}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:6px 14px;gap:10px;flex-shrink:0}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:5px 12px;gap:10px;flex-shrink:0}
|
||||
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
|
||||
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
|
||||
.compose-field select option{background:var(--surface2)}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.compose-footer{padding:6px 12px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.send-btn:hover{opacity:.85}
|
||||
@@ -329,7 +437,16 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
padding:22px 24px;margin-bottom:20px}
|
||||
.admin-card h3{font-size:14px;font-weight:500;margin-bottom:4px}
|
||||
.admin-card .card-desc{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||
.settings-group{margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.settings-nav{width:160px;flex-shrink:0;padding:12px 8px;border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;gap:1px}
|
||||
.settings-nav button{display:block;width:100%;text-align:left;padding:7px 10px;border:none;
|
||||
background:transparent;color:var(--text2);border-radius:5px;cursor:pointer;font-family:'DM Sans',sans-serif;
|
||||
font-size:13px;transition:background .1s}
|
||||
.settings-nav button:hover{background:var(--surface3);color:var(--text)}
|
||||
.settings-nav button.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.settings-panel{display:none}
|
||||
.settings-panel.active{display:block}
|
||||
.settings-group{margin-bottom:18px;padding-bottom:18px;border-bottom:1px solid var(--border)}
|
||||
.settings-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.settings-group-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;
|
||||
color:var(--accent);margin-bottom:14px}
|
||||
@@ -346,11 +463,11 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.setting-control input[type=password]{font-family:monospace;letter-spacing:.1em}
|
||||
|
||||
/* ---- Rich text compose editor ---- */
|
||||
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
|
||||
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
|
||||
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
|
||||
.fmt-btn:hover{background:var(--border2);color:var(--text)}
|
||||
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
|
||||
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
|
||||
.compose-editor{flex:1;overflow-y:auto;padding:10px 12px;
|
||||
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
|
||||
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
|
||||
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
|
||||
@@ -369,10 +486,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
|
||||
/* ---- Attachment chips ---- */
|
||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
|
||||
text-decoration:none;color:inherit}
|
||||
.attachment-chip:hover{background:var(--border2)}
|
||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
||||
/* Drag-and-drop compose overlay */
|
||||
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
|
||||
|
||||
/* ── Email tag input ─────────────────────────────────────────── */
|
||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||
@@ -389,25 +509,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
|
||||
font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
|
||||
|
||||
/* ── Accounts popup ──────────────────────────────────────────── */
|
||||
.accounts-popup{
|
||||
position:fixed;bottom:52px;left:8px;
|
||||
width:300px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);
|
||||
z-index:300;display:none;flex-direction:column;overflow:hidden;
|
||||
}
|
||||
.accounts-popup.open{display:flex}
|
||||
.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299}
|
||||
.accounts-popup-backdrop.open{display:block}
|
||||
.accounts-popup-inner{padding:12px}
|
||||
.accounts-popup-header{display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;
|
||||
color:var(--muted);margin-bottom:8px}
|
||||
.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px;
|
||||
transition:background .1s}
|
||||
.acct-popup-item:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px;
|
||||
margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
/* ── Settings: connected-accounts list (Accounts tab) ──────────── */
|
||||
.acct-row{display:flex;align-items:center;gap:8px;padding:9px 8px;border-radius:6px;
|
||||
transition:background .1s;border-bottom:1px solid var(--border)}
|
||||
.acct-row:last-child{border-bottom:none}
|
||||
.acct-row:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px 6px;
|
||||
margin-top:10px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
|
||||
transition:background .1s}
|
||||
.accounts-add-btn:hover{background:var(--accent-dim)}
|
||||
@@ -495,3 +603,117 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
from{opacity:0;transform:translateY(16px) scale(.96)}
|
||||
to{opacity:1;transform:translateY(0) scale(1)}
|
||||
}
|
||||
|
||||
/* ── Mobile top bar (hidden on desktop) ───────────────────────────────── */
|
||||
.mob-topbar{display:none}
|
||||
|
||||
/* ── Responsive layout ────────────────────────────────────────────────── */
|
||||
@media (max-width:700px){
|
||||
/* Show mobile top bar */
|
||||
.mob-topbar{
|
||||
display:flex;align-items:center;gap:8px;
|
||||
position:fixed;top:0;left:0;right:0;height:50px;z-index:200;
|
||||
background:var(--surface);border-bottom:1px solid var(--border);
|
||||
padding:0 12px;
|
||||
}
|
||||
.mob-nav-btn,.mob-back-btn{
|
||||
background:none;border:none;cursor:pointer;color:var(--text);
|
||||
padding:6px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.mob-nav-btn:hover,.mob-back-btn:hover{background:var(--surface3)}
|
||||
.mob-nav-btn svg,.mob-back-btn svg{width:20px;height:20px;fill:currentColor}
|
||||
.mob-title{font-family:'DM Serif Display',serif;font-size:15px;overflow:hidden;
|
||||
text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
|
||||
/* Push content below topbar */
|
||||
body.app-page{overflow:hidden}
|
||||
.app{flex-direction:column;height:100dvh;height:100vh;padding-top:50px}
|
||||
|
||||
/* Sidebar becomes a drawer */
|
||||
.sidebar{
|
||||
position:fixed;top:50px;left:0;bottom:0;z-index:150;
|
||||
transform:translateX(-100%);transition:transform .25s ease;
|
||||
width:280px;max-width:85vw;
|
||||
}
|
||||
.sidebar.mob-open{transform:translateX(0)}
|
||||
.mob-sidebar-backdrop{
|
||||
display:none;position:fixed;inset:0;top:50px;z-index:140;
|
||||
background:rgba(0,0,0,.45);
|
||||
}
|
||||
.mob-sidebar-backdrop.mob-open{display:block}
|
||||
|
||||
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
|
||||
.sidebar-header .compose-btn{display:none}
|
||||
/* Desktop-only sidebar collapse control — mobile already has the drawer/hamburger */
|
||||
.sidebar-collapse-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)}
|
||||
|
||||
+188
-8
@@ -1,9 +1,10 @@
|
||||
// GoMail Admin SPA
|
||||
// GoWebMail Admin SPA
|
||||
|
||||
const adminRoutes = {
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
'/admin/security': renderSecurity,
|
||||
};
|
||||
|
||||
function navigate(path) {
|
||||
@@ -26,7 +27,7 @@ async function renderUsers() {
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Users</h1>
|
||||
<p>Manage GoMail accounts and permissions.</p>
|
||||
<p>Manage GoWebMail accounts and permissions.</p>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
@@ -67,16 +68,19 @@ async function loadUsersTable() {
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
||||
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||
el.innerHTML = `<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
|
||||
<tbody>${r.map(u => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(u.username)}</td>
|
||||
<td style="color:var(--muted)">${esc(u.email)}</td>
|
||||
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
||||
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
||||
<td style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
|
||||
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
|
||||
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
|
||||
else toast((r && r.error) || 'Delete failed', 'error');
|
||||
}
|
||||
|
||||
async function disableMFA(userId, username) {
|
||||
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
|
||||
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
|
||||
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
|
||||
else toast((r && r.error) || 'Failed to disable MFA', 'error');
|
||||
}
|
||||
|
||||
function openResetPassword(userId, username) {
|
||||
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
|
||||
if (!pw) return;
|
||||
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
|
||||
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
|
||||
if (r && r.ok) toast('Password reset for ' + username, 'success');
|
||||
else toast((r && r.error) || 'Failed to reset password', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
@@ -182,6 +203,34 @@ const SETTINGS_META = [
|
||||
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Security Notifications',
|
||||
fields: [
|
||||
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
|
||||
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
|
||||
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
|
||||
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
|
||||
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
|
||||
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Brute Force Protection',
|
||||
fields: [
|
||||
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
|
||||
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
|
||||
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
|
||||
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
|
||||
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Geo Blocking',
|
||||
fields: [
|
||||
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
|
||||
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
async function renderSettings() {
|
||||
@@ -308,4 +357,135 @@ function eventBadge(evt) {
|
||||
navigate(a.getAttribute('href'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
})();
|
||||
// ============================================================
|
||||
// Security — IP Blocks & Login Attempts
|
||||
// ============================================================
|
||||
async function renderSecurity() {
|
||||
const el = document.getElementById('admin-content');
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Security</h1>
|
||||
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-bottom:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
|
||||
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
|
||||
</div>
|
||||
<div id="blocks-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
|
||||
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="attempts-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="add-block-modal">
|
||||
<div class="modal" style="max-width:420px">
|
||||
<h2>Block IP Address</h2>
|
||||
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
|
||||
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
|
||||
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
|
||||
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadIPBlocks();
|
||||
loadLoginAttempts();
|
||||
}
|
||||
|
||||
async function loadIPBlocks() {
|
||||
const el = document.getElementById('blocks-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/ip-blocks');
|
||||
const blocks = r?.blocks || [];
|
||||
if (!blocks.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${blocks.map(b => `<tr>
|
||||
<td><code>${esc(b.ip)}</code></td>
|
||||
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
|
||||
<td>${esc(b.reason)}</td>
|
||||
<td>${b.attempts||0}</td>
|
||||
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
|
||||
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
|
||||
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadLoginAttempts() {
|
||||
const el = document.getElementById('attempts-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/login-attempts');
|
||||
const attempts = r?.attempts || [];
|
||||
if (!attempts.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
|
||||
<td><code>${esc(a.ip)}</code></td>
|
||||
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
|
||||
<td>${a.total}</td>
|
||||
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
|
||||
<td style="font-size:11px">${a.last_seen||'—'}</td>
|
||||
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function openAddBlock() { openModal('add-block-modal'); }
|
||||
|
||||
async function submitAddBlock() {
|
||||
const ip = document.getElementById('block-ip').value.trim();
|
||||
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
|
||||
const hours = parseInt(document.getElementById('block-hours').value) || 0;
|
||||
if (!ip) { toast('IP address required', 'error'); return; }
|
||||
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
|
||||
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
|
||||
else toast(r?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
async function unblockIP(ip) {
|
||||
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
|
||||
const data = await r.json();
|
||||
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
|
||||
else toast(data?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
function blockFromAttempt(ip) {
|
||||
document.getElementById('block-ip').value = ip;
|
||||
document.getElementById('block-reason').value = 'Manual block from login attempts';
|
||||
openModal('add-block-modal');
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
+1533
-177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
// ── 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 — mail-view wraps the message list + reading pane together
|
||||
// so they hide/show as one unit rather than two separately-toggled panels.
|
||||
const mailView = document.getElementById('mail-view');
|
||||
const contacts = document.getElementById('contacts-panel');
|
||||
const calendar = document.getElementById('calendar-panel');
|
||||
if (mailView) mailView.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,10 +1,13 @@
|
||||
// GoMail shared utilities - loaded on every page
|
||||
// GoWebMail shared utilities - loaded on every page
|
||||
|
||||
// ---- API helper ----
|
||||
async function api(method, path, body) {
|
||||
async function api(method, path, body, timeoutMs) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
if (timeoutMs) setTimeout(() => controller.abort(), timeoutMs);
|
||||
opts.signal = controller.signal;
|
||||
const r = await fetch('/api' + path, opts);
|
||||
if (r.status === 401) { location.href = '/auth/login'; return null; }
|
||||
return r.json().catch(() => null);
|
||||
@@ -66,6 +69,39 @@ function positionMenu(menu, x, y) {
|
||||
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + 'px';
|
||||
}
|
||||
|
||||
// ---- Long-press → right-click (touch devices have no right-click) ----
|
||||
// Every context menu in the app is wired via oncontextmenu="...". Touch devices never fire
|
||||
// that event, so a ~550ms press-and-hold synthesizes a real 'contextmenu' event at the
|
||||
// touch point instead — every existing handler picks it up unchanged.
|
||||
(function () {
|
||||
let timer = null, fired = false, start = null;
|
||||
function cancel() { clearTimeout(timer); timer = null; }
|
||||
document.addEventListener('touchstart', e => {
|
||||
if (e.touches.length !== 1) { cancel(); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY, target: e.target };
|
||||
fired = false;
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
fired = true;
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
start.target.dispatchEvent(new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, clientX: start.x, clientY: start.y, view: window,
|
||||
}));
|
||||
}, 550);
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!start || !timer) return;
|
||||
const t = e.touches[0];
|
||||
if (Math.abs(t.clientX - start.x) > 10 || Math.abs(t.clientY - start.y) > 10) cancel();
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchend', e => {
|
||||
cancel();
|
||||
if (fired) { e.preventDefault(); fired = false; } // swallow the tap-through click
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchcancel', cancel, { passive: true });
|
||||
})();
|
||||
|
||||
// ---- Debounce ----
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
<a href="/admin/security" id="nav-security">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||
Security
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -35,5 +39,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script src="/static/js/admin.js?v=25"></script>
|
||||
{{end}}
|
||||
+548
-75
@@ -3,15 +3,40 @@
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="app">
|
||||
<div class="app" id="app-root" data-mob-view="list">
|
||||
<!-- Mobile top bar (hidden on desktop) -->
|
||||
<div class="mob-topbar" id="mob-topbar">
|
||||
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||
</button>
|
||||
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</button>
|
||||
<span class="mob-title" id="mob-title">GoWebMail</span>
|
||||
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
|
||||
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab">↗</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<button class="icon-btn sidebar-collapse-btn" onclick="toggleSidebarCollapse()" title="Collapse sidebar" style="flex-shrink:0">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||||
</button>
|
||||
<div class="logo">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;margin-top:8px">
|
||||
<button class="compose-btn" onclick="openCompose()" style="flex:1;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>
|
||||
<button class="compose-btn" onclick="openCompose()">+ New</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
@@ -24,6 +49,14 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
Starred
|
||||
</div>
|
||||
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
|
||||
Contacts
|
||||
</div>
|
||||
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
|
||||
Calendar
|
||||
</div>
|
||||
<div id="folders-by-account"></div>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +66,6 @@
|
||||
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
|
||||
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
</button>
|
||||
@@ -45,40 +75,82 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
|
||||
|
||||
<!-- Message list -->
|
||||
<!-- Mail view: message list + reading pane (position/density configurable via View menu) -->
|
||||
<div class="mail-view" id="mail-view">
|
||||
<div class="message-list-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" id="panel-title">Unified Inbox</span>
|
||||
<div style="display:flex;align-items:center;gap:6px;min-width:0">
|
||||
<button class="icon-btn" id="sidebar-expand-btn" onclick="toggleSidebarCollapse()" title="Show sidebar" style="flex-shrink:0">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
|
||||
</button>
|
||||
<span class="panel-title" id="panel-title">Unified Inbox</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="panel-count" id="panel-count"></span>
|
||||
<div class="filter-dropdown" id="view-dropdown">
|
||||
<button class="filter-dropdown-btn" id="view-dropdown-btn" title="View settings" onclick="toggleViewDropdown(event)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||
<span>View</span>
|
||||
</button>
|
||||
<div class="filter-dropdown-menu" id="view-dropdown-menu" style="display:none;min-width:190px">
|
||||
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Reading pane</div>
|
||||
<div class="filter-opt" id="vopt-pane-right" onclick="setViewPref('readingPane','right');event.stopPropagation()">✓ Right</div>
|
||||
<div class="filter-opt" id="vopt-pane-bottom" onclick="setViewPref('readingPane','bottom');event.stopPropagation()">○ Bottom</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Density</div>
|
||||
<div class="filter-opt" id="vopt-density-compact" onclick="setViewPref('density','compact');event.stopPropagation()">✓ Compact</div>
|
||||
<div class="filter-opt" id="vopt-density-comfortable" onclick="setViewPref('density','comfortable');event.stopPropagation()">○ Comfortable</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Sidebar</div>
|
||||
<div class="filter-opt" id="vopt-sidebar-expanded" onclick="setViewPref('sidebarMode','expanded');event.stopPropagation()">✓ Pinned (always visible)</div>
|
||||
<div class="filter-opt" id="vopt-sidebar-collapsed" onclick="setViewPref('sidebarMode','collapsed');event.stopPropagation()">○ Minimized</div>
|
||||
<div class="filter-opt" id="vopt-sidebar-auto" onclick="setViewPref('sidebarMode','auto');event.stopPropagation()">○ Auto-hide (peek on hover)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-dropdown" id="labels-dropdown">
|
||||
<button class="filter-dropdown-btn" id="labels-dropdown-btn" title="Labels" onclick="toggleLabelsDropdown(event)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/></svg>
|
||||
<span>Labels</span>
|
||||
</button>
|
||||
<div class="filter-dropdown-menu" id="labels-dropdown-menu" style="display:none;min-width:210px"></div>
|
||||
</div>
|
||||
<div class="filter-dropdown" id="filter-dropdown">
|
||||
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter & sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
|
||||
<span id="filter-label">Filter</span>
|
||||
</button>
|
||||
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
|
||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<div class="search-wrap" style="flex:1">
|
||||
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)" onkeydown="if(event.key==='Enter')applySearchFilters()">
|
||||
</div>
|
||||
<button class="filter-dropdown-btn" id="search-filters-btn" title="Search filters" onclick="toggleSearchFilters(event)" style="flex-shrink:0">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-list" id="message-list">
|
||||
<div class="spinner" style="margin-top:60px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-resize-handle" id="panel-resize-handle" title="Drag to resize"></div>
|
||||
|
||||
<!-- Message detail -->
|
||||
<main class="message-detail" id="message-detail">
|
||||
@@ -88,25 +160,107 @@
|
||||
<p>Choose a message from the list to read it</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||
<div class="accounts-popup" id="accounts-popup">
|
||||
<div class="accounts-popup-inner">
|
||||
<div class="accounts-popup-header">
|
||||
<span>Accounts</span>
|
||||
<button class="icon-btn" onclick="closeAccountsMenu()" style="margin:-4px -4px -4px 0">
|
||||
<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>
|
||||
<!-- ── 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 id="accounts-popup-list"></div>
|
||||
<button class="accounts-add-btn" onclick="closeAccountsMenu();openAddAccountModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect new account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounts-popup-backdrop" id="accounts-popup-backdrop" onclick="closeAccountsMenu()"></div>
|
||||
|
||||
<!-- ── Draggable Compose dialog ───────────────────────────────────────────── -->
|
||||
<div class="compose-dialog" id="compose-dialog">
|
||||
@@ -118,7 +272,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-body-wrap" id="compose-body-wrap">
|
||||
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
|
||||
<div class="compose-field"><label>From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
|
||||
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label>BCC</label><div id="compose-bcc-tags" class="tag-container"></div></div>
|
||||
@@ -171,6 +325,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Inline prompt (replaces browser prompt()) ─────────────────────────── -->
|
||||
<div class="inline-confirm" id="inline-prompt">
|
||||
<p id="inline-prompt-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
|
||||
<div class="modal-field">
|
||||
<input type="text" id="inline-prompt-input"
|
||||
onkeydown="if(event.key==='Enter'){document.getElementById('inline-prompt-ok').click();}else if(event.key==='Escape'){document.getElementById('inline-prompt-cancel').click();}">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="font-size:12px" id="inline-prompt-cancel">Cancel</button>
|
||||
<button class="btn-primary" style="font-size:12px" id="inline-prompt-ok">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Draft close confirm (save / delete / keep editing) ─────────────────── -->
|
||||
<div class="inline-confirm" id="draft-close-confirm">
|
||||
<p style="margin:0 0 14px;font-size:13px;line-height:1.5">Save this message as a draft before closing?</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
|
||||
<button class="btn-secondary" style="font-size:12px" id="draft-close-cancel">Keep editing</button>
|
||||
<button class="btn-danger" style="font-size:12px" id="draft-close-delete">Delete draft</button>
|
||||
<button class="btn-primary" style="font-size:12px" id="draft-close-save">Save draft</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="add-account-modal">
|
||||
<div class="modal">
|
||||
@@ -178,12 +355,27 @@
|
||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||
<div class="provider-btns">
|
||||
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
Gmail
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
||||
Outlook
|
||||
<!-- Microsoft 365 icon -->
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
|
||||
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
|
||||
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
|
||||
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
|
||||
</svg>
|
||||
Microsoft 365
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
|
||||
<!-- Outlook icon (blue envelope) -->
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="3" fill="#0078D4"/>
|
||||
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
|
||||
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
|
||||
</svg>
|
||||
Outlook Personal
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||
@@ -195,18 +387,25 @@
|
||||
</div>
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
|
||||
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
|
||||
<div style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
|
||||
<div class="modal-field" style="display:flex;align-items:center;gap:8px;flex-direction:row">
|
||||
<input type="checkbox" id="use-jmap" onchange="toggleJMAPFields()" style="width:auto;flex:none">
|
||||
<label for="use-jmap" style="margin:0;font-weight:400">Connect via JMAP instead of IMAP/SMTP</label>
|
||||
</div>
|
||||
<div id="imap-hint" style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
|
||||
Common ports — IMAP: <strong>993</strong> TLS/SSL, <strong>143</strong> STARTTLS/Plain ·
|
||||
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
<div class="modal-field"><label id="imap-host-label">IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field" id="imap-port-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-row" id="smtp-fields">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
|
||||
</div>
|
||||
<div class="modal-divider"><span>optional — sync calendar & contacts</span></div>
|
||||
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="imap-caldav-url" placeholder="https://mail.example.com/dav/calendars/user@example.com/default"></div>
|
||||
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="imap-carddav-url" placeholder="https://mail.example.com/dav/addressbooks/user@example.com/default"></div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
|
||||
@@ -216,6 +415,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Label Editor Modal (create/rename/recolor) ────────────────────────────── -->
|
||||
<div class="modal-overlay" id="label-editor-modal">
|
||||
<div class="modal" style="max-width:360px">
|
||||
<h2 id="label-editor-title">New Label</h2>
|
||||
<input type="hidden" id="label-editor-id">
|
||||
<div class="modal-field"><label>Name</label><input type="text" id="label-editor-name" maxlength="40"></div>
|
||||
<div class="modal-field">
|
||||
<label>Color</label>
|
||||
<div class="label-swatches" id="label-editor-swatches"></div>
|
||||
<input type="color" id="label-editor-custom-color" onchange="pickLabelColor(this.value)" style="width:40px;height:28px;padding:0;border:1px solid var(--border);border-radius:6px;background:none;cursor:pointer">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('label-editor-modal')">Cancel</button>
|
||||
<button class="modal-submit" onclick="saveLabelEditor()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="edit-account-modal">
|
||||
<div class="modal">
|
||||
@@ -223,15 +440,35 @@
|
||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||
<input type="hidden" id="edit-account-id">
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||
|
||||
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
|
||||
<div id="edit-oauth-section" style="display:none">
|
||||
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
|
||||
⚠️ Access token has expired — sync and send will fail until you reconnect.
|
||||
</div>
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
|
||||
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||
|
||||
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
|
||||
<div id="edit-creds-section">
|
||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label id="edit-imap-host-label">IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||
<div class="modal-field" id="edit-imap-port-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||
</div>
|
||||
<div class="modal-row" id="edit-smtp-fields">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Calendar & Contacts (optional)</div>
|
||||
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="edit-caldav-url" placeholder="leave blank to disable"></div>
|
||||
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="edit-carddav-url" placeholder="leave blank to disable"></div>
|
||||
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Email history to sync</label>
|
||||
@@ -263,42 +500,277 @@
|
||||
|
||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal" style="width:520px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||
<div class="modal" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px">
|
||||
<h2 style="margin-bottom:0">Settings</h2>
|
||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
|
||||
<div class="settings-nav">
|
||||
<button data-tab="accounts" class="active" onclick="showSettingsTab('accounts')">Accounts</button>
|
||||
<button data-tab="account" onclick="showSettingsTab('account')">Profile</button>
|
||||
<button data-tab="rules" onclick="showSettingsTab('rules')">Rules</button>
|
||||
<button data-tab="signatures" onclick="showSettingsTab('signatures')">Signatures</button>
|
||||
<button data-tab="certs" onclick="showSettingsTab('certs')">Certificates</button>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;overflow-y:auto;padding:20px 24px">
|
||||
|
||||
<div class="settings-panel active" data-tab="accounts">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Connected mailboxes</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Manage sync, credentials, CalDAV/CardDAV and per-account settings for each connected mailbox.</div>
|
||||
<div id="settings-accounts-list"></div>
|
||||
<button class="accounts-add-btn" onclick="openAddAccountModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect new account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="account">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Profile</div>
|
||||
<div class="modal-field">
|
||||
<label>Username</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Email Address</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Email Sync</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<select id="sync-interval-select" style="flex:1;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="0">Manual only</option>
|
||||
<option value="1">Every 1 minute</option>
|
||||
<option value="5">Every 5 minutes</option>
|
||||
<option value="10">Every 10 minutes</option>
|
||||
<option value="15">Every 15 minutes (default)</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every 60 minutes</option>
|
||||
</select>
|
||||
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
|
||||
<button class="btn-primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
|
||||
Two-Factor Authentication <span id="mfa-badge"></span>
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">IP Access Rules</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
|
||||
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Mode</label>
|
||||
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
|
||||
<option value="disabled">Disabled — use global settings</option>
|
||||
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
|
||||
<option value="allow_only">Allow only — only listed IPs can log in</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
|
||||
<div class="modal-field" id="ip-rule-list-field">
|
||||
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
|
||||
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
|
||||
</div>
|
||||
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="rules">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Rules apply to</div>
|
||||
<select id="rules-account-select" onchange="loadRules()" 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"></select>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Add Rule</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">Rules run in priority order (lowest first) against newly-synced mail; the first match wins.</div>
|
||||
<div class="modal-field"><label>Rule name</label><input type="text" id="rule-name" placeholder="e.g. Invoices to Accounting"></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<div class="modal-field" style="flex:1"><label>Priority</label><input type="number" id="rule-priority" value="0"></div>
|
||||
<div class="modal-field" style="flex:1"><label>Match</label>
|
||||
<select id="rule-match-type"><option value="all">ALL of the following (AND)</option><option value="any">ANY of the following (OR)</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rule-conditions"></div>
|
||||
<button class="btn-secondary" style="margin-bottom:14px" onclick="addRuleConditionRow()">+ Add condition</button>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||||
<div class="modal-field" style="flex:1;min-width:140px"><label>Then</label>
|
||||
<select id="rule-action" onchange="updateRuleActionFields()">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="mark_as_spam">Mark as Junk</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
<option value="forward">Forward to...</option>
|
||||
<option value="auto_reply">Send auto-reply</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" style="flex:1;min-width:160px" id="rule-action-value-field"><label>Folder name</label><input type="text" id="rule-action-value" placeholder="folder name"></div>
|
||||
</div>
|
||||
<div class="modal-field" id="rule-autoreply-body-field" style="display:none"><label>Auto-reply body</label><textarea id="rule-autoreply-body" rows="3" style="width:100%"></textarea></div>
|
||||
<button class="btn-primary" onclick="saveRule()">Add Rule</button>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Existing Rules</div>
|
||||
<div id="rules-list"><p style="color:var(--muted);font-size:13px">No rules yet.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="signatures">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Add Signature</div>
|
||||
<div class="modal-field"><label>Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
|
||||
<div class="modal-field"><label>Content (HTML)</label><textarea id="sig-content" rows="4" style="width:100%" placeholder="Best regards, Your Name"></textarea></div>
|
||||
<button class="btn-primary" onclick="saveSignature()">Add Signature</button>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Your Signatures</div>
|
||||
<div id="signatures-list"><p style="color:var(--muted);font-size:13px">No signatures yet.</p></div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Defaults per account</div>
|
||||
<select id="sig-defaults-account-select" onchange="renderSignatureDefaultsForm()" style="width:100%;margin-bottom:10px;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"></select>
|
||||
<div id="sig-defaults-form"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="certs">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Certificates apply to</div>
|
||||
<select id="certs-account-select" onchange="loadCerts()" 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"></select>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:10px">S/MIME certificates <b>sign</b> outgoing mail. PGP keys <b>encrypt</b> it. A message can use either, both, or neither.</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">S/MIME — for signing</div>
|
||||
<div id="smime-identity-list"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
|
||||
<button class="btn-primary" onclick="smimeGenerate()">Generate Self-Signed Certificate</button>
|
||||
</div>
|
||||
<div class="modal-field"><label>Import existing (.p12/.pfx)</label>
|
||||
<input type="file" id="smime-import-file" accept=".p12,.pfx">
|
||||
<input type="password" id="smime-import-password" placeholder=".p12 export password (if any)" style="margin-top:6px">
|
||||
<button class="btn-secondary" style="margin-top:6px" onclick="smimeImport()">Import</button>
|
||||
</div>
|
||||
<div class="settings-group-title" style="margin-top:16px;font-size:13px">S/MIME contact certificates</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<input type="email" id="smime-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
|
||||
<input type="file" id="smime-contact-file" accept=".pem,.crt,.cer">
|
||||
<button class="btn-secondary" onclick="smimeAddContact()">Add Contact</button>
|
||||
</div>
|
||||
<div id="smime-contacts-list"></div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">PGP — for encryption</div>
|
||||
<div id="pgp-identity-list"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
|
||||
<input type="text" id="pgp-gen-label" placeholder="Label (optional)" style="flex:1;min-width:120px">
|
||||
<input type="password" id="pgp-gen-pass" placeholder="Passphrase (min 8 chars)" style="flex:1;min-width:140px">
|
||||
<input type="password" id="pgp-gen-pass2" placeholder="Confirm passphrase" style="flex:1;min-width:140px">
|
||||
<button class="btn-primary" onclick="pgpGenerate()">Generate PGP Key</button>
|
||||
</div>
|
||||
<div class="modal-field"><label>Import existing (.asc)</label>
|
||||
<input type="file" id="pgp-import-file" accept=".asc">
|
||||
<input type="password" id="pgp-import-pass" placeholder="The key's passphrase" style="margin-top:6px">
|
||||
<button class="btn-secondary" style="margin-top:6px" onclick="pgpImport()">Import</button>
|
||||
</div>
|
||||
<div class="settings-group-title" style="margin-top:16px;font-size:13px">PGP contact keys</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<input type="email" id="pgp-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
|
||||
<input type="text" id="pgp-contact-label" placeholder="Label (optional)" style="flex:1;min-width:100px">
|
||||
<input type="file" id="pgp-contact-file" accept=".asc">
|
||||
<button class="btn-secondary" onclick="pgpAddContact()">Add Contact</button>
|
||||
</div>
|
||||
<div id="pgp-contacts-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Email Sync</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<select id="sync-interval-select" style="flex:1;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="0">Manual only</option>
|
||||
<option value="1">Every 1 minute</option>
|
||||
<option value="5">Every 5 minutes</option>
|
||||
<option value="10">Every 10 minutes</option>
|
||||
<option value="15">Every 15 minutes (default)</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every 60 minutes</option>
|
||||
</select>
|
||||
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
|
||||
<button class="btn-primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
<!-- Compose split-button dropdown — fixed-position, JS-placed (see toggleComposeDropdown);
|
||||
lives outside .sidebar so its overflow:hidden can't clip it -->
|
||||
<div id="compose-dropdown" style="display:none;position:fixed;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:210;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 class="settings-group">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
|
||||
Two-Factor Authentication <span id="mfa-badge"></span>
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</div>
|
||||
<!-- Search filters popover — fixed-position, JS-placed under the search bar (see
|
||||
toggleSearchFilters); same reasoning as #compose-dropdown above. -->
|
||||
<div id="search-filters-panel" style="display:none;position:fixed;padding:10px;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.5);z-index:210;max-height:80vh;overflow-y:auto">
|
||||
<div class="modal-field" style="margin-bottom:8px">
|
||||
<label>Search in mailbox</label>
|
||||
<select id="sf-mailbox-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
<option value="">All mailboxes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:8px">
|
||||
<label>Search in</label>
|
||||
<select id="sf-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
<option value="all">All fields</option>
|
||||
<option value="subject">Subject only</option>
|
||||
<option value="body">Body only</option>
|
||||
<option value="subject_body">Subject + Body</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:8px">
|
||||
<label>Attachment</label>
|
||||
<select id="sf-attachment" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
<option value="">Any</option>
|
||||
<option value="1">Has attachment</option>
|
||||
<option value="0">No attachment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-row" style="margin-bottom:8px;gap:8px">
|
||||
<div class="modal-field" style="flex:1;margin-bottom:0"><label>From date</label>
|
||||
<input type="date" id="sf-date-from" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
</div>
|
||||
<div class="modal-field" style="flex:1;margin-bottom:0"><label>To date</label>
|
||||
<input type="date" id="sf-date-to" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:8px">
|
||||
<label>Older than (days) <span style="color:var(--muted);font-size:10px">— fills in "To date"</span></label>
|
||||
<input type="number" id="sf-older-days" min="0" placeholder="e.g. 30" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
</div>
|
||||
<div class="modal-row" style="margin-bottom:10px;gap:8px">
|
||||
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Min size (KB)</label>
|
||||
<input type="number" id="sf-min-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
</div>
|
||||
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Max size (KB)</label>
|
||||
<input type="number" id="sf-max-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="clearSearchFilters()">Clear</button>
|
||||
<button class="btn-primary" style="font-size:12px" onclick="applySearchFilters()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,5 +780,6 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=12"></script>
|
||||
<script src="/static/js/app.js?v=70"></script>
|
||||
<script src="/static/js/contacts_calendar.js?v=70"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=70">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gowebmail.js?v=12"></script>
|
||||
<script src="/static/js/gowebmail.js?v=70"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}Compose — GoWebMail{{end}}
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
|
||||
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back to GoWebMail
|
||||
</a>
|
||||
<span style="color:var(--border);font-size:16px">|</span>
|
||||
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
|
||||
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="compose-page-form">
|
||||
<!-- From -->
|
||||
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
|
||||
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
|
||||
</div>
|
||||
<!-- To -->
|
||||
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
|
||||
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||
</div>
|
||||
<!-- CC -->
|
||||
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
|
||||
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||
</div>
|
||||
<!-- Subject -->
|
||||
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
|
||||
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
|
||||
<!-- Attachments -->
|
||||
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
|
||||
Attach file
|
||||
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
|
||||
</label>
|
||||
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// Parse URL params
|
||||
const params = new URLSearchParams(location.search);
|
||||
const replyId = parseInt(params.get('reply_id') || '0');
|
||||
const forwardId = parseInt(params.get('forward_id') || '0');
|
||||
const cpAttachments = [];
|
||||
|
||||
async function apiCall(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body instanceof FormData) { opts.body = body; }
|
||||
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||
const r = await fetch('/api' + path, opts);
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
// Tag field (simple comma/enter separated)
|
||||
function initTagField(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
|
||||
const inp = el.querySelector('input');
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const v = inp.value.trim().replace(/,$/, '');
|
||||
if (v) addTagTo(id, v);
|
||||
inp.value = '';
|
||||
} else if (e.key === 'Backspace' && !inp.value) {
|
||||
const tags = el.querySelectorAll('.tag-chip');
|
||||
if (tags.length) tags[tags.length-1].remove();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('blur', () => {
|
||||
const v = inp.value.trim().replace(/,$/, '');
|
||||
if (v) { addTagTo(id, v); inp.value = ''; }
|
||||
});
|
||||
}
|
||||
|
||||
function addTagTo(fieldId, email) {
|
||||
const el = document.getElementById(fieldId);
|
||||
const inp = el.querySelector('input');
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip';
|
||||
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
|
||||
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
|
||||
el.insertBefore(chip, inp);
|
||||
}
|
||||
|
||||
function getTagValues(fieldId) {
|
||||
const el = document.getElementById(fieldId);
|
||||
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function addPageAttachments(files) {
|
||||
for (const f of files) {
|
||||
cpAttachments.push(f);
|
||||
const chip = document.createElement('span');
|
||||
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
|
||||
chip.textContent = f.name;
|
||||
document.getElementById('cp-att-list').appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const accounts = await apiCall('GET', '/accounts') || [];
|
||||
const sel = document.getElementById('cp-from');
|
||||
accounts.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function prefillReply() {
|
||||
if (!replyId) return;
|
||||
document.getElementById('compose-page-title').textContent = 'Reply';
|
||||
const msg = await apiCall('GET', '/messages/' + replyId);
|
||||
if (!msg) return;
|
||||
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
|
||||
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
|
||||
addTagTo('cp-to-tags', msg.from_email || '');
|
||||
const editor = document.getElementById('cp-editor');
|
||||
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
|
||||
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||
</div>`;
|
||||
// Set from to same account
|
||||
if (msg.account_id) {
|
||||
const sel = document.getElementById('cp-from');
|
||||
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
|
||||
}
|
||||
}
|
||||
|
||||
async function prefillForward() {
|
||||
if (!forwardId) return;
|
||||
document.getElementById('compose-page-title').textContent = 'Forward';
|
||||
const msg = await apiCall('GET', '/messages/' + forwardId);
|
||||
if (!msg) return;
|
||||
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
|
||||
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
|
||||
const editor = document.getElementById('cp-editor');
|
||||
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
|
||||
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function sendFromPage() {
|
||||
const btn = document.getElementById('send-page-btn');
|
||||
const accountId = parseInt(document.getElementById('cp-from').value || '0');
|
||||
const to = getTagValues('cp-to-tags');
|
||||
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
|
||||
btn.disabled = true; btn.textContent = 'Sending…';
|
||||
|
||||
const meta = {
|
||||
account_id: accountId,
|
||||
to,
|
||||
cc: getTagValues('cp-cc-tags'),
|
||||
bcc: [],
|
||||
subject: document.getElementById('cp-subject').value,
|
||||
body_html: document.getElementById('cp-editor').innerHTML,
|
||||
body_text: document.getElementById('cp-editor').innerText,
|
||||
in_reply_to_id: replyId || 0,
|
||||
forward_from_id: forwardId || 0,
|
||||
};
|
||||
|
||||
let r;
|
||||
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
|
||||
if (cpAttachments.length) {
|
||||
const fd = new FormData();
|
||||
fd.append('meta', JSON.stringify(meta));
|
||||
cpAttachments.forEach(f => fd.append('file', f, f.name));
|
||||
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
|
||||
r = await resp.json().catch(() => null);
|
||||
} else {
|
||||
r = await apiCall('POST', endpoint, meta);
|
||||
}
|
||||
|
||||
btn.disabled = false; btn.textContent = 'Send';
|
||||
if (r?.ok) {
|
||||
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
|
||||
document.getElementById('compose-page-form').style.opacity = '0.5';
|
||||
document.getElementById('compose-page-form').style.pointerEvents = 'none';
|
||||
} else {
|
||||
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
|
||||
}
|
||||
|
||||
// Init
|
||||
initTagField('cp-to-tags');
|
||||
initTagField('cp-cc-tags');
|
||||
loadAccounts();
|
||||
if (replyId) prefillReply();
|
||||
else if (forwardId) prefillForward();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -19,7 +19,7 @@
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||
const k=new URLSearchParams(location.search).get('error');
|
||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||
</script>
|
||||
|
||||
@@ -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