Compare commits

..
8 Commits
58 changed files with 11151 additions and 892 deletions
+23 -1
View File
@@ -6,4 +6,26 @@ data/gowebmail.conf
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace
webmail.code-workspace
graphify-out
GEMINI.md
tests/
android/build/
android/app/build/
android/app/release/
android/.gradle/
android/.idea/
android/.kotlin/
android/captures/
android/*.iml
android/app/*.iml
android/local.properties
android/keystore.properties
android/*.keystore
android/*.jks
android/*.apk
android/*.aab
android/**/.cxx/
android/**/.externalNativeBuild/
+91 -1
View File
@@ -143,6 +143,26 @@ func main() {
w.Header().Set("Content-Type", "image/png")
w.Write(data)
})
// PWA manifest + service worker — served at root (not /static/) so the service worker's
// default scope covers the whole app, not just /static/.
r.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/manifest.json")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/manifest+json")
w.Write(data)
})
r.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/sw.js")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/javascript")
w.Write(data)
})
// Public auth routes
auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
@@ -204,6 +224,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")
@@ -218,23 +239,42 @@ func main() {
api.HandleFunc("/messages/{id:[0-9]+}/read", h.API.MarkRead).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.SnoozeMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.UnsnoozeMessage).Methods("DELETE")
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/snoozed", h.API.SnoozedMessages).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")
api.HandleFunc("/remote-content-whitelist", h.API.AddRemoteContentWhitelist).Methods("POST")
api.HandleFunc("/remote-content-whitelist", h.API.DeleteRemoteContentWhitelist).Methods("DELETE")
// Spam blocklist
api.HandleFunc("/spam-block", h.API.ListSpamBlock).Methods("GET")
api.HandleFunc("/spam-block", h.API.AddSpamBlock).Methods("POST")
api.HandleFunc("/spam-block", h.API.DeleteSpamBlock).Methods("DELETE")
// Send
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")
api.HandleFunc("/send-later", h.API.CreateScheduledSend).Methods("POST")
api.HandleFunc("/scheduled-sends", h.API.ListScheduledSends).Methods("GET")
api.HandleFunc("/scheduled-sends/{id:[0-9]+}", h.API.CancelScheduledSend).Methods("DELETE")
// Folders
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
@@ -245,8 +285,10 @@ func main() {
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]+}/export", h.API.ExportFolder).Methods("GET")
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")
@@ -257,6 +299,12 @@ func main() {
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
// Web Push (background new-mail notifications)
api.HandleFunc("/push/vapid-public-key", h.API.GetVAPIDPublicKey).Methods("GET")
api.HandleFunc("/push/subscribe", h.API.SubscribePush).Methods("POST")
api.HandleFunc("/push/unsubscribe", h.API.UnsubscribePush).Methods("POST")
api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET")
// Search
api.HandleFunc("/search", h.API.Search).Methods("GET")
@@ -282,6 +330,38 @@ func main() {
// 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))
@@ -308,6 +388,16 @@ func main() {
}
}()
// Deliver due scheduled sends and wake expired message snoozes
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
h.API.ProcessDueScheduledSends()
h.API.WakeExpiredSnoozes()
}
}()
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: r,
+35 -2
View File
@@ -11,6 +11,8 @@ import (
"os"
"strconv"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
)
// Config holds all application configuration.
@@ -61,6 +63,10 @@ type Config struct {
MicrosoftClientSecret string
MicrosoftTenantID string
MicrosoftRedirectURL string // auto-derived from BaseURL if blank
// Web Push (VAPID) — signs background push notifications to browsers/mobile PWAs
VAPIDPublicKey string
VAPIDPrivateKey string
}
const configPath = "./data/gowebmail.conf"
@@ -325,6 +331,20 @@ var allFields = []configField{
"Must exactly match what is registered in Azure.",
},
},
{
key: "VAPID_PUBLIC_KEY",
defVal: "",
comments: []string{
"--- Web Push Notifications ---",
"VAPID keypair signing background push notifications (browser + mobile PWA/Android wrapper).",
"Auto-generated on first run. Do not edit manually.",
},
},
{
key: "VAPID_PRIVATE_KEY",
defVal: "",
comments: []string{},
},
}
// Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config.
@@ -347,6 +367,14 @@ func Load() (*Config, error) {
if existing["SESSION_SECRET"] == "" {
existing["SESSION_SECRET"] = mustHex(32)
}
if existing["VAPID_PUBLIC_KEY"] == "" || existing["VAPID_PRIVATE_KEY"] == "" {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
return nil, fmt.Errorf("generate VAPID keys: %w", err)
}
existing["VAPID_PRIVATE_KEY"] = priv
existing["VAPID_PUBLIC_KEY"] = pub
}
// Write back (preserves existing, adds any new fields from allFields)
if err := writeConfigFile(configPath, existing); err != nil {
@@ -463,6 +491,9 @@ func Load() (*Config, error) {
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
MicrosoftRedirectURL: outlookRedirect,
VAPIDPublicKey: get("VAPID_PUBLIC_KEY"),
VAPIDPrivateKey: get("VAPID_PRIVATE_KEY"),
}
// Derive SECURE_COOKIE automatically if BASE_URL uses https
@@ -652,8 +683,10 @@ func writeConfigFile(path string, values map[string]string) error {
// SESSION_SECRET and ENCRYPTION_KEY are intentionally excluded.
var EditableKeys = func() map[string]bool {
excluded := map[string]bool{
"SESSION_SECRET": true,
"ENCRYPTION_KEY": true,
"SESSION_SECRET": true,
"ENCRYPTION_KEY": true,
"VAPID_PUBLIC_KEY": true,
"VAPID_PRIVATE_KEY": true,
}
m := map[string]bool{}
for _, f := range allFields {
-90
View File
@@ -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 =
+15 -4
View File
@@ -1,17 +1,28 @@
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.34
golang.org/x/crypto v0.49.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/SherClockHolmes/webpush-go v1.4.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
golang.org/x/text v0.35.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // 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
)
+90 -6
View File
@@ -1,21 +1,105 @@
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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
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/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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
+157
View File
@@ -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),
}
}
+1269 -60
View File
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
package db
import (
"path/filepath"
"testing"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
// newTestDB creates a fresh, migrated DB backed by a temp file (WAL mode needs a real file,
// not :memory:) and returns it along with the bootstrap admin user's ID (always 1 — Migrate
// creates it when no users exist).
func newTestDB(t *testing.T) (*DB, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
d, err := New(path, key)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return d, 1 // bootstrap admin
}
// seedAccountAndFolder creates a minimal IMAP account + INBOX folder for userID, returning
// their IDs.
func seedAccountAndFolder(t *testing.T, d *DB, userID int64) (accountID, folderID int64) {
t.Helper()
acc := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: "user@example.com", DisplayName: "Test User",
IMAPHost: "imap.example.com", IMAPPort: 993,
SMTPHost: "smtp.example.com", SMTPPort: 587,
Color: "#4A90D9",
}
if err := d.CreateAccount(acc); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
f, err := d.GetFolderByPath(acc.ID, "INBOX")
if err != nil || f == nil {
t.Fatalf("GetFolderByPath: %v", err)
}
return acc.ID, f.ID
}
func seedMessage(t *testing.T, d *DB, accountID, folderID int64, remoteUID, subject string) int64 {
t.Helper()
m := &models.Message{
AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID,
Subject: subject, FromName: "Sender Name", FromEmail: "sender@example.com",
ToList: "user@example.com", BodyText: "hello world", Date: time.Now(),
}
if err := d.UpsertMessage(m); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
if m.ID == 0 {
t.Fatalf("UpsertMessage did not populate ID")
}
return m.ID
}
// ---- Encryption round-trip ----
func TestMessageEncryptionRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
const subject = `Subject with "quotes", unicode ✉️ and a semicolon; and a % sign`
msgID := seedMessage(t, d, accountID, folderID, "100", subject)
got, err := d.GetMessage(msgID, userID)
if err != nil || got == nil {
t.Fatalf("GetMessage: %v", err)
}
if got.Subject != subject {
t.Errorf("Subject = %q, want %q", got.Subject, subject)
}
if got.FromEmail != "sender@example.com" {
t.Errorf("FromEmail = %q", got.FromEmail)
}
}
func TestGetMessage_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "100", "secret")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
got, err := d.GetMessage(msgID, other.ID)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if got != nil {
t.Errorf("expected nil for another user's message, got %+v", got)
}
}
// ---- ListMessages / snooze filtering ----
func TestListMessages_ExcludesFutureSnoozed(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
visibleID := seedMessage(t, d, accountID, folderID, "1", "visible")
snoozedID := seedMessage(t, d, accountID, folderID, "2", "snoozed")
if err := d.SnoozeMessage(snoozedID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if page.Total != 1 {
t.Fatalf("Total = %d, want 1 (snoozed message should be excluded)", page.Total)
}
if len(page.Messages) != 1 || page.Messages[0].ID != visibleID {
t.Fatalf("Messages = %+v, want only %d", page.Messages, visibleID)
}
}
func TestListMessages_IncludesPastSnoozed(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "was snoozed")
if err := d.SnoozeMessage(msgID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
// Simulate the snooze having already expired (SnoozeMessage validates nothing server-side
// about "future", so write an already-past timestamp directly).
if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), msgID); err != nil {
t.Fatalf("backdate snooze: %v", err)
}
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if page.Total != 1 {
t.Fatalf("Total = %d, want 1 (past-snooze message should be visible again)", page.Total)
}
}
// ---- Snooze / unsnooze / wake ----
func TestSnoozeUnsnoozeRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "snooze me")
until := time.Now().Add(2 * time.Hour)
if err := d.SnoozeMessage(msgID, userID, until); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
snoozed, err := d.ListSnoozedMessages(userID, 1, 50)
if err != nil {
t.Fatalf("ListSnoozedMessages: %v", err)
}
if snoozed.Total != 1 || snoozed.Messages[0].ID != msgID {
t.Fatalf("ListSnoozedMessages = %+v, want [%d]", snoozed.Messages, msgID)
}
if snoozed.Messages[0].SnoozedUntil == nil {
t.Fatalf("SnoozedUntil not populated")
}
if err := d.UnsnoozeMessage(msgID, userID); err != nil {
t.Fatalf("UnsnoozeMessage: %v", err)
}
snoozed, err = d.ListSnoozedMessages(userID, 1, 50)
if err != nil {
t.Fatalf("ListSnoozedMessages after unsnooze: %v", err)
}
if snoozed.Total != 0 {
t.Fatalf("Total = %d after unsnooze, want 0", snoozed.Total)
}
}
func TestSnoozeMessage_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Attempting to snooze someone else's message must be a silent no-op (0 rows affected),
// not an error and not a mutation.
if err := d.SnoozeMessage(msgID, other.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("SnoozeMessage (other user): %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.SnoozedUntil != nil {
t.Errorf("message got snoozed by a non-owning user: %+v", msg.SnoozedUntil)
}
}
func TestWakeExpiredSnoozes(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
expiredID := seedMessage(t, d, accountID, folderID, "1", "expired")
futureID := seedMessage(t, d, accountID, folderID, "2", "future")
if err := d.SnoozeMessage(expiredID, userID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), expiredID); err != nil {
t.Fatalf("backdate: %v", err)
}
if err := d.SnoozeMessage(futureID, userID, time.Now().Add(24*time.Hour)); err != nil {
t.Fatalf("SnoozeMessage: %v", err)
}
// Mark both read=0 initially is already the UpsertMessage default; flip expired one to
// read=1 so we can prove WakeExpiredSnoozes resets it to unread.
if _, err := d.sql.Exec(`UPDATE messages SET is_read=1 WHERE id=?`, expiredID); err != nil {
t.Fatalf("mark read: %v", err)
}
folderIDs, err := d.WakeExpiredSnoozes()
if err != nil {
t.Fatalf("WakeExpiredSnoozes: %v", err)
}
if len(folderIDs) != 1 || folderIDs[0] != folderID {
t.Fatalf("folderIDs = %v, want [%d]", folderIDs, folderID)
}
expired, err := d.GetMessage(expiredID, userID)
if err != nil || expired == nil {
t.Fatalf("GetMessage(expired): %v", err)
}
if expired.SnoozedUntil != nil {
t.Errorf("expired message still snoozed: %+v", expired.SnoozedUntil)
}
if expired.IsRead {
t.Errorf("expired message should be marked unread on wake")
}
// GetMessage doesn't project snoozed_until (only the Snoozed-view listing does), so check
// the future message is still excluded from the normal folder listing instead.
page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
for _, m := range page.Messages {
if m.ID == futureID {
t.Errorf("future-snoozed message reappeared in folder listing after wake sweep")
}
}
}
// ---- Scheduled sends ----
func TestScheduledSendRoundTrip(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
s := &models.ScheduledSend{
UserID: userID, AccountID: accountID,
To: []string{"a@example.com", "b@example.com"},
CC: []string{"c@example.com"},
Subject: `Meeting notes — "Q3 review"`, BodyHTML: "<p>hi</p>", BodyText: "hi",
ForwardFromIDs: []int64{42},
SendAt: time.Now().Add(time.Hour),
}
id, err := d.CreateScheduledSend(s)
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
if id == 0 {
t.Fatalf("CreateScheduledSend returned id=0")
}
list, err := d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends: %v", err)
}
if len(list) != 1 {
t.Fatalf("ListScheduledSends returned %d items, want 1", len(list))
}
got := list[0]
if got.Subject != s.Subject {
t.Errorf("Subject = %q, want %q", got.Subject, s.Subject)
}
if len(got.To) != 2 || got.To[0] != "a@example.com" || got.To[1] != "b@example.com" {
t.Errorf("To = %v", got.To)
}
if len(got.CC) != 1 || got.CC[0] != "c@example.com" {
t.Errorf("CC = %v", got.CC)
}
if len(got.ForwardFromIDs) != 1 || got.ForwardFromIDs[0] != 42 {
t.Errorf("ForwardFromIDs = %v", got.ForwardFromIDs)
}
// Not due yet (send_at is an hour out).
due, err := d.ListDueScheduledSends()
if err != nil {
t.Fatalf("ListDueScheduledSends: %v", err)
}
if len(due) != 0 {
t.Fatalf("ListDueScheduledSends = %d items, want 0 (not due yet)", len(due))
}
if err := d.DeleteScheduledSend(id, userID); err != nil {
t.Fatalf("DeleteScheduledSend: %v", err)
}
list, err = d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends after delete: %v", err)
}
if len(list) != 0 {
t.Fatalf("ListScheduledSends after delete = %d, want 0", len(list))
}
}
func TestListDueScheduledSends(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
dueID, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "due", SendAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
// Backdate it into the past so it's due.
if _, err := d.sql.Exec(`UPDATE scheduled_sends SET send_at=? WHERE id=?`,
time.Now().Add(-time.Minute).UTC().Format("2006-01-02 15:04:05"), dueID); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "not due", SendAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
due, err := d.ListDueScheduledSends()
if err != nil {
t.Fatalf("ListDueScheduledSends: %v", err)
}
if len(due) != 1 || due[0].ID != dueID {
t.Fatalf("ListDueScheduledSends = %+v, want only id=%d", due, dueID)
}
}
func TestDeleteScheduledSend_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, _ := seedAccountAndFolder(t, d, userID)
id, err := d.CreateScheduledSend(&models.ScheduledSend{
UserID: userID, AccountID: accountID, To: []string{"a@example.com"},
Subject: "mine", SendAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateScheduledSend: %v", err)
}
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := d.DeleteScheduledSend(id, other.ID); err != nil {
t.Fatalf("DeleteScheduledSend: %v", err)
}
list, err := d.ListScheduledSends(userID)
if err != nil {
t.Fatalf("ListScheduledSends: %v", err)
}
if len(list) != 1 {
t.Fatalf("scheduled send was deleted by a non-owning user; list = %+v", list)
}
}
// ---- Labels ----
func TestLabelCRUDAndAssignment(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "label me")
// userID (the bootstrap admin) already has the 4 seeded default labels — use a name that
// doesn't collide with those ("Important", "Personal", "Work", "ToDo").
baseline, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels (baseline): %v", err)
}
label, err := d.CreateLabel(userID, "Project Zeta", "#e74c3c")
if err != nil {
t.Fatalf("CreateLabel: %v", err)
}
if label.ID == 0 {
t.Fatalf("CreateLabel returned id=0")
}
if _, err := d.CreateLabel(userID, "Project Zeta", "#000000"); err == nil {
t.Errorf("expected duplicate label name to fail")
}
if err := d.AssignLabel(msgID, label.ID, userID); err != nil {
t.Fatalf("AssignLabel: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 1 || msg.Labels[0].ID != label.ID {
t.Fatalf("Labels = %+v, want [%d]", msg.Labels, label.ID)
}
if err := d.UpdateLabel(label.ID, userID, "Project Zeta Renamed", "#ff0000"); err != nil {
t.Fatalf("UpdateLabel: %v", err)
}
labels, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels: %v", err)
}
if len(labels) != len(baseline)+1 {
t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1)
}
found := false
for _, l := range labels {
if l.ID == label.ID {
found = true
if l.Name != "Project Zeta Renamed" {
t.Errorf("renamed label Name = %q", l.Name)
}
}
}
if !found {
t.Fatalf("renamed label not found in ListLabels: %+v", labels)
}
if err := d.UnassignLabel(msgID, label.ID, userID); err != nil {
t.Fatalf("UnassignLabel: %v", err)
}
msg, err = d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 0 {
t.Fatalf("Labels after unassign = %+v, want none", msg.Labels)
}
if err := d.DeleteLabel(label.ID, userID); err != nil {
t.Fatalf("DeleteLabel: %v", err)
}
labels, err = d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels after delete: %v", err)
}
if len(labels) != len(baseline) {
t.Fatalf("ListLabels after delete = %+v, want back to baseline %+v", labels, baseline)
}
}
func TestAssignLabel_CannotCrossUserBoundary(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "protected")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
label, err := d.CreateLabel(other.ID, "Bob's label", "#123456")
if err != nil {
t.Fatalf("CreateLabel: %v", err)
}
// Bob tries to label userID's message with his own label — must be a no-op.
if err := d.AssignLabel(msgID, label.ID, other.ID); err != nil {
t.Fatalf("AssignLabel: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil || msg == nil {
t.Fatalf("GetMessage: %v", err)
}
if len(msg.Labels) != 0 {
t.Errorf("cross-user label assignment succeeded: %+v", msg.Labels)
}
}
// ---- Folder export support ----
func TestListMessageIDsByFolder(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
id1 := seedMessage(t, d, accountID, folderID, "1", "one")
id2 := seedMessage(t, d, accountID, folderID, "2", "two")
ids, err := d.ListMessageIDsByFolder(folderID, userID)
if err != nil {
t.Fatalf("ListMessageIDsByFolder: %v", err)
}
if len(ids) != 2 {
t.Fatalf("ids = %v, want 2 entries", ids)
}
got := map[int64]bool{ids[0]: true, ids[1]: true}
if !got[id1] || !got[id2] {
t.Errorf("ids = %v, want %d and %d", ids, id1, id2)
}
}
func TestListMessageIDsByFolder_WrongUserScoped(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
seedMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
ids, err := d.ListMessageIDsByFolder(folderID, other.ID)
if err != nil {
t.Fatalf("ListMessageIDsByFolder: %v", err)
}
if len(ids) != 0 {
t.Errorf("non-owning user got message IDs from another user's folder: %v", ids)
}
}
// ---- Delete / star (existing behavior, previously untested) ----
func TestDeleteMessage(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "delete me")
if err := d.DeleteMessage(msgID, userID); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
msg, err := d.GetMessage(msgID, userID)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg != nil {
t.Errorf("message still present after delete: %+v", msg)
}
}
func TestToggleMessageStar(t *testing.T) {
d, userID := newTestDB(t)
accountID, folderID := seedAccountAndFolder(t, d, userID)
msgID := seedMessage(t, d, accountID, folderID, "1", "star me")
starred, err := d.ToggleMessageStar(msgID, userID)
if err != nil {
t.Fatalf("ToggleMessageStar: %v", err)
}
if !starred {
t.Errorf("expected starred=true after first toggle")
}
starred, err = d.ToggleMessageStar(msgID, userID)
if err != nil {
t.Fatalf("ToggleMessageStar: %v", err)
}
if starred {
t.Errorf("expected starred=false after second toggle")
}
}
+107
View File
@@ -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
}
+152
View File
@@ -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
}
+104
View File
@@ -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
}
+125
View File
@@ -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
}
+301 -44
View File
@@ -4,8 +4,11 @@ package email
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -13,6 +16,7 @@ import (
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"path/filepath"
@@ -26,6 +30,50 @@ import (
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:
@@ -111,21 +159,9 @@ 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 {
@@ -172,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 {
@@ -181,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 {
@@ -326,11 +463,14 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
}
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
// Fetch FetchRFC822 (full raw message) so we can properly parse MIME
// Full raw message, needed for proper MIME parsing — fetched via BODY.PEEK[] (not the
// plain RFC822/BODY[] item) so reading it during a background sync doesn't implicitly
// mark the message \Seen on the server before the user has actually opened it.
peekBody := &imap.BodySectionName{Peek: true}
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822, // full message including headers needed for proper MIME parsing
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -354,10 +494,11 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -884,9 +1025,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
@@ -896,26 +1065,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)
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
var c *smtp.Client
var err error
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
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)
var c *smtp.Client
// 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"
@@ -1137,27 +1316,72 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
// Returns the folder name that was used (for sync purposes).
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
// 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
}
var draftsName string
for _, mb := range mailboxes {
ft := InferFolderType(mb.Name, mb.Attributes)
if ft == "drafts" {
draftsName = mb.Name
break
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 "", nil // no Drafts folder, skip silently
return "", 0, nil // no Drafts folder, skip silently
}
if prevUID != 0 {
_ = c.DeleteByUID(draftsName, prevUID, "")
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
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
@@ -1340,6 +1564,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)
@@ -1356,6 +1593,25 @@ func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
return uids, nil
}
// FetchByUIDs fetches specific messages by UID, regardless of the incremental last_seen_uid
// cursor — used by the sync reconciliation pass (see syncer.syncFolder) to recover messages
// that exist on the server but are missing from the local cache, so a local-only data loss
// (from any cause) self-heals on the next sync instead of leaving that message permanently
// unreachable (incremental fetch only ever asks for UIDs newer than what it last saw).
func (c *Client) FetchByUIDs(mailboxName string, uids []uint32) ([]*gomailModels.Message, error) {
if len(uids) == 0 {
return nil, nil
}
if _, err := c.imap.Select(mailboxName, true); err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
seqSet := new(imap.SeqSet)
for _, uid := range uids {
seqSet.AddNum(uid)
}
return c.fetchByUIDSet(seqSet)
}
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
mbox, err := c.imap.Select(mailboxName, true)
@@ -1370,10 +1626,11 @@ func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomai
seqSet := new(imap.SeqSet)
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
+47
View File
@@ -0,0 +1,47 @@
package email
import "testing"
// InferFolderType drives how the sync engine classifies each IMAP folder (inbox/sent/drafts/
// trash/spam/archive/custom) — used for default folder discovery, DeleteByUID's trash-move
// target lookup, and rule actions like "mark_as_spam". Covers both the IMAP SPECIAL-USE
// attribute path (authoritative when the server sends it) and the name-guessing fallback.
func TestInferFolderType(t *testing.T) {
cases := []struct {
name string
folderName string
attrs []string
want string
}{
{"special-use inbox", "Whatever", []string{`\Inbox`}, "inbox"},
{"special-use sent", "Whatever", []string{`\Sent`}, "sent"},
{"special-use drafts", "Whatever", []string{`\Drafts`}, "drafts"},
{"special-use trash", "Whatever", []string{`\Trash`}, "trash"},
{"special-use deleted alias", "Whatever", []string{`\Deleted`}, "trash"},
{"special-use junk", "Whatever", []string{`\Junk`}, "spam"},
{"special-use spam alias", "Whatever", []string{`\Spam`}, "spam"},
{"special-use archive", "Whatever", []string{`\Archive`}, "archive"},
{"special-use case-insensitive", "Whatever", []string{`\SENT`}, "sent"},
{"special-use wins over misleading name", "Trash Talk", []string{`\Sent`}, "sent"},
{"name INBOX exact", "INBOX", nil, "inbox"},
{"name lowercase inbox", "inbox", nil, "inbox"},
{"name contains sent", "Sent Items", nil, "sent"},
{"name contains draft", "Drafts", nil, "drafts"},
{"name contains trash", "Trash", nil, "trash"},
{"name contains deleted", "Deleted Items", nil, "trash"},
{"name contains spam", "Spam", nil, "spam"},
{"name contains junk", "Junk E-mail", nil, "spam"},
{"name contains archive", "Archive", nil, "archive"},
{"unrecognized name is custom", "Projects", nil, "custom"},
{"gmail-style path", "[Gmail]/Sent Mail", nil, "sent"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferFolderType(tc.folderName, tc.attrs)
if got != tc.want {
t.Errorf("InferFolderType(%q, %v) = %q, want %q", tc.folderName, tc.attrs, got, tc.want)
}
})
}
}
+48
View File
@@ -0,0 +1,48 @@
package email
import (
"context"
"fmt"
"github.com/ghostersk/gowebmail/internal/jmap"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// SaveDraftJMAP saves req as a draft on the account's JMAP server, mirroring
// AppendToDrafts' replace-in-place behavior: if prevID is non-empty that earlier draft
// copy is deleted first (best-effort — a failure there shouldn't block saving the new
// one), then the new message is uploaded + imported into the Drafts mailbox and flagged
// $draft. Returns the new draft's email id.
func SaveDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, prevID string) (string, error) {
rawMsg, err := BuildRawMessage(account, req, nil)
if err != nil {
return "", err
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
draftsID, err := jc.FindMailboxByRole(ctx, "drafts")
if err != nil {
return "", fmt.Errorf("jmap find Drafts folder: %w", err)
}
if prevID != "" {
_ = jc.DeleteEmail(ctx, prevID)
}
blobID, err := jc.UploadBlob(ctx, rawMsg)
if err != nil {
return "", fmt.Errorf("jmap upload draft: %w", err)
}
newID, err := jc.ImportEmail(ctx, blobID, draftsID)
if err != nil {
return "", fmt.Errorf("jmap import draft: %w", err)
}
_ = jc.SetKeyword(ctx, newID, "$draft", true)
return newID, nil
}
// DeleteDraftJMAP deletes a previously-autosaved draft by id.
func DeleteDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, id string) error {
if id == "" {
return nil
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
return jc.DeleteEmail(ctx, id)
}
+27
View File
@@ -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)
}
+61
View File
@@ -412,6 +412,67 @@ func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error
return nil
}
func (c *Client) post(ctx context.Context, path string, body map[string]interface{}, out interface{}) error {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("graph POST %s returned %d: %s", path, resp.StatusCode, string(errBody))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func draftBody(req *models.ComposeRequest) map[string]interface{} {
body := map[string]string{"contentType": "HTML", "content": req.BodyHTML}
if req.BodyHTML == "" {
body["contentType"] = "Text"
body["content"] = req.BodyText
}
return map[string]interface{}{
"subject": req.Subject,
"body": body,
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
}
// CreateDraft creates a new draft message (POST /me/messages, which — unlike /sendMail —
// files into Drafts instead of sending) and returns its Graph message id.
func (c *Client) CreateDraft(ctx context.Context, req *models.ComposeRequest) (string, error) {
var out struct {
ID string `json:"id"`
}
if err := c.post(ctx, "/messages", draftBody(req), &out); err != nil {
return "", err
}
return out.ID, nil
}
// UpdateDraft overwrites an existing draft's subject/body/recipients in place.
func (c *Client) UpdateDraft(ctx context.Context, draftID string, req *models.ComposeRequest) error {
return c.patch(ctx, "/messages/"+draftID, draftBody(req))
}
// DeleteDraft deletes a draft message by id — used when the user closes a compose panel and
// chooses not to keep the draft that autosave already wrote to the server.
func (c *Client) DeleteDraft(ctx context.Context, draftID string) error {
return c.deleteReq(ctx, "/messages/"+draftID)
}
func graphRecipients(addrs []string) []map[string]interface{} {
result := []map[string]interface{}{}
for _, a := range addrs {
+948 -136
View File
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// newTestHandler builds an APIHandler backed by a fresh, migrated temp-file DB, with no
// syncer/cfg — sufficient for the local-only handlers under test here (Labels, Snooze,
// Send-later, Folder export), none of which touch IMAP/Graph/JMAP or config.
func newTestHandler(t *testing.T) (*APIHandler, *db.DB, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
d, err := db.New(path, key)
if err != nil {
t.Fatalf("db.New: %v", err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return &APIHandler{db: d}, d, 1 // bootstrap admin
}
func seedTestAccountAndFolder(t *testing.T, d *db.DB, userID int64) (accountID, folderID int64) {
t.Helper()
acc := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: "user@example.com", DisplayName: "Test User", Color: "#4A90D9",
}
if err := d.CreateAccount(acc); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
f, err := d.GetFolderByPath(acc.ID, "INBOX")
if err != nil || f == nil {
t.Fatalf("GetFolderByPath: %v", err)
}
return acc.ID, f.ID
}
func seedTestMessage(t *testing.T, d *db.DB, accountID, folderID int64, remoteUID, subject string) int64 {
t.Helper()
m := &models.Message{
AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID,
Subject: subject, FromName: "Sender", FromEmail: "sender@example.com",
ToList: "user@example.com", BodyText: "hello", Date: time.Now(),
}
if err := d.UpsertMessage(m); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
return m.ID
}
// authedRequest builds a request carrying userID the way RequireAuth middleware would (via
// context), with mux path vars set directly (bypassing the router) and an optional JSON body.
func authedRequest(t *testing.T, method, target string, userID int64, vars map[string]string, body interface{}) *http.Request {
t.Helper()
var r *http.Request
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
r = httptest.NewRequest(method, target, bytes.NewReader(b))
} else {
r = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID)
r = r.WithContext(ctx)
if vars != nil {
r = mux.SetURLVars(r, vars)
}
return r
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder, v interface{}) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
}
// ---- Labels ----
func TestCreateAndListLabels(t *testing.T) {
h, d, userID := newTestHandler(t)
baseline, err := d.ListLabels(userID)
if err != nil {
t.Fatalf("ListLabels (baseline): %v", err)
}
rec := httptest.NewRecorder()
h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "Project Zeta", "Color": "#abcdef"}))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CreateLabel status = %d, body = %s", rec.Code, rec.Body.String())
}
var created models.Label
decodeJSON(t, rec, &created)
if created.ID == 0 || created.Name != "Project Zeta" {
t.Fatalf("created label = %+v", created)
}
rec = httptest.NewRecorder()
h.ListLabels(rec, authedRequest(t, "GET", "/api/labels", userID, nil, nil))
var labels []models.Label
decodeJSON(t, rec, &labels)
if len(labels) != len(baseline)+1 {
t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1)
}
}
func TestCreateLabel_MissingFields(t *testing.T) {
h, _, userID := newTestHandler(t)
rec := httptest.NewRecorder()
h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "", "Color": "#fff"}))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
// ---- Snooze ----
func TestSnoozeMessage_Handler(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
msgID := seedTestMessage(t, d, accountID, folderID, "1", "snooze via handler")
until := time.Now().Add(time.Hour).Format(time.RFC3339)
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(msgID)}
h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{"until": until}))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("SnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil))
var page models.PagedMessages
decodeJSON(t, rec, &page)
if page.Total != 1 || len(page.Messages) != 1 || page.Messages[0].ID != msgID {
t.Fatalf("SnoozedMessages = %+v", page)
}
rec = httptest.NewRecorder()
h.UnsnoozeMessage(rec, authedRequest(t, "DELETE", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("UnsnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil))
decodeJSON(t, rec, &page)
if page.Total != 0 {
t.Fatalf("SnoozedMessages after unsnooze = %+v, want empty", page)
}
}
func TestSnoozeMessage_RejectsMissingUntil(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
msgID := seedTestMessage(t, d, accountID, folderID, "1", "no until")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(msgID)}
h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{}))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// ---- Send-later ----
func TestCreateScheduledSend_RejectsPastDate(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "hi", "send_at": time.Now().Add(-time.Hour).Format(time.RFC3339),
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestCreateScheduledSend_RejectsFileAttachments(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "hi", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339),
"attachments": []map[string]string{{"filename": "x.pdf", "content_type": "application/pdf"}},
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestScheduledSend_CreateListCancel(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, _ := seedTestAccountAndFolder(t, d, userID)
body := map[string]interface{}{
"account_id": accountID, "to": []string{"a@example.com"},
"subject": "Scheduled", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339),
}
rec := httptest.NewRecorder()
h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CreateScheduledSend status = %d, body = %s", rec.Code, rec.Body.String())
}
var created struct {
OK bool `json:"ok"`
ID int64 `json:"id"`
}
decodeJSON(t, rec, &created)
if !created.OK || created.ID == 0 {
t.Fatalf("CreateScheduledSend result = %+v", created)
}
rec = httptest.NewRecorder()
h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil))
var list []models.ScheduledSend
decodeJSON(t, rec, &list)
if len(list) != 1 || list[0].ID != created.ID {
t.Fatalf("ListScheduledSends = %+v", list)
}
rec = httptest.NewRecorder()
vars := map[string]string{"id": itoa(created.ID)}
h.CancelScheduledSend(rec, authedRequest(t, "DELETE", "/api/scheduled-sends/"+itoa(created.ID), userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("CancelScheduledSend status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil))
decodeJSON(t, rec, &list)
if len(list) != 0 {
t.Fatalf("ListScheduledSends after cancel = %+v, want empty", list)
}
}
// ---- Folder export ----
func TestExportFolder_Zip(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "one")
seedTestMessage(t, d, accountID, folderID, "2", "two")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
target := "/api/folders/" + itoa(folderID) + "/export?format=zip"
h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Errorf("Content-Type = %q", ct)
}
body := rec.Body.Bytes()
if len(body) < 2 || string(body[:2]) != "PK" {
t.Errorf("body doesn't look like a zip (got %d bytes, prefix %q)", len(body), body[:min(4, len(body))])
}
}
func TestExportFolder_Mbox(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "one")
seedTestMessage(t, d, accountID, folderID, "2", "two")
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
target := "/api/folders/" + itoa(folderID) + "/export?format=mbox"
h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil))
if rec.Code != http.StatusOK && rec.Code != 0 {
t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/mbox" {
t.Errorf("Content-Type = %q", ct)
}
body := rec.Body.String()
count := bytesCount(body, "From MAILER-DAEMON")
if count != 2 {
t.Errorf("mbox has %d envelope lines, want 2; body:\n%s", count, body)
}
}
func TestExportFolder_EmptyFolderRejected(t *testing.T) {
h, d, userID := newTestHandler(t)
_, folderID := seedTestAccountAndFolder(t, d, userID)
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", userID, vars, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestExportFolder_WrongUserScoped(t *testing.T) {
h, d, userID := newTestHandler(t)
accountID, folderID := seedTestAccountAndFolder(t, d, userID)
seedTestMessage(t, d, accountID, folderID, "1", "not yours")
other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
rec := httptest.NewRecorder()
vars := map[string]string{"id": itoa(folderID)}
h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", other.ID, vars, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("non-owning user's export status = %d, want %d (folder empty for them); body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
// ---- small local helpers ----
func itoa(id int64) string {
if id == 0 {
return "0"
}
neg := id < 0
if neg {
id = -id
}
var buf [20]byte
i := len(buf)
for id > 0 {
i--
buf[i] = byte('0' + id%10)
id /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
func bytesCount(s, substr string) int {
count := 0
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
count++
i += len(substr) - 1
}
}
return count
}
+5
View File
@@ -20,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"
)
@@ -30,6 +31,7 @@ type AuthHandler struct {
cfg *config.Config
renderer *Renderer
syncer interface{ TriggerReconcile() }
pgpCache *pgp.Cache
}
// ---- Login ----
@@ -101,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: "/",
+462
View File
@@ -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})
}
+7 -2
View File
@@ -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, syncer: sc},
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},
}
}
+60
View File
@@ -0,0 +1,60 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/ghostersk/gowebmail/internal/middleware"
)
// GetVAPIDPublicKey exposes the server's VAPID public key so the frontend can pass it to
// PushManager.subscribe({applicationServerKey: ...}).
func (h *APIHandler) GetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]string{"public_key": h.cfg.VAPIDPublicKey})
}
// SubscribePush stores a browser/WebView's PushSubscription (from
// PushManager.subscribe().toJSON()) so background new-mail push can reach it.
func (h *APIHandler) SubscribePush(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024))
if err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
var sub webpush.Subscription
if err := json.Unmarshal(body, &sub); err != nil || sub.Endpoint == "" || sub.Keys.P256dh == "" || sub.Keys.Auth == "" {
h.writeError(w, http.StatusBadRequest, "invalid push subscription")
return
}
if err := h.db.UpsertPushSubscription(userID, sub.Endpoint, sub.Keys.P256dh, sub.Keys.Auth); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save subscription")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// UnsubscribePush removes a subscription by endpoint (sent when the user disables the
// notifications toggle, or the browser reports the subscription changed/expired).
func (h *APIHandler) UnsubscribePush(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024))
if err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
var req struct {
Endpoint string `json:"endpoint"`
}
if err := json.Unmarshal(body, &req); err != nil || req.Endpoint == "" {
h.writeError(w, http.StatusBadRequest, "endpoint required")
return
}
if err := h.db.DeletePushSubscription(userID, req.Endpoint); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to remove subscription")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
+153
View File
@@ -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})
}
+106
View File
@@ -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})
}
+507
View File
@@ -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
}
+167 -18
View File
@@ -87,6 +87,7 @@ const (
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).
@@ -100,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"
@@ -118,6 +126,23 @@ type EmailAccount struct {
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"
}
// SpamBlockEntry pairs a blocked sender address with when it was added — Settings >
// Security > Spam Block.
type SpamBlockEntry struct {
Sender string `json:"sender"`
CreatedAt time.Time `json:"created_at"`
}
// Folder represents a mailbox folder or Gmail label.
type Folder struct {
ID int64 `json:"id"`
@@ -179,26 +204,51 @@ type Message struct {
IsStarred bool `json:"is_starred"`
IsDraft bool `json:"is_draft"`
HasAttachment bool `json:"has_attachment"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Labels []Label `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// MessageSummary is a lightweight version for list views.
type MessageSummary struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
HasAttachment bool `json:"has_attachment"`
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountName string `json:"account_name"` // account's own display_name (may be blank)
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
ToList string `json:"to_list"` // comma-separated; only shown in the Sent folder view
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
HasAttachment bool `json:"has_attachment"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
Size int64 `json:"size,omitempty"` // approximate; only populated by search results
Labels []Label `json:"labels,omitempty"`
}
// ScheduledSend is a fully-composed message held until SendAt, delivered by the background
// sweep via the same send path as an immediate send. No raw file attachments in v1 — only
// forwarded-message .eml attachments (ForwardFromIDs).
type ScheduledSend struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountID int64 `json:"account_id"`
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"`
SendAt time.Time `json:"send_at"`
CreatedAt time.Time `json:"created_at"`
}
// ---- Compose ----
@@ -213,10 +263,18 @@ type ComposeRequest struct {
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
// ForwardFromIDs: each message here is fetched as a raw .eml and attached to the outgoing
// message — independent of mode (new/reply/forward), so a user can attach one or more
// original emails to any compose session, not just a dedicated "forward as attachment" one.
ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
// DraftID identifies this compose session's previously-autosaved draft ("" if never
// saved) — an IMAP UID, Graph message id, or JMAP email id depending on the account's
// provider, opaque to the caller. A resave replaces that copy in place (delete-then-
// recreate for IMAP/JMAP, PATCH for Graph) instead of piling up duplicates.
DraftID string `json:"draft_id,omitempty"`
}
// ---- Search ----
@@ -249,6 +307,8 @@ type PagedMessages struct {
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"`
@@ -289,3 +349,92 @@ type CalDAVToken struct {
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"`
}
+51
View File
@@ -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)
}
+85
View File
@@ -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)
}
}
+285
View File
@@ -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
}
+68
View File
@@ -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")
}
}
+107
View File
@@ -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
}
}
+98
View File
@@ -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)
}
})
}
}
+93
View File
@@ -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)
}
}
+289
View File
@@ -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()
}
+60
View File
@@ -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)
}
}
+272
View File
@@ -0,0 +1,272 @@
package syncer
import (
"context"
"fmt"
"log"
"strings"
"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 {
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 strings.ToLower(s[start:end])
}
func parseUID(s string) uint32 {
var uid uint32
fmt.Sscanf(s, "%d", &uid)
return uid
}
// ---- Spam blocklist (Settings > Security > Spam Block) ----
// A user-managed list of blocked senders, separate from the Rules engine so it gets its own
// simple add/remove UI instead of the generic condition/action rule builder — but enforced
// the same way the Rules engine's mark_as_spam action already is: move to the account's Spam
// folder. Applied to every provider's newly-synced messages, mirroring where matchRule runs.
func (s *Scheduler) moveToSpamIMAP(account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
uid := parseUID(msg.RemoteUID)
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
}
func (s *Scheduler) moveToSpamGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
if err := gc.MoveMessage(context.Background(), msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[spam-block] graph move: %v", err)
}
}
// ---- 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)
}
+131
View File
@@ -0,0 +1,131 @@
package syncer
import (
"testing"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- matchRule ----
func TestMatchRule_NoActiveRules(t *testing.T) {
msg := &models.Message{Subject: "hello"}
if got := matchRule(msg, "me@example.com", nil); got != nil {
t.Errorf("matchRule with no rules = %+v, want nil", got)
}
}
func TestMatchRule_SubjectContains(t *testing.T) {
msg := &models.Message{FromEmail: "boss@work.com", Subject: "Re: Invoice #42", BodyText: "please pay"}
active := []models.Rule{
{ID: 1, Priority: 1, MatchType: "all", Action: "move_to_folder", ActionValue: "Finance",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}},
}
got := matchRule(msg, "me@example.com", active)
if got == nil {
t.Fatalf("matchRule = nil, want rule 1 to match")
}
if got.ID != 1 || got.Action != "move_to_folder" || got.ActionValue != "Finance" {
t.Errorf("matchRule = %+v", got)
}
}
func TestMatchRule_ReturnsFirstMatchByPriority(t *testing.T) {
msg := &models.Message{FromEmail: "newsletter@shop.com", Subject: "50% off everything"}
active := []models.Rule{
{ID: 2, Priority: 2, MatchType: "all", Action: "delete",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "off"}}},
{ID: 1, Priority: 1, MatchType: "all", Action: "mark_as_spam",
Conditions: []models.RuleCondition{{Field: "from", Op: "contains", Value: "shop.com"}}},
}
// Match() iterates in the slice's given order and returns the first hit — callers
// (ListActiveRules) are documented to pre-sort by priority ascending, so put rule 1
// (priority 1) first here to prove matchRule returns it, not rule 2.
active[0], active[1] = active[1], active[0]
got := matchRule(msg, "me@example.com", active)
if got == nil || got.ID != 1 {
t.Fatalf("matchRule = %+v, want rule with ID=1 (lower priority number, listed first)", got)
}
}
func TestMatchRule_NoConditionsMatch(t *testing.T) {
msg := &models.Message{FromEmail: "friend@example.com", Subject: "hi"}
active := []models.Rule{
{ID: 1, Priority: 1, MatchType: "all", Action: "delete",
Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}},
}
if got := matchRule(msg, "me@example.com", active); got != nil {
t.Errorf("matchRule = %+v, want nil (no condition matches)", got)
}
}
// ---- recipientType / containsAddress ----
func TestRecipientType(t *testing.T) {
cases := []struct {
name string
msg *models.Message
want string
}{
{"plain to", &models.Message{ToList: "me@example.com"}, "to"},
{"in cc", &models.Message{CCList: "me@example.com, other@example.com"}, "cc"},
{"in bcc", &models.Message{BCCList: "me@example.com"}, "bcc"},
{"cc checked before bcc", &models.Message{CCList: "me@example.com", BCCList: "me@example.com"}, "cc"},
{"not in cc/bcc falls back to to", &models.Message{CCList: "someoneelse@example.com"}, "to"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := recipientType(tc.msg, "me@example.com")
if got != tc.want {
t.Errorf("recipientType = %q, want %q", got, tc.want)
}
})
}
}
func TestContainsAddress(t *testing.T) {
cases := []struct {
list, addr string
want bool
}{
{"a@x.com, b@x.com", "b@x.com", true},
{"a@x.com,b@x.com", "b@x.com", true}, // no space after comma
{" a@x.com , b@x.com ", "b@x.com", true},
{"a@x.com, b@x.com", "c@x.com", false},
{"", "a@x.com", false},
{"User@Example.com", "user@example.com", true}, // case-insensitive match
}
for _, tc := range cases {
if got := containsAddress(tc.list, tc.addr); got != tc.want {
t.Errorf("containsAddress(%q, %q) = %v, want %v", tc.list, tc.addr, got, tc.want)
}
}
}
func TestSplitAndTrim(t *testing.T) {
got := splitAndTrim(" A@x.com , B@x.com,C@x.com ")
want := []string{"a@x.com", "b@x.com", "c@x.com"}
if len(got) != len(want) {
t.Fatalf("splitAndTrim = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("splitAndTrim[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestTrimLower(t *testing.T) {
if got := trimLower(" MiXeD Case\t"); got != "mixed case" {
t.Errorf("trimLower = %q", got)
}
}
func TestParseUID(t *testing.T) {
if got := parseUID("12345"); got != 12345 {
t.Errorf("parseUID(\"12345\") = %d, want 12345", got)
}
if got := parseUID("not-a-number"); got != 0 {
t.Errorf("parseUID(garbage) = %d, want 0", got)
}
}
+383 -5
View File
@@ -7,18 +7,22 @@ package syncer
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"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"
)
@@ -205,6 +209,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)
@@ -220,6 +233,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
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())
@@ -467,6 +486,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)
@@ -475,6 +497,7 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
}
// 1. Fetch new messages (UID > lastSeenUID)
isIncrementalSync := lastSeenUID != 0 // false = first-ever sync or post-UIDVALIDITY full re-sync
var msgs []*models.Message
if lastSeenUID == 0 {
// First sync: respect the account's days/all setting
@@ -490,6 +513,11 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
return 0, fmt.Errorf("fetch new: %w", err)
}
// Collected only for a genuine incremental inbox sync — never for first-sync/full-resync
// backfill (that's historical mail, not "new mail") — mirrors the same restraint the
// reconciliation step below already applies to rules/spam-move side effects.
var pushCandidates []*models.Message
maxUID := lastSeenUID
for _, msg := range msgs {
msg.FolderID = dbFolder.ID
@@ -499,6 +527,13 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
if dbFolder.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamIMAP(account, dbFolder, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleIMAP(c, account, dbFolder, msg, rule)
} else if isIncrementalSync && dbFolder.FolderType == "inbox" && !msg.IsRead {
pushCandidates = append(pushCandidates, msg)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
@@ -506,6 +541,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
maxUID = uid
}
}
if len(pushCandidates) > 0 {
s.sendNewMailPush(account.UserID, pushCandidates)
}
// 2. Sync flags for ALL existing messages (catch read/star changes from other clients)
flags, err := c.SyncFlags(dbFolder.FullPath)
@@ -524,21 +562,121 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
if purged > 0 {
log.Printf("[sync] purged %d server-deleted messages from %s/%s", purged, account.EmailAddress, dbFolder.FullPath)
}
// 4. Reconcile the other direction: any UID the server has that we don't (from any
// past cause of local data loss — a bug, a crash mid-write, manual intervention) is
// re-fetched here, so the local cache always self-heals back to matching the server
// instead of staying permanently drifted — the incremental fetch in step 1 alone can
// never recover these, since it only ever asks for UIDs newer than last_seen_uid.
if localUIDs, lerr := s.db.GetLocalUIDSet(dbFolder.ID); lerr == nil {
var missing []uint32
for _, uid := range serverUIDs {
if !localUIDs[fmt.Sprintf("%d", uid)] {
missing = append(missing, uid)
}
}
if len(missing) > 0 {
recovered, rerr := c.FetchByUIDs(dbFolder.FullPath, missing)
if rerr != nil {
log.Printf("[sync] recover missing %s/%s: %v", account.EmailAddress, dbFolder.FullPath, rerr)
} else {
n := 0
for _, msg := range recovered {
msg.FolderID = dbFolder.ID
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
}
if n > 0 {
log.Printf("[sync] recovered %d message(s) missing from local cache in %s/%s", n, account.EmailAddress, dbFolder.FullPath)
newMessages += n
}
}
}
}
}
// 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
}
// sendNewMailPush delivers a background Web Push notification for genuinely new unread
// inbox mail to every device userID has subscribed (Settings > General > Notifications).
// Mirrors the title/body convention already used client-side by sendOSNotification() in
// app.js so a background push and a foreground toast read the same way.
func (s *Scheduler) sendNewMailPush(userID int64, msgs []*models.Message) {
if s.cfg.VAPIDPrivateKey == "" || s.cfg.VAPIDPublicKey == "" {
return
}
subs, err := s.db.GetPushSubscriptionsForUser(userID)
if err != nil || len(subs) == 0 {
return
}
first := msgs[0]
fromLabel := first.FromName
if fromLabel == "" {
fromLabel = first.FromEmail
}
subject := first.Subject
if subject == "" {
subject = "(no subject)"
}
title, body := fromLabel, subject
if len(msgs) > 1 {
title = fmt.Sprintf("%d new messages in GoWebMail", len(msgs))
body = fmt.Sprintf("%s: %s", fromLabel, subject)
}
payload, err := json.Marshal(map[string]string{"title": title, "body": body, "tag": "gowebmail-new"})
if err != nil {
return
}
for _, sub := range subs {
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
}, &webpush.Options{
Subscriber: "mailto:noreply@" + s.cfg.Hostname,
VAPIDPublicKey: s.cfg.VAPIDPublicKey,
VAPIDPrivateKey: s.cfg.VAPIDPrivateKey,
TTL: 60,
})
if err != nil {
log.Printf("[push] send to user %d: %v", userID, err)
continue
}
resp.Body.Close()
if resp.StatusCode == 404 || resp.StatusCode == 410 {
// Subscription is dead (browser data cleared, app uninstalled, etc).
s.db.DeletePushSubscriptionByEndpoint(sub.Endpoint)
}
}
}
// ---- Pending ops drain ----
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
// Graph accounts don't use the IMAP ops queue
if account.Provider == models.ProviderOutlookPersonal {
// 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)
@@ -583,7 +721,10 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
if applyErr != nil {
log.Printf("[ops:%s] %s uid=%d folder=%s: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.IncrementPendingOpAttempts(op.ID)
if abandoned := s.db.IncrementPendingOpAttempts(op.ID); abandoned {
log.Printf("[ops:%s] giving up on %s uid=%d folder=%s after repeated failures: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.SetAccountError(account.ID, fmt.Sprintf("a %s operation failed repeatedly and was abandoned: %v", op.OpType, applyErr))
}
} else {
s.db.DeletePendingOp(op.ID)
}
@@ -659,7 +800,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
}
@@ -710,6 +859,38 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
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)
@@ -817,6 +998,9 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
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{
@@ -835,6 +1019,13 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
}
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 dbFolderSaved.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamGraph(gc, account, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleGraph(gc, account, msg, rule)
}
}
}
@@ -847,3 +1038,190 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
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)
}
}
}
+249 -63
View File
@@ -30,6 +30,10 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
.toast.error{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#fca5a5}
.toast.warn{border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.08);color:#fde68a}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.toast-undo{display:flex;align-items:center;gap:14px;max-width:none}
.toast-undo-btn{background:none;border:none;color:var(--accent);font-weight:700;font-size:13px;
cursor:pointer;flex-shrink:0;padding:0}
.toast-undo-btn:hover{text-decoration:underline}
/* ---- Context menu ---- */
.ctx-menu{position:fixed;z-index:200;background:var(--surface2);border:1px solid var(--border2);
@@ -47,13 +51,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}
/* Modals that open from inside the Settings modal must stack above it, regardless of DOM
order, so Settings stays visible (and reachable) underneath. */
#add-account-modal,#edit-account-modal,#login-history-modal,#spam-block-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 +147,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 +209,20 @@ 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;
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
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}
@@ -174,7 +230,7 @@ body.app-page{overflow:hidden}
.nav-account-group{border-radius:6px;transition:background .15s}
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
.nav-account-group.acc-dragging{opacity:.4}
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
.sidebar-footer{padding: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}
@@ -183,11 +239,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}
@@ -197,28 +253,79 @@ 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,.thread-sibling-row.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
/* Compact 2-line row (default): sender+date, then subjectpreview 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-thread-count{color:var(--muted);font-size:11px;font-weight:600}
.msg-preview{color:var(--muted)}
.msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.msg-account-name{font-size:10px;color:var(--muted);flex-shrink:0;max-width:110px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;background:var(--surface3);padding:1px 6px;border-radius:4px}
.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}
@@ -235,19 +342,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;
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s}
.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:5px;flex-shrink:0}
.action-btn{padding:3px 8px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:11px;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}
@@ -256,13 +363,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);
}
@@ -272,12 +379,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}
@@ -337,7 +444,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}
@@ -354,11 +470,15 @@ 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;
.fmt-font-select{background:var(--surface2);border:1px solid var(--border2);border-radius:4px;color:var(--text);
font-size:12px;padding:3px 4px;cursor:pointer;max-width:130px;color-scheme:dark}
.fmt-font-select:hover{border-color:var(--accent)}
.fmt-font-select option{background:var(--surface2);color:var(--text)}
.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)}
@@ -382,6 +502,19 @@ body.admin-page{overflow:auto;background:var(--bg)}
.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)}
.thread-btn-wrap{position:relative;display:inline-flex;align-items:center}
.thread-dropdown{position:absolute;top:calc(100% + 6px);left:0;z-index:250;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:260px;max-width:360px;
max-height:320px;overflow-y:auto;padding:8px}
.thread-siblings-title{font-size:10px;text-transform:uppercase;letter-spacing:.6px;
color:var(--muted);margin-bottom:6px}
.thread-sibling-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;
cursor:pointer;font-size:12px;color:var(--text2)}
.thread-sibling-row:hover{background:var(--surface3)}
.thread-sibling-row.active{background:var(--accent-dim);color:var(--accent)}
.thread-sibling-from{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.thread-sibling-date{color:var(--muted);font-size:11px;flex-shrink:0}
/* Drag-and-drop compose overlay */
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
@@ -390,6 +523,9 @@ body.admin-page{overflow:auto;background:var(--bg)}
padding:4px 6px;min-height:32px;cursor:text;background:transparent}
.tag-container:focus-within{}
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:7px}
.compose-cc-bcc-toggle{display:flex;gap:8px;flex-shrink:0;align-self:flex-start;padding-top:7px}
.cc-bcc-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:0;transition:color .15s}
.cc-bcc-btn:hover{color:var(--accent)}
.email-tag{display:inline-flex;align-items:center;gap:3px;padding:2px 6px 2px 8px;
background:var(--surface3);border:1px solid var(--border2);border-radius:12px;
font-size:12px;color:var(--text);white-space:nowrap;max-width:260px}
@@ -399,26 +535,32 @@ body.admin-page{overflow:auto;background:var(--bg)}
.tag-remove:hover{color:var(--text)}
.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}
.compose-tag-field{position:relative}
.contact-suggest{position:absolute;top:100%;left:12px;right:12px;z-index:50;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,.25);overflow:hidden;margin-top:2px}
.contact-suggest-row{display:flex;align-items:center;gap:8px;padding:7px 10px;
cursor:pointer;font-size:12px}
.contact-suggest-row:hover,.contact-suggest-row.active{background:var(--surface3)}
.contact-suggest-name{color:var(--text);flex-shrink:0;max-width:45%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.contact-suggest-email{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ── 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;
/* ── Date/time presets (snooze / send later) ─────────────────────── */
.datetime-presets{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}
.datetime-preset-btn{padding:5px 10px;background:var(--surface3);border:1px solid var(--border2);
border-radius:14px;color:var(--text2);font-size:12px;cursor:pointer;font-family:inherit}
.datetime-preset-btn:hover{background:var(--surface2);color:var(--text)}
#inline-datetime-input{width:100%;padding:7px 9px;background:var(--surface3);border:1px solid var(--border2);
border-radius:6px;color:var(--text);font-family:inherit;font-size:13px}
/* ── 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)}
@@ -548,9 +690,24 @@ body.admin-page{overflow:auto;background:var(--bg)}
/* 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 action row (Reply/Forward/Star/...): wrap instead of overflowing
horizontally — there's no room for 9+ buttons in one row on a phone. */
.detail-actions{flex-wrap:wrap}
/* Let the whole message view (subject/from/buttons + body) scroll together as one
on a phone, instead of pinning the header/action row in place and squeezing the
body into whatever vertical space is left over — that left almost no room to read. */
.message-detail{overflow-y:auto}
.detail-body{flex:none;overflow-y:visible}
/* Message list panel: full width/height, shown/hidden by data-mob-view. !important
because the desktop drag-resize / reading-pane-bottom feature can leave an inline
height (e.g. "35%") on this element from a persisted desktop layout — without it,
the list would only fill that leftover fraction of the screen on mobile. */
.message-list-panel{width:100%!important;height:100%!important;border-right:none;flex-shrink:0}
.message-detail{width:100%}
/* View switching via data-mob-view on #app-root */
@@ -568,6 +725,35 @@ body.admin-page{overflow:auto;background:var(--bg)}
}
/* Hide floating minimised bar on mobile, use back button instead */
.compose-minimised{display:none!important}
/* Any other modal (add/edit account, login history, spam block, etc.): fit the screen
instead of overflowing a fixed desktop width. */
.modal{width:calc(100vw - 24px)!important;max-width:440px}
/* Settings modal on mobile becomes a full-screen "page" — it must render above the
app's own fixed .mob-topbar (z-index:200), otherwise the topbar sits on top of the
modal's own close button and swallows the tap. Modals opened from inside Settings
(add/edit account, etc.) keep stacking above it, just shifted up to match. */
#settings-modal{z-index:220}
#add-account-modal,#edit-account-modal,#login-history-modal,#spam-block-modal{z-index:230}
/* Settings modal: full screen, side nav becomes a horizontal sliding tab strip on top. */
.settings-modal-box{
width:100vw!important;max-width:100vw!important;height:100dvh!important;height:100vh!important;
max-height:100vh!important;border-radius:0!important;
position:fixed!important;inset:0!important;
}
.settings-body{flex-direction:column!important}
.settings-nav{
width:100%!important;flex-direction:row!important;flex-shrink:0;
overflow-x:auto;-webkit-overflow-scrolling:touch;
border-right:none!important;border-bottom:1px solid var(--border);
padding:8px 10px!important;gap:4px!important;
}
.settings-nav button{
width:auto!important;display:inline-block;white-space:nowrap;flex-shrink:0;
padding:6px 12px;font-size:12px;
}
}
/* ── Contacts ──────────────────────────────────────────────────────────── */
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+5 -5
View File
@@ -9,7 +9,7 @@ const adminRoutes = {
function navigate(path) {
history.pushState({}, '', path);
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
document.querySelectorAll('.admin-nav a').forEach(a => { const on = a.getAttribute('href') === path; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current'); });
const fn = adminRoutes[path];
if (fn) fn();
}
@@ -35,7 +35,7 @@ async function renderUsers() {
</div>
<div id="users-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="user-modal">
<div class="modal-overlay" id="user-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="user-modal-title">
<div class="modal">
<h2 id="user-modal-title">New User</h2>
<input type="hidden" id="user-id">
@@ -346,7 +346,7 @@ function eventBadge(evt) {
// Boot: detect current page from URL
(function() {
const path = location.pathname;
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
document.querySelectorAll('.admin-nav a').forEach(a => { const on = a.getAttribute('href') === path; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current'); });
const fn = adminRoutes[path];
if (fn) fn();
else renderUsers();
@@ -385,9 +385,9 @@ async function renderSecurity() {
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal">
<div class="modal-overlay" id="add-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-block-modal-title">
<div class="modal" style="max-width:420px">
<h2>Block IP Address</h2>
<h2 id="add-block-modal-title">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>
+1800 -203
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -12,13 +12,12 @@ function _setView(view) {
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
// 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 (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
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';
}
@@ -384,7 +383,7 @@ async function loadCalDAVTokens() {
<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">
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" aria-label="Revoke this CalDAV token" 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>`;
+66 -9
View File
@@ -1,10 +1,18 @@
// GoWebMail shared utilities - loaded on every page
// ---- Service worker (Web Push delivery) ----
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// ---- 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);
@@ -21,6 +29,9 @@ function toast(msg, type) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
document.body.appendChild(container);
}
const el = document.createElement('div');
@@ -66,6 +77,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;
@@ -75,11 +119,15 @@ function debounce(fn, ms) {
// ---- Modal helpers ----
function openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add('open');
if (!el) return;
el.classList.add('open');
el.setAttribute('aria-hidden', 'false');
const focusable = el.querySelector('input,button,select,textarea,[tabindex]');
if (focusable) setTimeout(() => focusable.focus(), 50);
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('open');
if (el) { el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); }
}
// Close modals on overlay click
@@ -101,12 +149,21 @@ document.addEventListener('keydown', e => {
});
// ---- Rich text compose helpers ----
function insertLink() {
const url = prompt('Enter URL:');
if (!url) return;
const text = window.getSelection().toString() || url;
document.getElementById('compose-editor').focus();
document.execCommand('createLink', false, url);
// editorId defaults to the main compose editor; the signature editor passes 'sig-content'.
// Uses inlinePrompt (not window.prompt) so the selection has to be saved/restored across the
// async gap — prompt() blocked synchronously and never lost it.
function insertLink(editorId) {
editorId = editorId || 'compose-editor';
const editor = document.getElementById(editorId);
if (!editor) return;
const sel = window.getSelection();
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
inlinePrompt('Enter URL:', url => {
if (!url) return;
editor.focus();
if (range) { sel.removeAllRanges(); sel.addRange(range); }
document.execCommand('createLink', false, url);
});
}
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
+15
View File
@@ -0,0 +1,15 @@
{
"name": "GoWebMail",
"short_name": "GoWebMail",
"description": "Multi-account webmail client",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0d0f14",
"theme_color": "#0d0f14",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+42
View File
@@ -0,0 +1,42 @@
// GoWebMail service worker — background Web Push delivery only.
// No fetch/cache handling: this app is always online-driven, so an offline shell would
// just serve stale mail; we deliberately don't add one.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
self.addEventListener('push', (event) => {
let data = {};
try { data = event.data ? event.data.json() : {}; } catch (e) { /* non-JSON push, ignore */ }
const title = data.title || 'GoWebMail';
const body = data.body || 'New mail';
const tag = data.tag || 'gowebmail-new';
event.waitUntil((async () => {
// Skip if a GoWebMail window is already open and focused — the in-page POLLER toast
// already covers that case, so this avoids a duplicate notification.
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clientList.some(c => c.focused)) return;
return self.registration.showNotification(title, {
body,
tag,
icon: '/static/icons/icon-192.png',
badge: '/static/icons/icon-192.png',
data: { url: '/' },
});
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil((async () => {
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of clientList) {
if ('focus' in c) return c.focus();
}
if (self.clients.openWindow) return self.clients.openWindow(url);
})());
});
+2 -2
View File
@@ -34,10 +34,10 @@
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="ctx-menu" id="ctx-menu"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js?v=25"></script>
<script src="/static/js/admin.js?v=26"></script>
{{end}}
+611 -150
View File
@@ -6,33 +6,36 @@
<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">
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu" aria-label="Open navigation 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">
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" aria-label="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>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab" aria-label="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" aria-label="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="position:relative;display:inline-flex">
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
<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" aria-label="More compose options" aria-haspopup="true">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
</div>
</div>
@@ -46,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-snoozed" onclick="selectFolder('snoozed','Snoozed')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
Snoozed
</div>
<div class="nav-item" id="nav-scheduled" onclick="showScheduledSends()">
<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 18H4V8h16v13zm-8-9h5v5h-5z"/></svg>
Scheduled
</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
@@ -63,13 +74,10 @@
<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">
<button class="icon-btn" onclick="openSettings()" title="Settings" aria-label="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>
<button class="icon-btn" onclick="doLogout()" title="Sign out">
<button class="icon-btn" onclick="doLogout()" title="Sign out" aria-label="Sign out">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div>
@@ -78,14 +86,47 @@
<!-- 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" aria-label="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)" aria-haspopup="true" aria-expanded="false">
<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)" aria-haspopup="true" aria-expanded="false">
<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 &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" aria-haspopup="true" aria-expanded="false" onclick="var m=document.getElementById('filter-dropdown-menu');var exp=m.style.display==='block';m.style.display=exp?'none':'block';this.setAttribute('aria-expanded',String(!exp));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>
@@ -103,15 +144,21 @@
</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" aria-label="Search emails" 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" aria-label="Search filters" aria-haspopup="true" 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">
@@ -121,6 +168,7 @@
<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)">
@@ -136,15 +184,15 @@
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
<button class="icon-btn" onclick="calNav(-1)" title="Previous">&#8249;</button>
<button class="icon-btn" onclick="calNav(-1)" title="Previous" aria-label="Previous period">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next">&#8250;</button>
<button class="icon-btn" onclick="calNav(1)" title="Next" aria-label="Next period">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing" aria-label="CalDAV and sharing settings">
<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>
@@ -155,7 +203,7 @@
</div>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<div class="modal-overlay" id="contact-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="contact-modal-title">
<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>
@@ -172,7 +220,7 @@
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal">
<div class="modal-overlay" id="event-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="event-modal-title">
<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>
@@ -205,9 +253,9 @@
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal">
<div class="modal-overlay" id="caldav-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="caldav-modal-title">
<div class="modal" style="max-width:560px">
<h2>CalDAV / Calendar Sharing</h2>
<h2 id="caldav-modal-title">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>
@@ -222,59 +270,63 @@
</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>
</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">
<div class="compose-dialog-header" id="compose-drag-handle">
<span class="compose-title" id="compose-title">New Message</span>
<div style="display:flex;align-items:center;gap:2px">
<button class="compose-close" onclick="minimizeCompose()" title="Minimise">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close">&#215;</button>
<button class="compose-close" onclick="minimizeCompose()" title="Minimise" aria-label="Minimise compose window">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close" aria-label="Close compose window">&#215;</button>
</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 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>
<div class="compose-field"><label>Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar">
<button class="fmt-btn" title="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" onclick="execFmt('removeFormat')">T&#x20D7;</button>
<div class="compose-field"><label for="compose-from">From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
<div class="compose-field compose-tag-field"><label id="compose-to-label">To</label><div id="compose-to" class="tag-container" role="group" aria-labelledby="compose-to-label"></div>
<div class="compose-cc-bcc-toggle">
<button type="button" class="cc-bcc-btn" id="cc-toggle-btn" onclick="showCCRow()">Cc</button>
<button type="button" class="cc-bcc-btn" id="bcc-toggle-btn" onclick="showBCCRow()">Bcc</button>
</div>
</div>
<div id="compose-editor" contenteditable="true" class="compose-editor" placeholder="Write your message..."></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label id="compose-cc-label">CC</label><div id="compose-cc-tags" class="tag-container" role="group" aria-labelledby="compose-cc-label"></div></div>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label id="compose-bcc-label">BCC</label><div id="compose-bcc-tags" class="tag-container" role="group" aria-labelledby="compose-bcc-label"></div></div>
<div class="compose-field"><label for="compose-subject">Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<select class="fmt-font-select" title="Font" aria-label="Font family" onmousedown="saveEditorRange()" onchange="applyFontFromSelect(this)">
<option value="">Font</option>
<option value="Arial" style="font-family:Arial">Arial</option>
<option value="Helvetica" style="font-family:Helvetica">Helvetica</option>
<option value="Georgia" style="font-family:Georgia">Georgia</option>
<option value="'Times New Roman'" style="font-family:'Times New Roman'">Times New Roman</option>
<option value="'Courier New'" style="font-family:'Courier New'">Courier New</option>
<option value="Verdana" style="font-family:Verdana">Verdana</option>
<option value="Tahoma" style="font-family:Tahoma">Tahoma</option>
<option value="'Trebuchet MS'" style="font-family:'Trebuchet MS'">Trebuchet MS</option>
<option value="Garamond" style="font-family:Garamond">Garamond</option>
<option value="'Palatino Linotype'" style="font-family:'Palatino Linotype'">Palatino</option>
<option value="'Comic Sans MS'" style="font-family:'Comic Sans MS'">Comic Sans MS</option>
<option value="Impact" style="font-family:Impact">Impact</option>
<option value="'Lucida Console'" style="font-family:'Lucida Console'">Lucida Console</option>
</select>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Attach files" aria-label="Attach files" onclick="triggerAttach()">&#128206;</button>
</div>
<div id="compose-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" class="compose-editor" placeholder="Write your message..."></div>
<div id="compose-attach-list" class="compose-attach-list"></div>
<div class="compose-footer">
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
<div style="display:flex;gap:6px;margin-left:4px">
<button class="btn-secondary" style="font-size:12px" onclick="showCCRow()">+CC</button>
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; Draft</button>
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">&#128339; Send later</button>
</div>
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
</div>
@@ -295,7 +347,7 @@
</div>
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
<div class="inline-confirm" id="inline-confirm">
<div class="inline-confirm" id="inline-confirm" role="alertdialog" aria-modal="true" aria-describedby="inline-confirm-msg">
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
@@ -303,10 +355,131 @@
</div>
</div>
<!-- ── Inline prompt (replaces browser prompt()) ─────────────────────────── -->
<div class="inline-confirm" id="inline-prompt" role="dialog" aria-modal="true" aria-describedby="inline-prompt-msg">
<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" aria-labelledby="inline-prompt-msg"
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>
<!-- ── Inline date/time prompt (snooze / send later) ──────────────────────── -->
<div class="inline-confirm" id="inline-datetime" role="dialog" aria-modal="true" aria-describedby="inline-datetime-msg">
<p id="inline-datetime-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="datetime-presets" id="inline-datetime-presets" role="group" aria-label="Quick presets"></div>
<div class="modal-field">
<input type="datetime-local" id="inline-datetime-input" aria-labelledby="inline-datetime-msg">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-datetime-cancel">Cancel</button>
<button class="btn-primary" style="font-size:12px" id="inline-datetime-ok">Set</button>
</div>
</div>
<!-- ── Draft close confirm (save / delete / keep editing) ─────────────────── -->
<div class="inline-confirm" id="draft-close-confirm" role="alertdialog" aria-modal="true" aria-describedby="draft-close-confirm-msg">
<p id="draft-close-confirm-msg" 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>
<!-- ── Scheduled Sends Modal ───────────────────────────────────────────────── -->
<div class="modal-overlay" id="scheduled-sends-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="scheduled-sends-modal-title">
<div class="modal" style="max-width:520px">
<h2 id="scheduled-sends-modal-title">Scheduled sends</h2>
<div id="scheduled-sends-list"></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('scheduled-sends-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Login History modal ────────────────────────────────────────────────── -->
<div class="modal-overlay" id="login-history-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="login-history-modal-title">
<div class="modal" style="width:min(1000px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="login-history-modal-title">Login History</h2>
<p>Login attempts for your account only.</p>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:14px">
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-from">From</label>
<input type="date" id="lh-date-from" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-to">To</label>
<input type="date" id="lh-date-to" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-status">Status</label>
<select id="lh-status" onchange="loadLoginHistory(1)">
<option value="">All</option>
<option value="true">Success</option>
<option value="false">Failed</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:0;flex:1;min-width:160px">
<label for="lh-ip">IP contains</label>
<input type="text" id="lh-ip" placeholder="e.g. 192.168" oninput="debouncedLoadLoginHistory()">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-sort">Sort by date</label>
<select id="lh-sort" onchange="loadLoginHistory(1)">
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Time</th><th>Status</th><th>IP Address</th><th>Detail</th></tr></thead>
<tbody id="lh-table-body"></tbody>
</table>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px">
<span id="lh-page-info" style="font-size:12px;color:var(--muted)"></span>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="lh-prev-btn" onclick="loginHistoryPrevPage()">Previous</button>
<button class="btn-secondary" id="lh-next-btn" onclick="loginHistoryNextPage()">Next</button>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('login-history-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Spam Block modal ───────────────────────────────────────────────────── -->
<div class="modal-overlay" id="spam-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="spam-block-modal-title">
<div class="modal" style="width:min(700px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="spam-block-modal-title">Spam Block</h2>
<p>Mail from these senders is automatically moved to Spam when it arrives — no notification is shown for it. Enter either a full email address, or just a domain (e.g. "example.com") to block every address at that domain and its subdomains.</p>
<div style="display:flex;gap:8px;margin-bottom:14px">
<input type="text" id="sb-add-input" placeholder="Email address or domain (e.g. example.com)…" 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">
<button class="btn-primary" onclick="addSpamBlockEntry()">Block</button>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Sender</th><th>Blocked since</th><th></th></tr></thead>
<tbody id="sb-table-body"></tbody>
</table>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('spam-block-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="add-account-modal">
<div class="modal-overlay" id="add-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-account-modal-title">
<div class="modal">
<h2>Connect an account</h2>
<h2 id="add-account-modal-title">Connect an account</h2>
<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')">
@@ -342,18 +515,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 &nbsp;·&nbsp;
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 &amp; 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>
@@ -363,10 +543,28 @@
</div>
</div>
<!-- ── Label Editor Modal (create/rename/recolor) ────────────────────────────── -->
<div class="modal-overlay" id="label-editor-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="label-editor-title">
<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-overlay" id="edit-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="edit-account-modal-title">
<div class="modal">
<h2>Account Settings</h2>
<h2 id="edit-account-modal-title">Account Settings</h2>
<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>
@@ -386,15 +584,19 @@
<div id="edit-creds-section">
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
<div 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">
<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 &amp; 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>
@@ -425,95 +627,354 @@
</div>
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
<h2 style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
<div class="modal-overlay" id="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settings-modal-title">
<div class="modal settings-modal-box" 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 id="settings-modal-title" style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn" aria-label="Close settings"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div class="settings-body" style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
<div class="settings-nav" role="tablist" aria-label="Settings sections">
<button data-tab="accounts" class="active" role="tab" aria-selected="true" onclick="showSettingsTab('accounts')">Accounts</button>
<button data-tab="general" role="tab" aria-selected="false" onclick="showSettingsTab('general')">General</button>
<button data-tab="security" role="tab" aria-selected="false" onclick="showSettingsTab('security')">Security</button>
<button data-tab="account" role="tab" aria-selected="false" onclick="showSettingsTab('account')">Profile</button>
<button data-tab="rules" role="tab" aria-selected="false" onclick="showSettingsTab('rules')">Rules</button>
<button data-tab="signatures" role="tab" aria-selected="false" onclick="showSettingsTab('signatures')">Signatures</button>
<button data-tab="certs" role="tab" aria-selected="false" onclick="showSettingsTab('certs')">Certificates</button>
</div>
<div style="flex:1;min-width:0;overflow-y:auto;padding:20px 24px">
<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 class="settings-panel active" data-tab="accounts" role="tabpanel">
<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="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 class="settings-panel" data-tab="general" role="tabpanel">
<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">Remote Images</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Control when images and other remote content in emails load automatically. Blocking prevents senders from using tracking pixels to detect that you've opened a message.</div>
<div class="modal-field">
<label for="remote-image-policy-select">Policy</label>
<select id="remote-image-policy-select" onchange="saveRemoteImagePolicy()" 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="always">Always render images</option>
<option value="contacts">Only from Contacts</option>
<option value="never">Never</option>
<option value="manual">Manually (needs allowing)</option>
</select>
</div>
<div class="modal-field">
<label>Allowed senders</label>
<div id="remote-whitelist-list" style="font-size:12px;color:var(--muted)">Loading…</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Notifications</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Show a browser notification when new mail arrives, even while GoWebMail is in a background tab.</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
<input type="checkbox" id="notifications-toggle" onchange="toggleNotifications(this.checked)" style="width:auto">
Enable desktop notifications
</label>
<div id="notifications-status" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
</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-panel" data-tab="security" role="tabpanel">
<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 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">Login History</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">View login attempts for your account only — successful and failed, with timestamps and source IPs.</div>
<button class="btn-secondary" onclick="openLoginHistory()">View Login History</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 class="settings-group">
<div class="settings-group-title">Spam Block</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Senders blocked here are automatically moved to Spam as soon as new mail from them arrives, across all your connected accounts — no notification is shown for it.</div>
<button class="btn-secondary" onclick="openSpamBlock()">Manage Spam Block List</button>
</div>
</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 class="settings-panel" data-tab="account" role="tabpanel">
<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">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>
<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 class="settings-panel" data-tab="rules" role="tabpanel">
<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 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 class="settings-panel" data-tab="signatures" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title" id="sig-form-title">Add Signature</div>
<div class="modal-field"><label for="sig-name">Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
<div class="modal-field">
<label for="sig-content">Content</label>
<div class="compose-toolbar" role="toolbar" aria-label="Signature formatting" style="margin-bottom:6px">
<button type="button" class="fmt-btn" title="Bold" aria-label="Bold" onclick="execSigFmt('bold')"><b>B</b></button>
<button type="button" class="fmt-btn" title="Italic" aria-label="Italic" onclick="execSigFmt('italic')"><i>I</i></button>
<button type="button" class="fmt-btn" title="Underline" aria-label="Underline" onclick="execSigFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<label class="fmt-btn" title="Text color" aria-label="Text color" style="cursor:pointer;position:relative">
🎨<input type="color" id="sig-color-input" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer" onchange="execSigFmt('foreColor', this.value)">
</label>
<span class="fmt-sep"></span>
<button type="button" class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink('sig-content')">&#128279;</button>
<button type="button" class="fmt-btn" title="Image" aria-label="Insert image" onclick="document.getElementById('sig-image-input').click()">&#128247;</button>
<button type="button" class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execSigFmt('removeFormat')">T&#x20D7;</button>
<input type="file" id="sig-image-input" accept="image/*" style="display:none" onchange="insertSigImage(this)">
</div>
<div id="sig-content" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Signature content"
style="width:100%;min-height:110px;padding:10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.5;overflow-y:auto"></div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" id="sig-save-btn" onclick="saveSignature()">Add Signature</button>
<button class="btn-secondary" id="sig-cancel-btn" onclick="cancelSignatureEdit()" style="display:none">Cancel</button>
</div>
</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" role="tabpanel">
<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>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
</div>
</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>
<!-- 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>
<!-- Context menu -->
<div class="ctx-menu" id="ctx-menu"></div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
<script src="/static/js/app.js?v=91"></script>
<script src="/static/js/contacts_calendar.js?v=79"></script>
{{end}}
+5 -2
View File
@@ -5,12 +5,15 @@
<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=58">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=79">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0d0f14">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=58"></script>
<script src="/static/js/gowebmail.js?v=79"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
+278 -50
View File
@@ -1,56 +1,85 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body_class"}}{{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">
<div id="compose-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border);flex-wrap:wrap">
<a href="/" id="cp-back-link" 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>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button class="btn-secondary" id="discard-draft-btn" style="display:none;font-size:12px;color:var(--danger)" onclick="discardDraftAndReset()">Discard draft</button>
<button type="button" id="cc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowCC()">+CC</button>
<button type="button" id="bcc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowBCC()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">💾 Save Draft</button>
<button class="btn-secondary" id="sendlater-btn" style="font-size:12px" onclick="toggleSendLaterPanel()">🕐 Send later</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
<input type="file" id="cp-file-input" multiple style="display:none" onchange="addPageAttachments(this.files)">
</div>
</div>
<div id="cp-leave-confirm" class="remote-content-banner" style="display:none;margin-bottom:14px">
You have a draft in progress.
<button class="rcb-btn" id="cp-leave-keep">Keep editing</button>
<button class="rcb-btn" id="cp-leave-save">Save &amp; leave</button>
<button class="rcb-btn" id="cp-leave-discard">Discard &amp; leave</button>
</div>
<div id="cp-sendlater-panel" class="remote-content-banner" style="display:none;margin-bottom:14px">
Send at:
<input type="datetime-local" id="cp-sendlater-input" style="background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:4px 8px;font-size:13px">
<button class="rcb-btn" onclick="confirmSendLater()">Schedule</button>
<button class="rcb-btn" onclick="document.getElementById('cp-sendlater-panel').style.display='none'">Cancel</button>
</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>
<label for="cp-from" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</label>
<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>
<span id="cp-to-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-container" role="group" aria-labelledby="cp-to-label" 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 id="cc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-cc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-container" role="group" aria-labelledby="cp-cc-label" style="flex:1;min-height:30px"></div>
</div>
<!-- BCC -->
<div id="bcc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-bcc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">BCC</span>
<div id="cp-bcc-tags" class="tag-container" role="group" aria-labelledby="cp-bcc-label" 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">
<label for="cp-subject" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</label>
<input id="cp-subject" type="text" placeholder="Subject" oninput="markDirty()" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Formatting toolbar -->
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
</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 id="cp-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" oninput="markDirty()" 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 (added via the "Attach" button in the header) -->
<div id="cp-att-list" style="border-top:1px solid var(--border);padding:10px 0;display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
<div id="cp-status" role="status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
@@ -60,7 +89,10 @@
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const editDraftId = parseInt(params.get('edit_draft_id') || '0');
const cpAttachments = [];
let draftId = '', dirty = false, draftTimer = null;
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
@@ -70,9 +102,44 @@ async function apiCall(method, path, body) {
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function markDirty() { dirty = true; }
function setStatus(msg, isError) {
const el = document.getElementById('cp-status');
el.textContent = msg;
el.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
// Tag field (simple comma/enter separated)
// ── Remote-image policy — same rules as the reading pane (app.js / message.html) ──
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
function quotedBodyHTML(msg) {
if (!msg.body_html) return '<pre>'+esc(msg.body_text||'')+'</pre>';
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!isRemoteContentAllowed(msg.from_email)) html = stripRemoteImages(html);
return html;
}
function restoreBlockedImages(html) {
return html.replace(/src=""\s+data-blocked-src="([^"]*)"/gi, 'src="$1"');
}
// ── Tag fields (To/Cc/Bcc) — same visual style as the main compose modal ──
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
@@ -85,8 +152,8 @@ function initTagField(id) {
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();
const tags = el.querySelectorAll('.email-tag');
if (tags.length) { tags[tags.length-1].remove(); markDirty(); }
}
});
inp.addEventListener('blur', () => {
@@ -96,20 +163,32 @@ function initTagField(id) {
}
function addTagTo(fieldId, email) {
if (!email) return;
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);
const tag = document.createElement('span');
tag.className = 'email-tag';
const label = document.createElement('span');
label.textContent = email;
const remove = document.createElement('button');
remove.innerHTML = '×'; remove.className = 'tag-remove'; remove.type = 'button';
remove.onclick = e => { e.stopPropagation(); tag.remove(); markDirty(); };
tag.appendChild(label); tag.appendChild(remove);
el.insertBefore(tag, inp || null);
markDirty();
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
return Array.from(el.querySelectorAll('.email-tag')).map(c => c.firstChild.textContent.trim()).filter(Boolean);
}
function cpShowCC() { document.getElementById('cc-row').style.display = 'flex'; document.getElementById('cc-toggle').style.display = 'none'; }
function cpShowBCC() { document.getElementById('bcc-row').style.display = 'flex'; document.getElementById('bcc-toggle').style.display = 'none'; }
function execFmt(cmd, val) { document.getElementById('cp-editor').focus(); document.execCommand(cmd, false, val || null); }
function triggerAttach() { document.getElementById('cp-file-input').click(); }
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
@@ -118,6 +197,7 @@ function addPageAttachments(files) {
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
markDirty();
}
async function loadAccounts() {
@@ -131,6 +211,13 @@ async function loadAccounts() {
});
}
async function loadRemoteImagePrefs() {
const [uiPrefs, wl] = await Promise.all([apiCall('GET', '/ui-prefs'), apiCall('GET', '/remote-content-whitelist')]);
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await apiCall('GET', '/contacts') || [];
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
@@ -142,13 +229,14 @@ async function prefillReply() {
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 style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</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; } }
}
dirty = false;
}
async function prefillForward() {
@@ -161,27 +249,56 @@ async function prefillForward() {
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 style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</div>`;
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; } }
}
dirty = false;
}
// Resuming a saved draft: unlike reply/forward, fields are populated directly (no quoting
// wrapper) and draftId is seeded from the draft's own id so the next autosave/send/discard
// replaces this exact draft in place instead of creating a second copy.
async function prefillEditDraft() {
if (!editDraftId) return;
document.getElementById('compose-page-title').textContent = 'Edit Draft';
const msg = await apiCall('GET', '/messages/' + editDraftId);
if (!msg) return;
document.title = 'Edit Draft — GoWebMail';
document.getElementById('cp-subject').value = msg.subject || '';
(msg.to || '').split(',').map(s => s.trim()).filter(Boolean).forEach(a => addTagTo('cp-to-tags', a));
const ccList = (msg.cc || '').split(',').map(s => s.trim()).filter(Boolean);
if (ccList.length) { cpShowCC(); ccList.forEach(a => addTagTo('cp-cc-tags', a)); }
const bccList = (msg.bcc || '').split(',').map(s => s.trim()).filter(Boolean);
if (bccList.length) { cpShowBCC(); bccList.forEach(a => addTagTo('cp-bcc-tags', a)); }
document.getElementById('cp-editor').innerHTML = quotedBodyHTML(msg);
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; } }
}
draftId = msg.remote_uid || '';
updateDraftUI();
dirty = false;
}
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; }
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 0,
};
let r;
@@ -198,23 +315,134 @@ async function sendFromPage() {
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>';
stopAutosave();
dirty = false;
await discardDraftReq(); // the autosaved Drafts-folder copy is now redundant — it's been sent
setStatus('✓ Message sent!');
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
setStatus(r?.error || 'Send failed.', true);
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
// ── Send later ───────────────────────────────────────────────────────────────
function toLocalInput(d) {
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function toggleSendLaterPanel() {
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
if (cpAttachments.length) { setStatus("Send later doesn't support file attachments yet — forwarded messages are fine", true); return; }
const panel = document.getElementById('cp-sendlater-panel');
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'flex';
if (!isOpen) {
const input = document.getElementById('cp-sendlater-input');
input.min = toLocalInput(new Date(Date.now() + 60000));
input.value = toLocalInput(new Date(Date.now() + 3600000));
}
}
async function confirmSendLater() {
const input = document.getElementById('cp-sendlater-input');
const d = new Date(input.value);
if (!input.value || isNaN(d.getTime()) || d <= new Date()) { setStatus('Pick a time in the future.', true); return; }
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
const meta = {
account_id: accountId, to,
cc: getTagValues('cp-cc-tags'), bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
send_at: d.toISOString(),
};
const r = await apiCall('POST', '/send-later', meta);
if (r?.ok) {
stopAutosave();
dirty = false;
await discardDraftReq();
setStatus('✓ Message scheduled!');
document.getElementById('cp-sendlater-panel').style.display = 'none';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
setStatus(r?.error || 'Failed to schedule.', true);
}
}
// ── Draft autosave ──────────────────────────────────────────────────────────
function startAutosave() { stopAutosave(); draftTimer = setInterval(() => { if (dirty) saveDraft(true); }, 60000); }
function stopAutosave() { if (draftTimer) { clearInterval(draftTimer); draftTimer = null; } }
function updateDraftUI() {
document.getElementById('discard-draft-btn').style.display = draftId ? 'inline-block' : 'none';
}
async function saveDraft(silent) {
dirty = false;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) { if (!silent) setStatus('Add a From account first.', true); return; }
const editor = document.getElementById('cp-editor');
const meta = {
account_id: accountId,
to: getTagValues('cp-to-tags'),
cc: getTagValues('cp-cc-tags'),
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(editor.innerHTML.trim()),
body_text: editor.innerText.trim(),
draft_id: draftId,
};
const r = await apiCall('POST', '/draft', meta);
if (r?.ok) { draftId = r.draft_id || draftId; updateDraftUI(); setStatus(silent ? 'Draft auto-saved' : 'Draft saved'); }
else if (!silent) setStatus(r?.error || 'Draft save failed', true);
}
// Deletes the draft that autosave already wrote to the server for this compose session.
async function discardDraftReq() {
if (!draftId) return;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) return;
await apiCall('POST', '/draft/discard', { account_id: accountId, draft_id: draftId });
draftId = ''; updateDraftUI();
}
async function discardDraftAndReset() {
await discardDraftReq();
setStatus('Draft discarded');
}
// ── Leaving the page with unsent work ───────────────────────────────────────
document.getElementById('cp-back-link').addEventListener('click', e => {
if (dirty || draftId) {
e.preventDefault();
document.getElementById('cp-leave-confirm').style.display = 'flex';
}
});
document.getElementById('cp-leave-keep').onclick = () => { document.getElementById('cp-leave-confirm').style.display = 'none'; };
document.getElementById('cp-leave-discard').onclick = async () => { await discardDraftReq(); location.href = '/'; };
document.getElementById('cp-leave-save').onclick = async () => { await saveDraft(true); location.href = '/'; };
window.addEventListener('beforeunload', e => {
if (dirty || draftId) { e.preventDefault(); e.returnValue = ''; }
});
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
async function boot() {
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
initTagField('cp-bcc-tags');
await Promise.all([loadAccounts(), loadRemoteImagePrefs()]);
if (replyId) await prefillReply();
else if (forwardId) await prefillForward();
else if (editDraftId) await prefillEditDraft();
startAutosave();
}
boot();
</script>
{{end}}
+3 -3
View File
@@ -9,10 +9,10 @@
</div>
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your Web Mail Client</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/login">
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<div class="field"><label for="login-username">Username or Email</label><input id="login-username" type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label for="login-password">Password</label><input id="login-password" type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
</form>
</div>
+124 -26
View File
@@ -1,9 +1,9 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div id="msg-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;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>
@@ -25,6 +25,7 @@
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function api(method, path, body) {
const opts = { method, headers: {} };
@@ -35,14 +36,83 @@ async function api(method, path, body) {
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// ── Remote-image policy — same rules as the main reading pane (app.js) ──
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
async function whitelistSender(sender) {
const r = await api('POST', '/remote-content-whitelist', { sender });
if (r?.ok) { remoteWhitelist.add(sender); render(window._msg, true); }
}
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
// Content-aware height report (leaf elements only — see app.js renderMessageDetail for why
// document.documentElement.scrollHeight is wrong: it counts trailing structural dead space
// some email templates leave behind) + link-click interception.
const heightScript = `<script>
function _reportH(){
try{
var maxBottom=0;
var all=document.body?document.body.getElementsByTagName('*'):[];
for(var i=0;i<all.length;i++){
var el=all[i];
if(el.children.length>0) continue;
var cs=getComputedStyle(el);
if(cs.display==='none'||cs.visibility==='hidden'||parseFloat(cs.opacity||'1')===0) continue;
var hasText=(el.textContent||'').replace(/[\\s\\u00A0]/g,'').length>0;
if(!hasText && el.tagName!=='IMG') continue;
var r=el.getBoundingClientRect();
if(r.bottom>maxBottom) maxBottom=r.bottom;
}
var h=maxBottom>0?maxBottom:document.documentElement.scrollHeight;
parent.postMessage({type:'gomail-frame-h',h:h},'*');
}catch(ex){parent.postMessage({type:'gomail-frame-h',h:0},'*');}
}
document.addEventListener('DOMContentLoaded',_reportH);
window.addEventListener('load',_reportH);
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
if(window.ResizeObserver) new ResizeObserver(_reportH).observe(document.documentElement);
[50,150,400,900,1800,3000].forEach(function(ms){ setTimeout(_reportH, ms); });
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
window.addEventListener('message', e => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const frame = document.getElementById('msg-frame');
if (frame) frame.style.height = (e.data.h + 24) + 'px';
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
window.open(e.data.url, '_blank', 'noopener,noreferrer');
}
});
function render(msg, showRemoteContent) {
window._msg = msg;
const allowed = showRemoteContent || isRemoteContentAllowed(msg.from_email);
const atts = msg.attachments || [];
const attHtml = atts.length ? `
@@ -53,6 +123,28 @@ async function load() {
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
let bodyHtml = '';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!allowed) {
const alwaysAllowBtn = remoteImagePolicy === 'never' ? '' :
`<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>`;
bodyHtml = `<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="render(window._msg,true)">Load images</button>
${alwaysAllowBtn}
</div>`;
html = stripRemoteImages(html);
}
const srcdoc = (cssReset + heightScript + html).replace(/"/g,'&quot;');
bodyHtml += `<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-frame" title="Message content" sandbox="${sandboxAttr}" style="width:100%;border:none;min-height:200px;display:block" srcdoc="${srcdoc}"></iframe>
</div>`;
} else {
bodyHtml = `<div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px;white-space:pre-wrap">${esc(msg.body_text||'(empty)')}</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">
@@ -63,31 +155,37 @@ async function load() {
</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>
${bodyHtml}
${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);
async function load() {
const [msg, folders, uiPrefs, wl] = await Promise.all([
api('GET', '/messages/' + msgId), api('GET', '/folders'),
api('GET', '/ui-prefs'), api('GET', '/remote-content-whitelist'),
]);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// A draft opened here (bookmark, typed URL, old link) should open editable, not read-only.
const folder = (folders||[]).find(f=>f.id===msg.folder_id);
if (folder?.folder_type === 'drafts') { location.replace('/compose?edit_draft_id=' + msgId); return; }
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await api('GET', '/contacts') || [];
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
render(msg, false);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
window.location = '/compose?reply_id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
window.location = '/compose?forward_id=' + msgId;
}
load();
+3 -3
View File
@@ -9,10 +9,10 @@
</div>
<h1>Two-Factor Auth</h1>
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/mfa/verify">
<div class="field"><label>Verification Code</label>
<input type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
<div class="field"><label for="mfa-code">Verification Code</label>
<input id="mfa-code" type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
style="font-size:22px;letter-spacing:.3em;text-align:center">
</div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Verify</button>
+1 -1
View File
@@ -4,5 +4,5 @@ import "embed"
// Global access to the web assets
//
//go:embed web/static/css/* web/static/js/* web/static/img/* web/templates/**
//go:embed web/static/css/* web/static/js/* web/static/img/* web/static/icons/* web/static/manifest.json web/static/sw.js web/templates/**
var WebFS embed.FS