mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-15 00:00:36 +01:00
Compare commits
8
Commits
d470c8b71f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1552da11ab | ||
|
|
45ce8e4e24 | ||
|
|
de7f124d60 | ||
|
|
69d82f1c96 | ||
|
|
77f4b04af6 | ||
|
|
cc9b987e83 | ||
|
|
d005cbb931 | ||
|
|
f506183b6d |
+23
-1
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# GoMail Configuration
|
||||
# =====================
|
||||
# Auto-generated and updated on each startup.
|
||||
# Edit freely — your values are always preserved.
|
||||
# Environment variables (or GOMAIL_<KEY>) override values here.
|
||||
#
|
||||
|
||||
# --- Server ---
|
||||
# Public hostname of this GoMail instance (no port, no protocol).
|
||||
# Examples: localhost | mail.example.com | 192.168.1.10
|
||||
# Used to build BASE_URL and OAuth redirect URIs automatically.
|
||||
# Also used in security checks to reject requests with unexpected Host headers.
|
||||
HOSTNAME = localhost
|
||||
|
||||
# Address and port to listen on. Format: [host]:port
|
||||
# :8080 — all interfaces, port 8080
|
||||
# 0.0.0.0:8080 — all interfaces (explicit)
|
||||
# 127.0.0.1:8080 — localhost only
|
||||
LISTEN_ADDR = :8080
|
||||
|
||||
# Public URL of this instance (no trailing slash). Leave blank to auto-build
|
||||
# from HOSTNAME and LISTEN_ADDR port (recommended).
|
||||
# Auto-build examples:
|
||||
# HOSTNAME=localhost + :8080 → http://localhost:8080
|
||||
# HOSTNAME=mail.example.com + :443 → https://mail.example.com
|
||||
# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080
|
||||
# Override here only if you need a custom path prefix or your proxy rewrites the URL.
|
||||
BASE_URL =
|
||||
|
||||
# Set to true when GoMail is served over HTTPS (directly or via proxy).
|
||||
# Marks session cookies as Secure so browsers only send them over TLS.
|
||||
SECURE_COOKIE = false
|
||||
|
||||
# How long a login session lasts, in seconds. Default: 604800 (7 days).
|
||||
SESSION_MAX_AGE = 604800
|
||||
|
||||
# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.
|
||||
# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,
|
||||
# which GoMail uses to determine the real client IP and whether TLS is in use.
|
||||
# Examples:
|
||||
# 127.0.0.1 (loopback only — Nginx/Traefik on same host)
|
||||
# 10.0.0.0/8,172.16.0.0/12 (private networks)
|
||||
# 192.168.1.50,192.168.1.51 (specific IPs)
|
||||
# Leave blank to disable proxy trust (requests are taken at face value).
|
||||
# NOTE: Do not add untrusted IPs — clients could spoof their source address.
|
||||
TRUSTED_PROXIES =
|
||||
|
||||
# --- Storage ---
|
||||
# Path to the SQLite database file.
|
||||
DB_PATH = ./data/gowebmail.db
|
||||
|
||||
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
|
||||
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.
|
||||
# NOTE: Back this up. Losing it makes the entire database permanently unreadable.
|
||||
# openssl rand -hex 32
|
||||
ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97
|
||||
|
||||
# Secret used to sign session cookies. Auto-generated on first run.
|
||||
# Changing this invalidates all active sessions (everyone gets logged out).
|
||||
SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e
|
||||
|
||||
# --- Gmail / Google OAuth2 ---
|
||||
# Create at: https://console.cloud.google.com/apis/credentials
|
||||
# Application type : Web application
|
||||
# Required scope : https://mail.google.com/
|
||||
# Redirect URI : <BASE_URL>/auth/gmail/callback
|
||||
GOOGLE_CLIENT_ID =
|
||||
|
||||
GOOGLE_CLIENT_SECRET =
|
||||
|
||||
# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Google Cloud Console.
|
||||
GOOGLE_REDIRECT_URL =
|
||||
|
||||
# --- Outlook / Microsoft 365 OAuth2 ---
|
||||
# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
|
||||
# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email
|
||||
# Redirect URI : <BASE_URL>/auth/outlook/callback
|
||||
MICROSOFT_CLIENT_ID =
|
||||
|
||||
MICROSOFT_CLIENT_SECRET =
|
||||
|
||||
# Use 'common' to allow any Microsoft account,
|
||||
# or your Azure tenant ID to restrict to one organisation.
|
||||
MICROSOFT_TENANT_ID = common
|
||||
|
||||
# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Azure.
|
||||
MICROSOFT_REDIRECT_URL =
|
||||
|
||||
@@ -1,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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- PGP identities ----
|
||||
// private_key_armor relies on OpenPGP's own native S2K passphrase protection (no app-layer
|
||||
// encryption needed here, unlike smime.go's key_pem) — stored exactly as produced.
|
||||
|
||||
func (d *DB) ListPGPIdentities(accountID int64) ([]models.PGPIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? ORDER BY created_at DESC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPIdentity
|
||||
for rows.Next() {
|
||||
var p models.PGPIdentity
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetPGPIdentity(accountID, id int64) (*models.PGPIdentity, error) {
|
||||
p := &models.PGPIdentity{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (d *DB) CreatePGPIdentity(accountID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_identities (account_id, label, email, fingerprint, public_key_armor, private_key_armor) VALUES (?,?,?,?,?,?)`,
|
||||
accountID, label, email, fingerprint, publicKeyArmor, privateKeyArmor,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- PGP contact public keys (per-user address book) ----
|
||||
|
||||
func (d *DB) ListPGPContacts(userID int64) ([]models.PGPContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPContact
|
||||
for rows.Next() {
|
||||
var c models.PGPContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPGPContactByEmail looks up a contact's public key by address. Returns nil, nil if not found.
|
||||
func (d *DB) GetPGPContactByEmail(userID int64, email string) (*models.PGPContact, error) {
|
||||
c := &models.PGPContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (d *DB) UpsertPGPContact(userID int64, email, label, fingerprint, publicKeyArmor string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_contacts (user_id, email, label, fingerprint, public_key_armor) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET label=excluded.label, fingerprint=excluded.fingerprint, public_key_armor=excluded.public_key_armor`,
|
||||
userID, email, label, fingerprint, publicKeyArmor,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Rules (filters) ----
|
||||
|
||||
func scanRule(rowConditions, rowActionOptions string, r *models.Rule) {
|
||||
_ = json.Unmarshal([]byte(rowConditions), &r.Conditions)
|
||||
_ = json.Unmarshal([]byte(rowActionOptions), &r.ActionOptions)
|
||||
}
|
||||
|
||||
// ListRules returns all rules for an account, ordered by priority (lowest first, then id).
|
||||
func (d *DB) ListRules(accountID int64) ([]models.Rule, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? ORDER BY priority ASC, id ASC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Rule
|
||||
for rows.Next() {
|
||||
var r models.Rule
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
if err := rows.Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, &r)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListActiveRules returns only is_active rules for an account, same ordering as ListRules.
|
||||
func (d *DB) ListActiveRules(accountID int64) ([]models.Rule, error) {
|
||||
all, err := d.ListRules(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var active []models.Rule
|
||||
for _, r := range all {
|
||||
if r.IsActive {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// GetRule fetches a single rule scoped to an account (so one user can't touch another's rule by id).
|
||||
func (d *DB) GetRule(accountID, id int64) (*models.Rule, error) {
|
||||
r := &models.Rule{}
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, r)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CreateRule inserts a new rule and returns its id.
|
||||
func (d *DB) CreateRule(r *models.Rule) (int64, error) {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
if r.MatchType == "" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO rules (account_id, name, priority, conditions, match_type, action, action_value, action_options, is_active)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
r.AccountID, r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateRule replaces an existing rule's fields (scoped to account_id).
|
||||
func (d *DB) UpdateRule(r *models.Rule) error {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
_, err = d.sql.Exec(
|
||||
`UPDATE rules SET name=?, priority=?, conditions=?, match_type=?, action=?, action_value=?, action_options=?, is_active=?
|
||||
WHERE id=? AND account_id=?`,
|
||||
r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
r.ID, r.AccountID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRule removes a rule (scoped to account_id).
|
||||
func (d *DB) DeleteRule(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM rules WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// HasRecentAutoReply reports whether an auto-reply was already sent to recipientEmail
|
||||
// for this rule within the last 24h, to prevent auto-reply loops.
|
||||
func (d *DB) HasRecentAutoReply(accountID, ruleID int64, recipientEmail string) (bool, error) {
|
||||
var n int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM auto_reply_log
|
||||
WHERE account_id=? AND rule_id=? AND recipient_email=? COLLATE NOCASE
|
||||
AND sent_at > datetime('now', '-1 day')`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// LogAutoReply records that an auto-reply was just sent, for HasRecentAutoReply's window check.
|
||||
func (d *DB) LogAutoReply(accountID, ruleID int64, recipientEmail string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO auto_reply_log (account_id, rule_id, recipient_email) VALUES (?,?,?)`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Signatures ----
|
||||
|
||||
// ListSignatures returns all signatures owned by a user.
|
||||
func (d *DB) ListSignatures(userID int64) ([]models.Signature, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? ORDER BY name`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Signature
|
||||
for rows.Next() {
|
||||
var s models.Signature
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSignature fetches one signature scoped to its owning user.
|
||||
func (d *DB) GetSignature(userID, id int64) (*models.Signature, error) {
|
||||
s := &models.Signature{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? AND id=?`, userID, id,
|
||||
).Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// CreateSignature inserts a new signature and returns its id.
|
||||
func (d *DB) CreateSignature(userID int64, name, contentHTML string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO signatures (user_id, name, content_html) VALUES (?,?,?)`, userID, name, contentHTML,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateSignature updates name/content of a signature (scoped to owner).
|
||||
func (d *DB) UpdateSignature(userID, id int64, name, contentHTML string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`UPDATE signatures SET name=?, content_html=? WHERE id=? AND user_id=?`, name, contentHTML, id, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSignature removes a signature (scoped to owner). Any signature_defaults rows
|
||||
// pointing at it are cleared automatically via ON DELETE SET NULL.
|
||||
func (d *DB) DeleteSignature(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM signatures WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSignatureDefaults returns the default-new/default-reply signature ids for an account.
|
||||
// Returns a zero-value struct (no error) if the account has no defaults row yet.
|
||||
func (d *DB) GetSignatureDefaults(accountID int64) (models.SignatureDefaults, error) {
|
||||
sd := models.SignatureDefaults{AccountID: accountID}
|
||||
var newID, replyID sql.NullInt64
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT default_new_id, default_reply_id FROM signature_defaults WHERE account_id=?`, accountID,
|
||||
).Scan(&newID, &replyID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sd, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sd, err
|
||||
}
|
||||
sd.DefaultNewID = newID.Int64
|
||||
sd.DefaultReplyID = replyID.Int64
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
// SetSignatureDefaults upserts which signature is default-for-new / default-for-reply on an account.
|
||||
// A ProviderID of 0 clears that default (stored as NULL).
|
||||
func (d *DB) SetSignatureDefaults(accountID, defaultNewID, defaultReplyID int64) error {
|
||||
var newVal, replyVal interface{}
|
||||
if defaultNewID > 0 {
|
||||
newVal = defaultNewID
|
||||
}
|
||||
if defaultReplyID > 0 {
|
||||
replyVal = defaultReplyID
|
||||
}
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO signature_defaults (account_id, default_new_id, default_reply_id) VALUES (?,?,?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET default_new_id=excluded.default_new_id, default_reply_id=excluded.default_reply_id`,
|
||||
accountID, newVal, replyVal,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- S/MIME identities ----
|
||||
// key_pem is encrypted at rest via d.enc (internal/crypto.Encryptor), same as OAuth tokens elsewhere.
|
||||
|
||||
// ListSMIMEIdentities returns all S/MIME identities for an account (key_pem decrypted).
|
||||
func (d *DB) ListSMIMEIdentities(accountID int64) ([]models.SMIMEIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? ORDER BY created_at DESC`,
|
||||
accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEIdentity
|
||||
for rows.Next() {
|
||||
var s models.SMIMEIdentity
|
||||
var keyEnc string
|
||||
if err := rows.Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEIdentity fetches one S/MIME identity scoped to its account (key_pem decrypted).
|
||||
func (d *DB) GetSMIMEIdentity(accountID, id int64) (*models.SMIMEIdentity, error) {
|
||||
s := &models.SMIMEIdentity{}
|
||||
var keyEnc string
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? AND id=?`,
|
||||
accountID, id,
|
||||
).Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// CreateSMIMEIdentity encrypts keyPEM at rest and inserts a new identity, returning its id.
|
||||
func (d *DB) CreateSMIMEIdentity(accountID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
|
||||
keyEnc, err := d.enc.Encrypt(keyPEM)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO smime_identities (account_id, cert_pem, key_pem, not_after) VALUES (?,?,?,?)`,
|
||||
accountID, certPEM, keyEnc, notAfter,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteSMIMEIdentity removes an identity (scoped to account_id).
|
||||
func (d *DB) DeleteSMIMEIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- S/MIME contact certs (per-user address book, unencrypted — public certs only) ----
|
||||
|
||||
func (d *DB) ListSMIMEContacts(userID int64) ([]models.SMIMEContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEContact
|
||||
for rows.Next() {
|
||||
var c models.SMIMEContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEContactByEmail looks up a contact's cert by address (used when signer/encryptor
|
||||
// needs to know if a recipient has a cert on file). Returns nil, nil if not found.
|
||||
func (d *DB) GetSMIMEContactByEmail(userID int64, email string) (*models.SMIMEContact, error) {
|
||||
c := &models.SMIMEContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// UpsertSMIMEContact adds or replaces a contact's cert for an email address.
|
||||
func (d *DB) UpsertSMIMEContact(userID int64, email, certPEM string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO smime_contacts (user_id, email, cert_pem) VALUES (?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET cert_pem=excluded.cert_pem`,
|
||||
userID, email, certPEM,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSMIMEContact removes a contact cert (scoped to owner).
|
||||
func (d *DB) DeleteSMIMEContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
+301
-44
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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: "/",
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
var validRuleFields = map[string]bool{
|
||||
"from": true, "to": true, "subject": true, "body": true, "has_attachment": true, "recipient_type": true,
|
||||
}
|
||||
var validRuleOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||
var validRuleActions = map[string]bool{
|
||||
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
|
||||
}
|
||||
|
||||
// ownAccount verifies accountID belongs to the current user, writing a 404 and returning false if not.
|
||||
func (h *APIHandler) ownAccount(w http.ResponseWriter, r *http.Request, accountID int64) bool {
|
||||
userID := middleware.GetUserID(r)
|
||||
account, err := h.db.GetAccount(accountID)
|
||||
if err != nil || account == nil || account.UserID != userID {
|
||||
h.writeError(w, http.StatusNotFound, "account not found")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Rules ----
|
||||
|
||||
func (h *APIHandler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
rules, err := h.db.ListRules(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list rules")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, rules)
|
||||
}
|
||||
|
||||
func validateRule(r *models.Rule) string {
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
return "name required"
|
||||
}
|
||||
if len(r.Conditions) == 0 {
|
||||
return "at least one condition required"
|
||||
}
|
||||
for _, c := range r.Conditions {
|
||||
if !validRuleFields[c.Field] {
|
||||
return "invalid condition field: " + c.Field
|
||||
}
|
||||
if !validRuleOps[c.Op] {
|
||||
return "invalid condition op: " + c.Op
|
||||
}
|
||||
if strings.TrimSpace(c.Value) == "" {
|
||||
return "condition value required"
|
||||
}
|
||||
}
|
||||
if r.MatchType != "any" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
if !validRuleActions[r.Action] {
|
||||
return "invalid action: " + r.Action
|
||||
}
|
||||
if r.Action == "move_to_folder" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "folder name required for move_to_folder"
|
||||
}
|
||||
if r.Action == "forward" && !strings.Contains(r.ActionValue, "@") {
|
||||
return "valid forward address required"
|
||||
}
|
||||
if r.Action == "auto_reply" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "auto-reply subject required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateRule(&req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetRule(req.AccountID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.db.UpdateRule(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteRule(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
)
|
||||
|
||||
func (h *APIHandler) ListSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
sigs, err := h.db.ListSignatures(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list signatures")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, sigs)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateSignature(userID, req.Name, req.ContentHTML)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetSignature(userID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateSignature(userID, id, req.Name, req.ContentHTML); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteSignature(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// SetSignatureDefaults sets which signature (if any) is default-for-new / default-for-reply
|
||||
// on one account. A ProviderID of 0 in the request clears that default.
|
||||
func (h *APIHandler) SetSignatureDefaults(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := pathInt64(r, "id")
|
||||
if !h.ownAccount(w, r, accountID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DefaultNewID int64 `json:"default_new_id"`
|
||||
DefaultReplyID int64 `json:"default_reply_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
// A signature id of 0 is "clear this default" — otherwise verify the user actually owns it.
|
||||
userID := middleware.GetUserID(r)
|
||||
if req.DefaultNewID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultNewID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_new_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.DefaultReplyID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultReplyID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_reply_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.db.SetSignatureDefaults(accountID, req.DefaultNewID, req.DefaultReplyID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to set defaults")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Package jmap is a minimal JMAP (RFC 8620 Core + RFC 8621 Mail) client for
|
||||
// ProviderJMAP accounts — an alternative to IMAP/SMTP for mail servers that
|
||||
// speak JMAP instead. It follows internal/graph's shape (a thin REST/JSON
|
||||
// wrapper), since both are HTTP+JSON providers unlike IMAP's binary protocol.
|
||||
//
|
||||
// Authenticated via HTTP Basic (mailbox email + app password), matching the
|
||||
// reference server this was built against — see tests/jmap-client.md.
|
||||
// One HTTP call per JMAP method call: no request batching or back-references,
|
||||
// since sync here isn't latency-sensitive enough to justify that complexity.
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps JMAP API calls for a single mailbox account.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
accountID string // resolved lazily from /jmap/session
|
||||
apiURL string
|
||||
uploadURL string
|
||||
}
|
||||
|
||||
// New creates a JMAP client. baseURL is the server's base URL, e.g.
|
||||
// "https://mail.example.com:8443" (no trailing slash needed).
|
||||
func New(baseURL, username, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
username: username,
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
// Force HTTP/1.1: the reference server (tests/jmap-client.md)
|
||||
// closes the connection with no response over HTTP/2 — verified
|
||||
// live (curl negotiates h2 by default and gets a broken pipe;
|
||||
// --http1.1 works). TLSNextProto disables Go's automatic h2 ALPN
|
||||
// upgrade for HTTPS requests.
|
||||
Transport: &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doReq(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
url := path
|
||||
if !strings.HasPrefix(path, "http") {
|
||||
url = c.baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("jmap %s %s returned %d: %s", method, path, resp.StatusCode, string(b))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Session is the RFC 8620 §2 session resource.
|
||||
type Session struct {
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
Username string `json:"username"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// Session fetches /jmap/session and resolves the mail account id + API/upload
|
||||
// URLs. Also serves as a pure connectivity/auth test (used by TestConnection).
|
||||
func (c *Client) Session(ctx context.Context) (*Session, error) {
|
||||
resp, err := c.doReq(ctx, http.MethodGet, "/jmap/session", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var s Session
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("decode jmap session: %w", err)
|
||||
}
|
||||
c.accountID = s.PrimaryAccounts["urn:ietf:params:jmap:mail"]
|
||||
if c.accountID == "" {
|
||||
return nil, fmt.Errorf("jmap session: no mail account found")
|
||||
}
|
||||
c.apiURL = s.APIURL
|
||||
c.uploadURL = strings.ReplaceAll(s.UploadURL, "{accountId}", c.accountID)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureSession(ctx context.Context) error {
|
||||
if c.accountID != "" {
|
||||
return nil
|
||||
}
|
||||
_, err := c.Session(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
type apiRequest struct {
|
||||
Using []string `json:"using"`
|
||||
MethodCalls [][3]interface{} `json:"methodCalls"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
MethodResponses [][]json.RawMessage `json:"methodResponses"`
|
||||
}
|
||||
|
||||
// call makes a single JMAP method call and decodes its result args into out
|
||||
// (which may be nil if the caller doesn't need the response body).
|
||||
func (c *Client) call(ctx context.Context, method string, args map[string]interface{}, out interface{}) error {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
body := apiRequest{
|
||||
Using: []string{
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
},
|
||||
MethodCalls: [][3]interface{}{{method, args, "c1"}},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.apiURL, bytes.NewReader(b), "application/json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var ar apiResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
|
||||
return fmt.Errorf("decode jmap response: %w", err)
|
||||
}
|
||||
if len(ar.MethodResponses) == 0 || len(ar.MethodResponses[0]) < 2 {
|
||||
return fmt.Errorf("jmap %s: empty or malformed response", method)
|
||||
}
|
||||
first := ar.MethodResponses[0]
|
||||
var name string
|
||||
json.Unmarshal(first[0], &name)
|
||||
if name == "error" {
|
||||
return fmt.Errorf("jmap %s error: %s", method, string(first[1]))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(first[1], out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) withAccount(args map[string]interface{}) map[string]interface{} {
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
args["accountId"] = c.accountID
|
||||
return args
|
||||
}
|
||||
|
||||
// ---- Mailboxes ----
|
||||
|
||||
// Mailbox is a JMAP folder.
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parentId"`
|
||||
Role string `json:"role"` // "inbox","sent","drafts","trash","junk", or "" for custom folders
|
||||
TotalEmails int `json:"totalEmails"`
|
||||
UnreadEmails int `json:"unreadEmails"`
|
||||
}
|
||||
|
||||
// InferFolderType maps a JMAP Mailbox role to gowebmail's folder type.
|
||||
func InferFolderType(role string) string {
|
||||
switch role {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "trash":
|
||||
return "trash"
|
||||
case "junk":
|
||||
return "spam"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// ListMailboxes returns every mailbox (folder) for the account.
|
||||
func (c *Client) ListMailboxes(ctx context.Context) ([]Mailbox, error) {
|
||||
var out struct {
|
||||
List []Mailbox `json:"list"`
|
||||
}
|
||||
if err := c.call(ctx, "Mailbox/get", c.withAccount(nil), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// FindMailboxByRole returns the id of the mailbox with the given role (e.g.
|
||||
// "sent", "inbox"), or an error if none is found.
|
||||
func (c *Client) FindMailboxByRole(ctx context.Context, role string) (string, error) {
|
||||
boxes, err := c.ListMailboxes(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range boxes {
|
||||
if b.Role == role {
|
||||
return b.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no mailbox with role %q", role)
|
||||
}
|
||||
|
||||
// ---- Emails ----
|
||||
|
||||
// EmailAddr is a JMAP EmailAddress object.
|
||||
type EmailAddr struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// BodyPart is an entry in an Email's textBody/htmlBody list.
|
||||
type BodyPart struct {
|
||||
PartID string `json:"partId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// BodyValue is the decoded content for one BodyPart, keyed by partId in Email.BodyValues.
|
||||
type BodyValue struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Email is a JMAP message. Keywords/mailboxIds mirror IMAP flags/folder
|
||||
// membership, except a message here lives in exactly one mailbox (see
|
||||
// tests/jmap-client.md — "single-mailbox membership").
|
||||
type Email struct {
|
||||
ID string `json:"id"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
Keywords map[string]bool `json:"keywords"`
|
||||
Size int `json:"size"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Subject string `json:"subject"`
|
||||
From []EmailAddr `json:"from"`
|
||||
To []EmailAddr `json:"to"`
|
||||
Preview string `json:"preview"`
|
||||
HasAttachment bool `json:"hasAttachment"`
|
||||
TextBody []BodyPart `json:"textBody"`
|
||||
HTMLBody []BodyPart `json:"htmlBody"`
|
||||
BodyValues map[string]BodyValue `json:"bodyValues"`
|
||||
}
|
||||
|
||||
func (e *Email) FromName() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Name
|
||||
}
|
||||
|
||||
func (e *Email) FromEmail() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Email
|
||||
}
|
||||
|
||||
func (e *Email) ToList() string {
|
||||
parts := make([]string, 0, len(e.To))
|
||||
for _, t := range e.To {
|
||||
parts = append(parts, t.Email)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (e *Email) IsRead() bool { return e.Keywords["$seen"] }
|
||||
func (e *Email) IsFlagged() bool { return e.Keywords["$flagged"] }
|
||||
|
||||
// TextValue returns the plain-text body, if fetched via GetEmailBody.
|
||||
func (e *Email) TextValue() string {
|
||||
for _, p := range e.TextBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HTMLValue returns the HTML body, if fetched via GetEmailBody.
|
||||
func (e *Email) HTMLValue() string {
|
||||
for _, p := range e.HTMLBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListEmails returns cheap-field emails in mailboxID. Newest-first order is
|
||||
// not guaranteed (the reference server's Email/query sort support is
|
||||
// undocumented — see tests/jmap-client.md — so no sort is requested; callers
|
||||
// that need a specific order should sort client-side).
|
||||
func (c *Client) ListEmails(ctx context.Context, mailboxID string, limit int) ([]Email, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var qout struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
qargs := c.withAccount(map[string]interface{}{
|
||||
"filter": map[string]string{"inMailbox": mailboxID},
|
||||
"limit": limit,
|
||||
})
|
||||
if err := c.call(ctx, "Email/query", qargs, &qout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qout.IDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return c.GetEmails(ctx, qout.IDs, false)
|
||||
}
|
||||
|
||||
// GetEmails fetches full Email objects for ids. withBody also fetches
|
||||
// text/html body content (an expensive decrypt+MIME-parse server-side).
|
||||
func (c *Client) GetEmails(ctx context.Context, ids []string, withBody bool) ([]Email, error) {
|
||||
var out struct {
|
||||
List []Email `json:"list"`
|
||||
}
|
||||
args := c.withAccount(map[string]interface{}{"ids": ids})
|
||||
if withBody {
|
||||
args["fetchTextBodyValues"] = true
|
||||
args["fetchHTMLBodyValues"] = true
|
||||
}
|
||||
if err := c.call(ctx, "Email/get", args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// GetEmailBody fetches a single email with its full text/html body.
|
||||
func (c *Client) GetEmailBody(ctx context.Context, id string) (*Email, error) {
|
||||
list, err := c.GetEmails(ctx, []string{id}, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("email %s not found", id)
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// SetKeyword sets or clears a single keyword (e.g. "$seen", "$flagged") on a message.
|
||||
func (c *Client) SetKeyword(ctx context.Context, emailID, keyword string, on bool) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"keywords/" + keyword: on},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap keyword update rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveEmail reassigns a message to a different (single) mailbox.
|
||||
func (c *Client) MoveEmail(ctx context.Context, emailID, destMailboxID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"mailboxIds": map[string]bool{destMailboxID: true}},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap move rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEmail hard-deletes a message. Unlike Mailbox/set destroy (soft, see
|
||||
// tests/jmap-client.md), Email/set destroy is a real, unrecoverable delete.
|
||||
func (c *Client) DeleteEmail(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{"destroy": []string{emailID}})
|
||||
var out struct {
|
||||
NotDestroyed map[string]json.RawMessage `json:"notDestroyed"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotDestroyed[emailID]; bad {
|
||||
return fmt.Errorf("jmap delete rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Sending ----
|
||||
|
||||
// UploadBlob uploads raw message bytes and returns the blob id.
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte) (string, error) {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.uploadURL, bytes.NewReader(data), "message/rfc822")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
BlobID string `json:"blobId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("decode jmap upload response: %w", err)
|
||||
}
|
||||
return out.BlobID, nil
|
||||
}
|
||||
|
||||
// ImportEmail imports an uploaded blob as a message into mailboxID, returning
|
||||
// the new email id. There is no Email/set create (see tests/jmap-client.md) —
|
||||
// this upload+import step is the only way to add a message.
|
||||
func (c *Client) ImportEmail(ctx context.Context, blobID, mailboxID string) (string, error) {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"emails": map[string]interface{}{
|
||||
"c1": map[string]interface{}{
|
||||
"blobId": blobID,
|
||||
"mailboxIds": map[string]bool{mailboxID: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
Created map[string]struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"created"`
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/import", args, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created, ok := out.Created["c1"]; ok {
|
||||
return created.ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("jmap import failed: %s", out.NotCreated["c1"])
|
||||
}
|
||||
|
||||
// Submit sends a previously-imported message via EmailSubmission/set.
|
||||
func (c *Client) Submit(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"create": map[string]interface{}{
|
||||
"s1": map[string]interface{}{"emailId": emailID},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "EmailSubmission/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotCreated["s1"]; bad {
|
||||
return fmt.Errorf("jmap submission rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send uploads rawMessage, imports it into mailboxID (typically the Sent
|
||||
// mailbox — the server doesn't auto-file after submission), and submits it
|
||||
// for delivery.
|
||||
func (c *Client) Send(ctx context.Context, mailboxID string, rawMessage []byte) error {
|
||||
blobID, err := c.UploadBlob(ctx, rawMessage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap upload: %w", err)
|
||||
}
|
||||
emailID, err := c.ImportEmail(ctx, blobID, mailboxID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap import: %w", err)
|
||||
}
|
||||
if err := c.Submit(ctx, emailID); err != nil {
|
||||
return fmt.Errorf("jmap submit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+167
-18
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
// Cache holds unlocked (passphrase-decrypted) PGP identities in memory, scoped to the
|
||||
// session that unlocked them — never written to disk. No TTL: memory-bounded by active
|
||||
// sessions, cleared only on explicit logout (see internal/handlers/auth.go Logout).
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
byTok map[string]map[int64]*openpgp.Entity // sessionToken -> identityID -> unlocked entity
|
||||
}
|
||||
|
||||
// NewCache creates an empty unlocked-key cache.
|
||||
func NewCache() *Cache {
|
||||
return &Cache{byTok: make(map[string]map[int64]*openpgp.Entity)}
|
||||
}
|
||||
|
||||
// Get returns the unlocked entity for identityID under sessionToken, if present.
|
||||
func (c *Cache) Get(sessionToken string, identityID int64) (*openpgp.Entity, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
e, ok := m[identityID]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Put stores an unlocked entity under sessionToken.
|
||||
func (c *Cache) Put(sessionToken string, identityID int64, entity *openpgp.Entity) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
m = make(map[int64]*openpgp.Entity)
|
||||
c.byTok[sessionToken] = m
|
||||
}
|
||||
m[identityID] = entity
|
||||
}
|
||||
|
||||
// ClearSession discards every unlocked identity for a session (call on logout).
|
||||
func (c *Cache) ClearSession(sessionToken string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.byTok, sessionToken)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestEncryptMIMERoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("frank@example.com", "hunter2hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte(
|
||||
"Message-ID: <1.frank.example.com@example.com>\r\n" +
|
||||
"From: Frank <frank@example.com>\r\n" +
|
||||
"To: grace@example.com\r\n" +
|
||||
"Subject: Secret\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: quoted-printable\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Grace! This is secret.\r\n")
|
||||
|
||||
encryptedMsg, err := EncryptMIME(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptMIME: %v", err)
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(encryptedMsg))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Secret" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive encryption)", got, "Secret")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/encrypted" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/encrypted", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
if _, err := mr.NextPart(); err != nil { // control part: application/pgp-encrypted, Version: 1
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
armored, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if err := UnlockPrivateKey(privEntity, "hunter2hunter2"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(armored, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
want := "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello, Grace! This is secret.\r\n"
|
||||
if string(decrypted) != want {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
|
||||
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
|
||||
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
|
||||
// "unsafe by design". This package is encryption-only: no PGP signature generation
|
||||
// or verification (S/MIME, internal/smime, handles signing).
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
func defaultConfig() *packet.Config {
|
||||
return &packet.Config{
|
||||
DefaultCipher: packet.CipherAES256, // library default is AES-128
|
||||
RSABits: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
|
||||
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
|
||||
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
|
||||
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
config := defaultConfig()
|
||||
entity, err := openpgp.NewEntity(email, "", email, config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate entity: %w", err)
|
||||
}
|
||||
if err := lockEntity(entity, passphrase); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor, err = serializePrivate(entity, config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
func lockEntity(entity *openpgp.Entity, passphrase string) error {
|
||||
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock primary key: %w", err)
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey == nil {
|
||||
continue
|
||||
}
|
||||
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := entity.Serialize(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
|
||||
// which requires the (now-encrypted) private key and fails once it's locked.
|
||||
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
|
||||
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
|
||||
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
entity, err := ParsePrivateKey(armoredData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Verify the passphrase actually unlocks it before accepting the import.
|
||||
if err := UnlockPrivateKey(entity, passphrase); err != nil {
|
||||
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor = armoredData
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
// ParsePublicKey reads a single armored public key.
|
||||
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
// ParsePrivateKey reads a single armored private key. The key remains locked
|
||||
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
|
||||
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
|
||||
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return nil, fmt.Errorf("no key found in armored data")
|
||||
}
|
||||
return entities[0], nil
|
||||
}
|
||||
|
||||
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
|
||||
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
|
||||
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock primary key: %w", err)
|
||||
}
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
||||
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
|
||||
func Fingerprint(entity *openpgp.Entity) string {
|
||||
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
|
||||
}
|
||||
|
||||
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
|
||||
// recipients' public keys.
|
||||
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
if _, err := pt.Write(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pt.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := aw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptEntity opens an armored PGP message using an already-unlocked identity
|
||||
// (see UnlockPrivateKey).
|
||||
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
|
||||
block, err := armor.Decode(bytes.NewReader(armored))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode armor: %w", err)
|
||||
}
|
||||
keyring := openpgp.EntityList{unlockedIdentity}
|
||||
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
return io.ReadAll(md.UnverifiedBody)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
|
||||
|
||||
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
|
||||
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
|
||||
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
|
||||
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
|
||||
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
|
||||
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
|
||||
// what was encrypted regardless of a trailing CRLF.
|
||||
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted, err := EncryptEntity(entity, recipients)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
|
||||
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
|
||||
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
|
||||
out.Write(encrypted)
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
|
||||
// stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
|
||||
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
|
||||
// Encoding/Content-Disposition headers plus blank line plus body.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("carol@example.com", "correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("the secret message body")
|
||||
encrypted, err := EncryptEntity(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptEntity: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if !privEntity.PrivateKey.Encrypted {
|
||||
t.Fatal("private key should be Encrypted (passphrase-protected) before unlocking")
|
||||
}
|
||||
|
||||
// Wrong passphrase must fail.
|
||||
if err := UnlockPrivateKey(privEntity, "wrong-passphrase"); err == nil {
|
||||
t.Error("UnlockPrivateKey succeeded with wrong passphrase, want error")
|
||||
}
|
||||
|
||||
if err := UnlockPrivateKey(privEntity, "correct-horse-battery-staple"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(encrypted, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
c := NewCache()
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected empty cache miss")
|
||||
}
|
||||
e := &openpgp.Entity{}
|
||||
c.Put("tok1", 1, e)
|
||||
got, ok := c.Get("tok1", 1)
|
||||
if !ok || got != e {
|
||||
t.Fatal("expected cache hit for tok1/1")
|
||||
}
|
||||
c.ClearSession("tok1")
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected cache miss after ClearSession")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package rules implements mail-filter matching: given a message and an account's
|
||||
// active rules (already ordered by priority), find the first rule that matches.
|
||||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
// Condition is one field/op/value test. Mirrors models.RuleCondition but this package
|
||||
// stays free of the models/db dependency so Match is trivially unit-testable.
|
||||
type Condition struct {
|
||||
Field string
|
||||
Op string
|
||||
Value string
|
||||
}
|
||||
|
||||
// MessageFields is the subset of a message's data rules can match against.
|
||||
type MessageFields struct {
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
HasAttachment bool
|
||||
RecipientType string // "to" | "cc" | "bcc"
|
||||
}
|
||||
|
||||
// Rule is one filter: conditions (AND'd or OR'd per MatchType) plus an action.
|
||||
type Rule struct {
|
||||
ID int64
|
||||
Priority int
|
||||
Conditions []Condition
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
Action string
|
||||
ActionValue string
|
||||
ActionOptions map[string]any
|
||||
}
|
||||
|
||||
// Match returns the first rule (by priority, ascending) whose conditions match msg,
|
||||
// or nil if none match. Callers must pass rules pre-filtered to is_active and pre-sorted
|
||||
// by priority ascending (ListActiveRules already does this).
|
||||
func Match(msg MessageFields, activeRules []Rule) *Rule {
|
||||
for i := range activeRules {
|
||||
if ruleMatches(&activeRules[i], msg) {
|
||||
return &activeRules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ruleMatches(r *Rule, msg MessageFields) bool {
|
||||
if len(r.Conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
if r.MatchType == "any" {
|
||||
for _, c := range r.Conditions {
|
||||
if conditionMatches(c, msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// default "all" (AND)
|
||||
for _, c := range r.Conditions {
|
||||
if !conditionMatches(c, msg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionMatches(c Condition, msg MessageFields) bool {
|
||||
var target string
|
||||
switch c.Field {
|
||||
case "from":
|
||||
target = msg.From
|
||||
case "to":
|
||||
target = msg.To
|
||||
case "subject":
|
||||
target = msg.Subject
|
||||
case "body":
|
||||
target = msg.Body
|
||||
case "has_attachment":
|
||||
if msg.HasAttachment {
|
||||
target = "yes"
|
||||
} else {
|
||||
target = "no"
|
||||
}
|
||||
case "recipient_type":
|
||||
target = msg.RecipientType
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return matchOp(c.Op, c.Value, target)
|
||||
}
|
||||
|
||||
func matchOp(op, value, target string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
target = strings.ToLower(target)
|
||||
switch op {
|
||||
case "contains":
|
||||
return value != "" && strings.Contains(target, value)
|
||||
case "equals":
|
||||
return target == value
|
||||
case "starts_with":
|
||||
return value != "" && strings.HasPrefix(target, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
msg := MessageFields{
|
||||
From: "boss@work.com",
|
||||
To: "me@example.com",
|
||||
Subject: "Weekly Report Due",
|
||||
Body: "please see attached",
|
||||
HasAttachment: true,
|
||||
RecipientType: "to",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rules []Rule
|
||||
want string // expected matched rule action value marker, "" for no match
|
||||
}{
|
||||
{
|
||||
name: "single contains condition matches",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "work.com"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "equals is case-insensitive and exact",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "to", Op: "equals", Value: "ME@EXAMPLE.COM"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "starts_with no match",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "subject", Op: "starts_with", Value: "URGENT"}}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type all requires every condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "work.com"},
|
||||
{Field: "subject", Op: "contains", Value: "NOPE"},
|
||||
}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type any needs only one condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "any", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "NOPE"},
|
||||
{Field: "subject", Op: "contains", Value: "Report"},
|
||||
}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "has_attachment field",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "has_attachment", Op: "equals", Value: "yes"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "first matching rule in list order wins, later matching rules ignored",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 5, MatchType: "all", ActionValue: "nope",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "does-not-appear"}}},
|
||||
{ID: 2, Priority: 0, MatchType: "all", ActionValue: "first",
|
||||
Conditions: []Condition{{Field: "to", Op: "contains", Value: "example"}}},
|
||||
{ID: 3, Priority: 10, MatchType: "all", ActionValue: "second",
|
||||
Conditions: []Condition{{Field: "subject", Op: "contains", Value: "Report"}}},
|
||||
},
|
||||
want: "first", // Match trusts caller ordering (ListActiveRules sorts by priority ASC before calling)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Match(msg, tc.rules)
|
||||
gotVal := ""
|
||||
if got != nil {
|
||||
gotVal = got.ActionValue
|
||||
}
|
||||
if gotVal != tc.want {
|
||||
t.Errorf("Match() = %q, want %q", gotVal, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignMIMERoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("dave@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
// CTE deliberately "7bit", not "quoted-printable": Go's mime/multipart.Part.Read
|
||||
// auto-decodes quoted-printable/base64 parts, which would make this test compare
|
||||
// decoded bytes against the raw wire bytes that were actually signed — a test-harness
|
||||
// footgun, not a production concern (a spec-compliant S/MIME verifier signs/checks the
|
||||
// encoded wire octets, never the decoded form).
|
||||
raw := []byte(
|
||||
"Message-ID: <1.dave.example.com@example.com>\r\n" +
|
||||
"From: Dave <dave@example.com>\r\n" +
|
||||
"To: eve@example.com\r\n" +
|
||||
"Subject: Hello\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: 7bit\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Eve!\r\n")
|
||||
|
||||
signed, err := SignMIME(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("SignMIME: %v", err)
|
||||
}
|
||||
|
||||
// Parse it back like a real mail client would: read top-level headers, find the
|
||||
// multipart/signed boundary, split into the two parts, and verify.
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(signed))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Hello" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive signing)", got, "Hello")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/signed" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/signed", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
part1, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part1Headers := "Content-Type: " + part1.Header.Get("Content-Type") + "\r\n"
|
||||
if cte := part1.Header.Get("Content-Transfer-Encoding"); cte != "" {
|
||||
part1Headers += "Content-Transfer-Encoding: " + cte + "\r\n"
|
||||
}
|
||||
part1Body, err := io.ReadAll(part1)
|
||||
if err != nil {
|
||||
t.Fatalf("read first part: %v", err)
|
||||
}
|
||||
entity := append([]byte(part1Headers+"\r\n"), part1Body...)
|
||||
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
sigB64, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigB64)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature base64: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(entity, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.EmailAddresses[0] != "dave@example.com" {
|
||||
t.Errorf("signer = %v, want dave@example.com", signer.EmailAddresses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package smime provides S/MIME certificate generation, signing, and encryption
|
||||
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
|
||||
//
|
||||
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
|
||||
// signature matches the given certificate, not that the certificate is trusted by any
|
||||
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
|
||||
// that want a "known sender" UI hint should compare against the user's own S/MIME
|
||||
// contact address book, not treat a successful Verify as proof of identity.
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
|
||||
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
|
||||
}
|
||||
|
||||
// DefaultValidity is the lifetime used for a freshly self-signed identity.
|
||||
const DefaultValidity = 365 * 24 * time.Hour
|
||||
|
||||
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
|
||||
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: email},
|
||||
EmailAddresses: []string{email},
|
||||
NotBefore: time.Now().Add(-5 * time.Minute),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
|
||||
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
|
||||
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
|
||||
key, cert, err := pkcs12.Decode(data, password)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decode p12: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
|
||||
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid certificate PEM")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
|
||||
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid key PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
|
||||
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sd, err := pkcs7.NewSignedData(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new signed data: %w", err)
|
||||
}
|
||||
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
|
||||
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
|
||||
return nil, fmt.Errorf("add signer: %w", err)
|
||||
}
|
||||
sd.Detach()
|
||||
return sd.Finish()
|
||||
}
|
||||
|
||||
// VerifySigned checks a detached signature against the original content and returns the
|
||||
// signer's certificate. It does NOT validate the certificate against any trust store —
|
||||
// see the package doc comment.
|
||||
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
|
||||
p7, err := pkcs7.Parse(signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse signature: %w", err)
|
||||
}
|
||||
p7.Content = raw
|
||||
if err := p7.Verify(); err != nil {
|
||||
return nil, fmt.Errorf("verify: %w", err)
|
||||
}
|
||||
signer := p7.GetOnlySigner()
|
||||
if signer == nil {
|
||||
return nil, errors.New("no signer certificate found in signature")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
|
||||
// smime-type=enveloped-data) for the given recipient certificates.
|
||||
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
|
||||
return pkcs7.Encrypt(raw, recipients)
|
||||
}
|
||||
|
||||
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
|
||||
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p7, err := pkcs7.Parse(enveloped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse enveloped data: %w", err)
|
||||
}
|
||||
return p7.Decrypt(cert, key)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
|
||||
//
|
||||
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
|
||||
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
|
||||
// above only handle the CMS blob itself.
|
||||
|
||||
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
|
||||
// original message's Content-Type + body become the first part, and a detached CMS
|
||||
// signature over that part becomes the second. All other top-level headers (From, To,
|
||||
// Subject, Date, Message-ID, ...) are preserved unchanged.
|
||||
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
|
||||
// the delimiter, not the signed content — a compliant multipart parser hands back the
|
||||
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
|
||||
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
|
||||
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
|
||||
sig, err := Sign(certPEM, keyPEM, signedContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.Write(signedContent)
|
||||
out.WriteString("\r\n--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
|
||||
out.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
|
||||
out.WriteString(base64Wrap(sig))
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
|
||||
// not stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
|
||||
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
|
||||
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
|
||||
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
|
||||
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
|
||||
func base64Wrap(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
out.WriteString(encoded[i:end])
|
||||
if end < len(encoded) {
|
||||
out.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("alice@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("this is the raw MIME message body")
|
||||
sig, err := Sign(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(raw, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if len(signer.EmailAddresses) == 0 || signer.EmailAddresses[0] != "alice@example.com" {
|
||||
t.Errorf("signer email = %v, want [alice@example.com]", signer.EmailAddresses)
|
||||
}
|
||||
|
||||
// Tamper one byte of the content — verification must fail.
|
||||
tampered := bytes.Clone(raw)
|
||||
tampered[0] ^= 0xFF
|
||||
if _, err := VerifySigned(tampered, sig); err == nil {
|
||||
t.Error("VerifySigned succeeded against tampered content, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("bob@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertPEM: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("secret message body")
|
||||
enveloped, err := Encrypt(raw, []*x509.Certificate{cert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(enveloped, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("Decrypt() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 subject–preview with trailing icons */
|
||||
.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px}
|
||||
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
|
||||
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
|
||||
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
|
||||
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.msg-acct{font-size:10px;color:var(--muted)}
|
||||
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
|
||||
.msg-line2{display:flex;align-items:center;gap:6px}
|
||||
.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4}
|
||||
.msg-subject{color:var(--text2)}
|
||||
.msg-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 |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>`;
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
})());
|
||||
});
|
||||
@@ -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
@@ -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 & 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 & 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">‹</button>
|
||||
<button class="icon-btn" onclick="calNav(-1)" title="Previous" aria-label="Previous period">‹</button>
|
||||
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
|
||||
<button class="icon-btn" onclick="calNav(1)" title="Next">›</button>
|
||||
<button class="icon-btn" onclick="calNav(1)" title="Next" aria-label="Next period">›</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">–</button>
|
||||
<button class="compose-close" onclick="closeCompose()" title="Close">×</button>
|
||||
<button class="compose-close" onclick="minimizeCompose()" title="Minimise" aria-label="Minimise compose window">–</button>
|
||||
<button class="compose-close" onclick="closeCompose()" title="Close" aria-label="Close compose window">×</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')">•—</button>
|
||||
<button class="fmt-btn" title="Numbers" onclick="execFmt('insertOrderedList')">1—</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Link" onclick="insertLink()">🔗</button>
|
||||
<button class="fmt-btn" title="Clear format" onclick="execFmt('removeFormat')">T⃗</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')">•—</button>
|
||||
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1—</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink()">🔗</button>
|
||||
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T⃗</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Attach files" aria-label="Attach files" onclick="triggerAttach()">📎</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()">📎 Attach</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">✎ Draft</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">🕓 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 ·
|
||||
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
<div class="modal-field"><label id="imap-host-label">IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field" id="imap-port-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-row" id="smtp-fields">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
|
||||
</div>
|
||||
<div class="modal-divider"><span>optional — sync calendar & contacts</span></div>
|
||||
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="imap-caldav-url" placeholder="https://mail.example.com/dav/calendars/user@example.com/default"></div>
|
||||
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="imap-carddav-url" placeholder="https://mail.example.com/dav/addressbooks/user@example.com/default"></div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
|
||||
@@ -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 & 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')">🔗</button>
|
||||
<button type="button" class="fmt-btn" title="Image" aria-label="Insert image" onclick="document.getElementById('sig-image-input').click()">📷</button>
|
||||
<button type="button" class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execSigFmt('removeFormat')">T⃗</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,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
@@ -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 & leave</button>
|
||||
<button class="rcb-btn" id="cp-leave-discard">Discard & 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')">•—</button>
|
||||
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1—</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T⃗</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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}}
|
||||
|
||||
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
async function load() {
|
||||
const msg = await api('GET', '/messages/' + msgId);
|
||||
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
|
||||
// ── 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,'"');
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user