diff --git a/.gitignore b/.gitignore index 15b333f..35f2a56 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ data/gowebmail.conf data/*.txt gowebmail-devplan.md testrun/ -webmail.code-workspace \ No newline at end of file +webmail.code-workspace + +graphify-out +GEMINI.md +tests/ \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index f5a70a2..81b077a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -204,6 +204,7 @@ func main() { api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET") api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST") api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST") + api.HandleFunc("/accounts/trust-cert", h.API.TrustCertificate).Methods("POST") api.HandleFunc("/accounts/detect", h.API.DetectMailSettings).Methods("POST") api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET") api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT") @@ -235,6 +236,7 @@ func main() { api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST") api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST") api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST") + api.HandleFunc("/draft/discard", h.API.DiscardDraft).Methods("POST") // Folders api.HandleFunc("/folders", h.API.ListFolders).Methods("GET") @@ -247,6 +249,7 @@ func main() { api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE") api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST") + api.HandleFunc("/accounts/{account_id:[0-9]+}/folders", h.API.CreateFolder).Methods("POST") api.HandleFunc("/poll", h.API.PollUnread).Methods("GET") api.HandleFunc("/new-messages", h.API.NewMessagesSince).Methods("GET") @@ -282,6 +285,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)) diff --git a/data/gowebmail.conf.example b/data/gowebmail.conf.example deleted file mode 100644 index 1480aa8..0000000 --- a/data/gowebmail.conf.example +++ /dev/null @@ -1,90 +0,0 @@ -# GoMail Configuration -# ===================== -# Auto-generated and updated on each startup. -# Edit freely — your values are always preserved. -# Environment variables (or GOMAIL_) 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 : /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 : /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 = - diff --git a/go.mod b/go.mod index 47fd22e..4ba428f 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,26 @@ module github.com/ghostersk/gowebmail -go 1.26 +go 1.26.6 require ( + github.com/ProtonMail/go-crypto v1.4.1 + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 + github.com/emersion/go-webdav v0.7.0 github.com/gorilla/mux v1.8.1 - github.com/mattn/go-sqlite3 v1.14.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/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/teambition/rrule-go v1.8.2 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 545e2cd..bad5ba1 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,39 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/mattn/go-sqlite3 v1.14.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= +go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg= +go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/caldav/client.go b/internal/caldav/client.go new file mode 100644 index 0000000..80d45d1 --- /dev/null +++ b/internal/caldav/client.go @@ -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), + } +} diff --git a/internal/db/db.go b/internal/db/db.go index dd360ba..07ff95f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/base64" "fmt" + "log" "strings" "time" @@ -183,6 +184,16 @@ func (d *DB) Migrate() error { `ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`, // UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices. `ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`, + // Optional CalDAV/CardDAV sync, works alongside any mail provider (encrypted like imap_host). + `ALTER TABLE email_accounts ADD COLUMN caldav_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE email_accounts ADD COLUMN carddav_url TEXT NOT NULL DEFAULT ''`, + // account_id/uid let CardDAV-synced contacts be upserted and pruned like calendar_events already are. + `ALTER TABLE contacts ADD COLUMN account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL`, + `ALTER TABLE contacts ADD COLUMN uid TEXT NOT NULL DEFAULT ''`, + // Scoped plaintext search indexes — let search filter to subject-only or body-only + // instead of always matching the combined search_text blob. + `ALTER TABLE messages ADD COLUMN search_subject TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE messages ADD COLUMN search_body TEXT NOT NULL DEFAULT ''`, } for _, stmt := range alterStmts { d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally @@ -253,6 +264,8 @@ func (d *DB) Migrate() error { if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL, + uid TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', @@ -267,6 +280,9 @@ func (d *DB) Migrate() error { if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil { return fmt.Errorf("index contacts_user: %w", err) } + if _, err := d.sql.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_user_uid ON contacts(user_id, uid)`); err != nil { + return fmt.Errorf("index contacts_user_uid: %w", err) + } if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -306,10 +322,199 @@ func (d *DB) Migrate() error { return fmt.Errorf("create caldav_tokens: %w", err) } + // Mail rules (filters) — evaluated against newly-synced messages per account. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + conditions TEXT NOT NULL, -- JSON [{field,op,value}] + match_type TEXT NOT NULL DEFAULT 'all', -- all|any + action TEXT NOT NULL, -- move_to_folder|delete|mark_read|mark_as_spam|forward|auto_reply + action_value TEXT NOT NULL DEFAULT '', + action_options TEXT NOT NULL DEFAULT '{}', + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create rules: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_rules_account ON rules(account_id, priority)`); err != nil { + return fmt.Errorf("index rules_account: %w", err) + } + // Loop-prevention log for the auto_reply rule action. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS auto_reply_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + rule_id INTEGER NOT NULL, + recipient_email TEXT NOT NULL, + sent_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create auto_reply_log: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_auto_reply_log ON auto_reply_log(account_id, rule_id, recipient_email, sent_at)`); err != nil { + return fmt.Errorf("index auto_reply_log: %w", err) + } + + // Signatures — belong to the user (span accounts); default assignment is per-account. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + content_html TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create signatures: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_signatures_user ON signatures(user_id)`); err != nil { + return fmt.Errorf("index signatures_user: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS signature_defaults ( + account_id INTEGER PRIMARY KEY REFERENCES email_accounts(id) ON DELETE CASCADE, + default_new_id INTEGER REFERENCES signatures(id) ON DELETE SET NULL, + default_reply_id INTEGER REFERENCES signatures(id) ON DELETE SET NULL + )`); err != nil { + return fmt.Errorf("create signature_defaults: %w", err) + } + + // S/MIME identities (per-account, sign) and contact certs (per-user, encrypt-to). + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS smime_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + cert_pem TEXT NOT NULL, + key_pem TEXT NOT NULL, -- encrypted at rest via internal/crypto.Encryptor + not_after DATETIME NOT NULL, + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create smime_identities: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS smime_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + cert_pem TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + UNIQUE(user_id, email) + )`); err != nil { + return fmt.Errorf("create smime_contacts: %w", err) + } + + // PGP identities (per-account, encrypt/decrypt) and contact public keys (per-user, encrypt-to). + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pgp_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + label TEXT NOT NULL, + email TEXT NOT NULL, + fingerprint TEXT NOT NULL, + public_key_armor TEXT NOT NULL, + private_key_armor TEXT NOT NULL, -- native OpenPGP S2K passphrase protection + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create pgp_identities: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pgp_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL, + public_key_armor TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + UNIQUE(user_id, email) + )`); err != nil { + return fmt.Errorf("create pgp_contacts: %w", err) + } + + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS trusted_certs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + cert_fingerprint TEXT NOT NULL, + cert_pem TEXT NOT NULL, + hostname TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + UNIQUE(account_id, cert_fingerprint) + )`); err != nil { + return fmt.Errorf("create trusted_certs: %w", err) + } + + // One-row-per-migration marker table so a one-time data backfill (as opposed to a + // schema ALTER, which is naturally idempotent) runs exactly once, ever — never + // re-applying and silently overwriting a choice the user made after that first run. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS data_migrations ( + name TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create data_migrations: %w", err) + } + + d.backfillSearchIndex() + d.runOnce("sync_all_folders_by_default", d.backfillSyncAllFoldersDefault) + // Bootstrap admin account if no users exist return d.bootstrapAdmin() } +// runOnce executes fn the first time this DB ever sees the given migration name, then +// never again — used for one-time data backfills where re-running on every startup would +// stomp a choice the user made afterward (unlike a schema ALTER, which is self-limiting). +func (d *DB) runOnce(name string, fn func()) { + var exists int + d.sql.QueryRow(`SELECT 1 FROM data_migrations WHERE name=?`, name).Scan(&exists) + if exists == 1 { + return + } + fn() + d.sql.Exec(`INSERT OR IGNORE INTO data_migrations(name) VALUES (?)`, name) +} + +// backfillSyncAllFoldersDefault enables sync for every folder that predates the "sync all +// folders by default" change (previously only inbox/sent/drafts/trash/spam synced by +// default; custom folders sat disabled until manually enabled). Runs once, via runOnce. +func (d *DB) backfillSyncAllFoldersDefault() { + r, err := d.sql.Exec(`UPDATE folders SET sync_enabled=1 WHERE sync_enabled=0`) + if err != nil { + return + } + if n, _ := r.RowsAffected(); n > 0 { + log.Printf("[migrate] enabled sync for %d folder(s) that predated the sync-all-by-default change", n) + } + d.sql.Exec(`UPDATE email_accounts SET sync_all_folders=1`) +} + +// backfillSearchIndex populates search_subject/search_body for messages synced +// before those columns existed (they default to '' from the ALTER TABLE above). +// One-time, best-effort: skipped entirely once no rows need it. +func (d *DB) backfillSearchIndex() { + var pending int + d.sql.QueryRow(`SELECT COUNT(*) FROM messages WHERE search_subject='' AND search_text!=''`).Scan(&pending) + if pending == 0 { + return + } + log.Printf("[migrate] backfilling search index for %d message(s)...", pending) + rows, err := d.sql.Query(`SELECT id, subject, body_text FROM messages WHERE search_subject='' AND search_text!=''`) + if err != nil { + return + } + type row struct{ id int64; subject, body string } + var pendingRows []row + for rows.Next() { + var r row + if rows.Scan(&r.id, &r.subject, &r.body) == nil { + pendingRows = append(pendingRows, r) + } + } + rows.Close() + for _, r := range pendingRows { + subject, _ := d.enc.Decrypt(r.subject) + body, _ := d.enc.Decrypt(r.body) + if len(body) > 2000 { + body = body[:2000] + } + d.sql.Exec(`UPDATE messages SET search_subject=?, search_body=? WHERE id=?`, + strings.ToLower(subject), strings.ToLower(body), r.id) + } + log.Printf("[migrate] search index backfill complete") +} + // bootstrapAdmin creates the default admin/admin account on first run. func (d *DB) bootstrapAdmin() error { var count int @@ -641,16 +846,18 @@ func (d *DB) CreateAccount(a *models.EmailAccount) error { refreshEnc, _ := d.enc.Encrypt(a.RefreshToken) imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost) smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost) + caldavEnc, _ := d.enc.Encrypt(a.CalDAVURL) + carddavEnc, _ := d.enc.Encrypt(a.CardDAVURL) res, err := d.sql.Exec(` INSERT INTO email_accounts (user_id, provider, email_address, display_name, access_token, refresh_token, - token_expiry, imap_host, imap_port, smtp_host, smtp_port, color) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, caldav_url, carddav_url) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, a.UserID, a.Provider, a.EmailAddress, a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort, - a.Color, + a.Color, caldavEnc, carddavEnc, ) if err != nil { return err @@ -677,14 +884,15 @@ func (d *DB) UpdateAccountLastSync(accountID int64) error { func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) { a := &models.EmailAccount{} - var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string var lastSync sql.NullTime err := d.sql.QueryRow(` SELECT id, user_id, provider, email_address, display_name, access_token, refresh_token, token_expiry, imap_host, imap_port, smtp_host, smtp_port, last_error, color, is_active, last_sync, created_at, - COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0) + COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0), + COALESCE(caldav_url,''), COALESCE(carddav_url,'') FROM email_accounts WHERE id=?`, accountID, ).Scan( &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, @@ -692,6 +900,7 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) { &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, &a.SyncDays, &a.SyncMode, &a.SortOrder, + &caldavEnc, &carddavEnc, ) if err == sql.ErrNoRows { return nil, nil @@ -703,6 +912,8 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) { a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc) + a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc) if lastSync.Valid { a.LastSync = lastSync.Time } @@ -748,6 +959,8 @@ func (d *DB) UpdateAccount(a *models.EmailAccount) error { accessEnc, _ := d.enc.Encrypt(a.AccessToken) imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost) smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost) + caldavEnc, _ := d.enc.Encrypt(a.CalDAVURL) + carddavEnc, _ := d.enc.Encrypt(a.CardDAVURL) syncMode := a.SyncMode if syncMode == "" { syncMode = "days" @@ -760,10 +973,12 @@ func (d *DB) UpdateAccount(a *models.EmailAccount) error { UPDATE email_accounts SET display_name=?, access_token=?, imap_host=?, imap_port=?, smtp_host=?, smtp_port=?, + caldav_url=?, carddav_url=?, color=?, sync_days=?, sync_mode=? WHERE id=? AND user_id=?`, a.DisplayName, accessEnc, imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort, + caldavEnc, carddavEnc, a.Color, syncDays, syncMode, a.ID, a.UserID, ) return err @@ -784,7 +999,7 @@ func (d *DB) ListAllActiveAccounts() ([]*models.EmailAccount, error) { a.access_token, a.refresh_token, a.token_expiry, a.imap_host, a.imap_port, a.smtp_host, a.smtp_port, a.last_error, a.color, a.is_active, a.last_sync, a.created_at, - u.sync_interval + u.sync_interval, COALESCE(a.caldav_url,''), COALESCE(a.carddav_url,'') FROM email_accounts a JOIN users u ON u.id = a.user_id WHERE a.is_active=1 AND u.is_active=1`) @@ -796,14 +1011,14 @@ func (d *DB) ListAllActiveAccounts() ([]*models.EmailAccount, error) { var accounts []*models.EmailAccount for rows.Next() { a := &models.EmailAccount{} - var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string var lastSync sql.NullTime if err := rows.Scan( &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, &accessEnc, &refreshEnc, &a.TokenExpiry, &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, - &a.SyncInterval, + &a.SyncInterval, &caldavEnc, &carddavEnc, ); err != nil { return nil, err } @@ -811,6 +1026,8 @@ func (d *DB) ListAllActiveAccounts() ([]*models.EmailAccount, error) { a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc) + a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc) if lastSync.Valid { a.LastSync = lastSync.Time } @@ -825,7 +1042,7 @@ func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) { access_token, refresh_token, token_expiry, imap_host, imap_port, smtp_host, smtp_port, last_error, color, is_active, last_sync, created_at, - COALESCE(sort_order,0) + COALESCE(sort_order,0), COALESCE(caldav_url,''), COALESCE(carddav_url,'') FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY COALESCE(sort_order,0), created_at`, userID) if err != nil { @@ -839,14 +1056,14 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) { var accounts []*models.EmailAccount for rows.Next() { a := &models.EmailAccount{} - var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc, caldavEnc, carddavEnc string var lastSync sql.NullTime if err := rows.Scan( &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, &accessEnc, &refreshEnc, &a.TokenExpiry, &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, - &a.SortOrder, + &a.SortOrder, &caldavEnc, &carddavEnc, ); err != nil { return nil, err } @@ -854,6 +1071,8 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) { a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + a.CalDAVURL, _ = d.enc.Decrypt(caldavEnc) + a.CardDAVURL, _ = d.enc.Decrypt(carddavEnc) if lastSync.Valid { a.LastSync = lastSync.Time } @@ -985,21 +1204,18 @@ func (d *DB) UpdateFolderCounts(folderID int64) { // ---- Folders ---- func (d *DB) UpsertFolder(f *models.Folder) error { - // On insert: set sync_enabled based on folder type (primary types sync by default) - defaultSync := 0 - switch f.FolderType { - case "inbox", "sent", "drafts", "trash", "spam": - defaultSync = 1 - } + // All folders sync by default (the user can disable sync per folder afterward via + // SetFolderVisibility). ON CONFLICT deliberately never touches sync_enabled, so this + // default only applies the first time a folder is discovered. _, err := d.sql.Exec(` INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count, sync_enabled) - VALUES (?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,1) ON CONFLICT(account_id, full_path) DO UPDATE SET name=excluded.name, folder_type=excluded.folder_type, unread_count=excluded.unread_count, total_count=excluded.total_count`, - f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount, defaultSync, + f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount, ) return err } @@ -1020,6 +1236,42 @@ func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder, return f, err } +// GetFolderByName returns a folder matched by its display name (case-insensitive), used to +// resolve a rule's move_to_folder ActionValue (a human-typed folder name) to a real folder. +func (d *DB) GetFolderByName(accountID int64, name string) (*models.Folder, error) { + f := &models.Folder{} + var isHidden, syncEnabled int + err := d.sql.QueryRow( + `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count, + COALESCE(is_hidden,0), COALESCE(sync_enabled,1) + FROM folders WHERE account_id=? AND name=? COLLATE NOCASE LIMIT 1`, accountID, name, + ).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled) + f.IsHidden = isHidden == 1 + f.SyncEnabled = syncEnabled == 1 + if err == sql.ErrNoRows { + return nil, nil + } + return f, err +} + +// GetFolderByType returns the first folder of the given folder_type for an account +// (used to resolve e.g. the Junk folder for a mark_as_spam rule action). +func (d *DB) GetFolderByType(accountID int64, folderType string) (*models.Folder, error) { + f := &models.Folder{} + var isHidden, syncEnabled int + err := d.sql.QueryRow( + `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count, + COALESCE(is_hidden,0), COALESCE(sync_enabled,1) + FROM folders WHERE account_id=? AND folder_type=? LIMIT 1`, accountID, folderType, + ).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled) + f.IsHidden = isHidden == 1 + f.SyncEnabled = syncEnabled == 1 + if err == sql.ErrNoRows { + return nil, nil + } + return f, err +} + func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) { rows, err := d.sql.Query( `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count, @@ -1057,28 +1309,39 @@ func (d *DB) UpsertMessage(m *models.Message) error { bodyTextEnc, _ := d.enc.Encrypt(m.BodyText) bodyHTMLEnc, _ := d.enc.Encrypt(m.BodyHTML) - // Build plaintext search index: subject + from name + from email + first 200 chars of body + // Build plaintext search indexes: combined (subject+from+preview, for the default/all + // scope), plus subject-only and body-only so search can be scoped to just one of them. preview := m.BodyText if len(preview) > 200 { preview = preview[:200] } searchText := strings.ToLower(m.Subject + " " + m.FromName + " " + m.FromEmail + " " + preview) + searchSubject := strings.ToLower(m.Subject) + bodyForSearch := m.BodyText + if len(bodyForSearch) > 2000 { + bodyForSearch = bodyForSearch[:2000] + } + searchBody := strings.ToLower(bodyForSearch) res, err := d.sql.Exec(` INSERT INTO messages (account_id, folder_id, remote_uid, thread_id, message_id, subject, from_name, from_email, to_list, cc_list, bcc_list, reply_to, - body_text, body_html, date, is_read, is_starred, is_draft, has_attachment, search_text) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + body_text, body_html, date, is_read, is_starred, is_draft, has_attachment, + search_text, search_subject, search_body) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(account_id, folder_id, remote_uid) DO UPDATE SET is_read=excluded.is_read, is_starred=excluded.is_starred, has_attachment=excluded.has_attachment, - search_text=excluded.search_text`, + search_text=excluded.search_text, + search_subject=excluded.search_subject, + search_body=excluded.search_body`, m.AccountID, m.FolderID, m.RemoteUID, m.ThreadID, m.MessageID, subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc, bodyTextEnc, bodyHTMLEnc, m.Date, - m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment, searchText, + m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment, + searchText, searchSubject, searchBody, ) if err != nil { return err @@ -1216,28 +1479,97 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page }, nil } -func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models.PagedMessages, error) { +// SearchFilters narrows a SearchMessages query beyond the plain text match. +// All fields are optional (zero value = "no filter"). +type SearchFilters struct { + Scope string // "" / "all" | "subject" | "body" | "subject_body" + HasAttachment *bool + DateFrom string // "YYYY-MM-DD", inclusive + DateTo string // "YYYY-MM-DD", inclusive + MinSizeKB *int + MaxSizeKB *int + AccountID *int64 // narrow to one connected mailbox instead of all of them + FolderID *int64 // narrow to one folder within a mailbox +} + +// approxSizeExpr estimates a message's size from ciphertext length (a stand-in for +// plaintext length — AES output is close enough in size for filtering purposes) +// plus its attachments' real sizes. There's no stored message size, so this avoids +// a schema/sync change just to support a size filter. +const approxSizeExpr = `(LENGTH(m.body_text)+LENGTH(m.body_html)+COALESCE((SELECT SUM(a.size) FROM attachments a WHERE a.message_id=m.id),0))` + +func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page, pageSize int) (*models.PagedMessages, error) { offset := (page - 1) * pageSize like := "%" + strings.ToLower(q) + "%" - args := []interface{}{userID, like, pageSize, offset} + + where := []string{"a.user_id=?"} + args := []interface{}{userID} + + switch filters.Scope { + case "subject": + where = append(where, "m.search_subject LIKE ?") + args = append(args, like) + case "body": + where = append(where, "m.search_body LIKE ?") + args = append(args, like) + case "subject_body": + where = append(where, "(m.search_subject LIKE ? OR m.search_body LIKE ?)") + args = append(args, like, like) + default: + where = append(where, "m.search_text LIKE ?") + args = append(args, like) + } + if filters.HasAttachment != nil { + v := 0 + if *filters.HasAttachment { + v = 1 + } + where = append(where, "m.has_attachment=?") + args = append(args, v) + } + if filters.DateFrom != "" { + where = append(where, "m.date >= ?") + args = append(args, filters.DateFrom) + } + if filters.DateTo != "" { + where = append(where, "m.date <= ?") + args = append(args, filters.DateTo+" 23:59:59") + } + if filters.MinSizeKB != nil { + where = append(where, approxSizeExpr+" >= ?") + args = append(args, *filters.MinSizeKB*1024) + } + if filters.MaxSizeKB != nil { + where = append(where, approxSizeExpr+" <= ?") + args = append(args, *filters.MaxSizeKB*1024) + } + if filters.AccountID != nil { + where = append(where, "m.account_id=?") + args = append(args, *filters.AccountID) + } + if filters.FolderID != nil { + where = append(where, "m.folder_id=?") + args = append(args, *filters.FolderID) + } + whereClause := strings.Join(where, " AND ") var total int d.sql.QueryRow(` SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id - WHERE a.user_id=? AND m.search_text LIKE ?`, - userID, like, + WHERE `+whereClause, args..., ).Scan(&total) + qArgs := append(append([]interface{}{}, args...), pageSize, offset) rows, err := d.sql.Query(` SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name, m.subject, m.from_name, m.from_email, m.body_text, - m.date, m.is_read, m.is_starred, m.has_attachment + m.date, m.is_read, m.is_starred, m.has_attachment, `+approxSizeExpr+` FROM messages m JOIN email_accounts a ON a.id=m.account_id JOIN folders f ON f.id=m.folder_id - WHERE a.user_id=? AND m.search_text LIKE ? - ORDER BY m.date DESC LIMIT ? OFFSET ?`, args..., + WHERE `+whereClause+` + ORDER BY m.date DESC LIMIT ? OFFSET ?`, qArgs..., ) if err != nil { return nil, err @@ -1251,7 +1583,7 @@ func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models if err := rows.Scan( &s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName, &subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc, - &s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, + &s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, &s.Size, ); err != nil { return nil, err } @@ -2248,7 +2580,7 @@ func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) { func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) { rows, err := d.sql.Query(` - SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at + SELECT id, user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color, created_at, updated_at FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID) if err != nil { return nil, err @@ -2257,8 +2589,10 @@ func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) { var out []*models.Contact for rows.Next() { var c models.Contact + var accountID *int64 var dn, em, ph, co, no, av []byte - rows.Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) + rows.Scan(&c.ID, &c.UserID, &accountID, &c.UID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) + c.AccountID = accountID c.DisplayName, _ = d.enc.Decrypt(string(dn)) c.Email, _ = d.enc.Decrypt(string(em)) c.Phone, _ = d.enc.Decrypt(string(ph)) @@ -2272,14 +2606,16 @@ func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) { func (d *DB) GetContact(id, userID int64) (*models.Contact, error) { var c models.Contact + var accountID *int64 var dn, em, ph, co, no, av []byte err := d.sql.QueryRow(` - SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at + SELECT id, user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color, created_at, updated_at FROM contacts WHERE id=? AND user_id=?`, id, userID). - Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) + Scan(&c.ID, &c.UserID, &accountID, &c.UID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, err } + c.AccountID = accountID c.DisplayName, _ = d.enc.Decrypt(string(dn)) c.Email, _ = d.enc.Decrypt(string(em)) c.Phone, _ = d.enc.Decrypt(string(ph)) @@ -2296,9 +2632,12 @@ func (d *DB) CreateContact(c *models.Contact) error { co, _ := d.enc.Encrypt(c.Company) no, _ := d.enc.Encrypt(c.Notes) av, _ := d.enc.Encrypt(c.AvatarColor) + if c.UID == "" { + c.UID = fmt.Sprintf("gwm-%d-%d", c.UserID, time.Now().UnixNano()) + } res, err := d.sql.Exec(` - INSERT INTO contacts (user_id, display_name, email, phone, company, notes, avatar_color) - VALUES (?,?,?,?,?,?,?)`, c.UserID, dn, em, ph, co, no, av) + INSERT INTO contacts (user_id, uid, display_name, email, phone, company, notes, avatar_color) + VALUES (?,?,?,?,?,?,?,?)`, c.UserID, c.UID, dn, em, ph, co, no, av) if err != nil { return err } @@ -2306,6 +2645,51 @@ func (d *DB) CreateContact(c *models.Contact) error { return nil } +// UpsertContact inserts or updates a contact synced from a CardDAV server, +// keyed on (user_id, uid) like UpsertCalendarEvent. +func (d *DB) UpsertContact(c *models.Contact) error { + dn, _ := d.enc.Encrypt(c.DisplayName) + em, _ := d.enc.Encrypt(c.Email) + ph, _ := d.enc.Encrypt(c.Phone) + co, _ := d.enc.Encrypt(c.Company) + no, _ := d.enc.Encrypt(c.Notes) + av, _ := d.enc.Encrypt(c.AvatarColor) + res, err := d.sql.Exec(` + INSERT INTO contacts (user_id, account_id, uid, display_name, email, phone, company, notes, avatar_color) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(user_id, uid) DO UPDATE SET + account_id=excluded.account_id, display_name=excluded.display_name, email=excluded.email, + phone=excluded.phone, company=excluded.company, notes=excluded.notes, + updated_at=datetime('now')`, + c.UserID, c.AccountID, c.UID, dn, em, ph, co, no, av) + if err != nil { + return err + } + if c.ID == 0 { + c.ID, _ = res.LastInsertId() + } + return nil +} + +// DeleteContactsNotIn removes previously-synced contacts for accountID whose +// uid is no longer present on the CardDAV server (i.e. deleted remotely). +func (d *DB) DeleteContactsNotIn(accountID int64, keepUIDs []string) error { + if len(keepUIDs) == 0 { + _, err := d.sql.Exec(`DELETE FROM contacts WHERE account_id=?`, accountID) + return err + } + placeholders := make([]string, len(keepUIDs)) + args := make([]interface{}, 0, len(keepUIDs)+1) + args = append(args, accountID) + for i, u := range keepUIDs { + placeholders[i] = "?" + args = append(args, u) + } + q := fmt.Sprintf(`DELETE FROM contacts WHERE account_id=? AND uid NOT IN (%s)`, strings.Join(placeholders, ",")) + _, err := d.sql.Exec(q, args...) + return err +} + func (d *DB) UpdateContact(c *models.Contact, userID int64) error { dn, _ := d.enc.Encrypt(c.DisplayName) em, _ := d.enc.Encrypt(c.Email) @@ -2460,6 +2844,25 @@ func (d *DB) DeleteCalendarEvent(id, userID int64) error { return err } +// DeleteCalendarEventsNotIn removes previously-synced events for accountID +// whose uid is no longer present on the CalDAV server (i.e. deleted remotely). +func (d *DB) DeleteCalendarEventsNotIn(accountID int64, keepUIDs []string) error { + if len(keepUIDs) == 0 { + _, err := d.sql.Exec(`DELETE FROM calendar_events WHERE account_id=?`, accountID) + return err + } + placeholders := make([]string, len(keepUIDs)) + args := make([]interface{}, 0, len(keepUIDs)+1) + args = append(args, accountID) + for i, u := range keepUIDs { + placeholders[i] = "?" + args = append(args, u) + } + q := fmt.Sprintf(`DELETE FROM calendar_events WHERE account_id=? AND uid NOT IN (%s)`, strings.Join(placeholders, ",")) + _, err := d.sql.Exec(q, args...) + return err +} + // ======== CalDAV Tokens ======== func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) { @@ -2507,3 +2910,28 @@ func (d *DB) GetUserByCalDAVToken(token string) (int64, error) { d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token) return userID, nil } + +// ---- Trusted Certificates (for self-signed certs) ---- + +func (d *DB) TrustCertificate(accountID int64, fingerprint, certPEM, hostname string) error { + _, err := d.sql.Exec(` + INSERT OR IGNORE INTO trusted_certs (account_id, cert_fingerprint, cert_pem, hostname) + VALUES (?, ?, ?, ?)`, + accountID, fingerprint, certPEM, hostname) + return err +} + +func (d *DB) IsCertTrusted(accountID int64, fingerprint string) (bool, error) { + var count int + err := d.sql.QueryRow(`SELECT COUNT(*) FROM trusted_certs WHERE account_id=? AND cert_fingerprint=?`, accountID, fingerprint).Scan(&count) + return count > 0, err +} + +func (d *DB) GetTrustedCert(accountID int64, fingerprint string) (string, error) { + var certPEM string + err := d.sql.QueryRow(`SELECT cert_pem FROM trusted_certs WHERE account_id=? AND cert_fingerprint=?`, accountID, fingerprint).Scan(&certPEM) + if err == sql.ErrNoRows { + return "", nil + } + return certPEM, err +} diff --git a/internal/db/pgp.go b/internal/db/pgp.go new file mode 100644 index 0000000..69b1a3f --- /dev/null +++ b/internal/db/pgp.go @@ -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 +} diff --git a/internal/db/rules.go b/internal/db/rules.go new file mode 100644 index 0000000..e0fa969 --- /dev/null +++ b/internal/db/rules.go @@ -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 +} diff --git a/internal/db/signatures.go b/internal/db/signatures.go new file mode 100644 index 0000000..4985c1d --- /dev/null +++ b/internal/db/signatures.go @@ -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 +} diff --git a/internal/db/smime.go b/internal/db/smime.go new file mode 100644 index 0000000..1368127 --- /dev/null +++ b/internal/db/smime.go @@ -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 +} diff --git a/internal/email/imap.go b/internal/email/imap.go index 32c98f5..e1c6434 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -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 { @@ -884,9 +1021,37 @@ func authSMTP(c *smtp.Client, account *gomailModels.EmailAccount, host string) e } } +// Signer optionally S/MIME-signs and/or PGP-encrypts the raw outgoing MIME message before +// it is sent. Implemented in internal/handlers using loaded S/MIME/PGP identities and +// contacts — kept as an interface here so this package never needs to import internal/db. +// A nil Signer (the common case: no certs configured) is a no-op. +type Signer interface { + SignAndEncrypt(account *gomailModels.EmailAccount, recipients []string, raw []byte) ([]byte, error) +} + // SendMessageFull sends an email via SMTP using the account's configured server. -// It also appends the sent message to the IMAP Sent folder. -func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) error { +// It also appends the sent message to the IMAP Sent folder. signer may be nil. +// BuildRawMessage assembles the RFC822 message body for req (optionally signed/ +// encrypted via signer), shared by the SMTP (SendMessageFull) and JMAP +// (SendMessageJMAP) send paths. +func BuildRawMessage(account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) ([]byte, error) { + var buf bytes.Buffer + buildMIMEMessage(&buf, account, req) + rawMsg := buf.Bytes() + + if signer != nil { + allRecipients := append(append([]string{}, req.To...), req.CC...) + allRecipients = append(allRecipients, req.BCC...) + signed, err := signer.SignAndEncrypt(account, allRecipients, rawMsg) + if err != nil { + return nil, fmt.Errorf("sign/encrypt: %w", err) + } + rawMsg = signed + } + return rawMsg, nil +} + +func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error { host, port := smtpHostFor(account.Provider) if account.SMTPHost != "" { host = account.SMTPHost @@ -896,26 +1061,36 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re return fmt.Errorf("SMTP host not configured") } - var buf bytes.Buffer - buildMIMEMessage(&buf, account, req) - rawMsg := buf.Bytes() + rawMsg, err := BuildRawMessage(account, req, signer) + if err != nil { + return err + } addr := fmt.Sprintf("%s:%d", host, port) 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 +1312,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 +1560,19 @@ func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) { }, nil } +// GetFolderCounts returns the true total/unread message counts for a mailbox straight from +// the server (IMAP STATUS), independent of how much history has been synced locally — a +// SELECT's response doesn't carry a real unseen count (only the sequence number of the +// first unseen message), so this needs its own STATUS query. STATUS doesn't disturb the +// currently selected mailbox, so it's safe to call alongside GetFolderStatus/syncFolder. +func (c *Client) GetFolderCounts(mailboxName string) (total, unread uint32, err error) { + status, err := c.imap.Status(mailboxName, []imap.StatusItem{imap.StatusMessages, imap.StatusUnseen}) + if err != nil { + return 0, 0, err + } + return status.Messages, status.Unseen, nil +} + // ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection. func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) { mbox, err := c.imap.Select(mailboxName, true) diff --git a/internal/email/jmap_send.go b/internal/email/jmap_send.go new file mode 100644 index 0000000..580a9fd --- /dev/null +++ b/internal/email/jmap_send.go @@ -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) +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 0d9be7c..25277ed 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -17,17 +17,20 @@ import ( "github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/email" graphpkg "github.com/ghostersk/gowebmail/internal/graph" + "github.com/ghostersk/gowebmail/internal/jmap" "github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/models" + "github.com/ghostersk/gowebmail/internal/pgp" "github.com/ghostersk/gowebmail/internal/syncer" "github.com/gorilla/mux" ) // APIHandler handles all /api/* JSON endpoints. type APIHandler struct { - db *db.DB - cfg *config.Config - syncer *syncer.Scheduler + db *db.DB + cfg *config.Config + syncer *syncer.Scheduler + pgpCache *pgp.Cache } func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) { @@ -63,6 +66,8 @@ type safeAccount struct { IMAPPort int `json:"imap_port,omitempty"` SMTPHost string `json:"smtp_host,omitempty"` SMTPPort int `json:"smtp_port,omitempty"` + CalDAVURL string `json:"caldav_url,omitempty"` + CardDAVURL string `json:"carddav_url,omitempty"` SyncDays int `json:"sync_days"` SyncMode string `json:"sync_mode"` SortOrder int `json:"sort_order"` @@ -70,6 +75,8 @@ type safeAccount struct { Color string `json:"color"` LastSync string `json:"last_sync"` TokenExpired bool `json:"token_expired,omitempty"` + DefaultSignatureNewID int64 `json:"default_signature_new_id,omitempty"` + DefaultSignatureReplyID int64 `json:"default_signature_reply_id,omitempty"` } func toSafeAccount(a *models.EmailAccount) safeAccount { @@ -85,6 +92,7 @@ func toSafeAccount(a *models.EmailAccount) safeAccount { ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress, DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort, SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort, + CalDAVURL: a.CalDAVURL, CardDAVURL: a.CardDAVURL, SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder, LastError: a.LastError, Color: a.Color, LastSync: lastSync, TokenExpired: tokenExpired, @@ -100,7 +108,12 @@ func (h *APIHandler) ListAccounts(w http.ResponseWriter, r *http.Request) { } result := make([]safeAccount, 0, len(accounts)) for _, a := range accounts { - result = append(result, toSafeAccount(a)) + sa := toSafeAccount(a) + if sd, err := h.db.GetSignatureDefaults(a.ID); err == nil { + sa.DefaultSignatureNewID = sd.DefaultNewID + sa.DefaultSignatureReplyID = sd.DefaultReplyID + } + result = append(result, sa) } h.writeJSON(w, result) } @@ -110,10 +123,13 @@ func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) { Email string `json:"email"` DisplayName string `json:"display_name"` Password string `json:"password"` + Provider string `json:"provider"` // "jmap" or "" / "imap_smtp" (default) IMAPHost string `json:"imap_host"` IMAPPort int `json:"imap_port"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` + CalDAVURL string `json:"caldav_url"` + CardDAVURL string `json:"carddav_url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request body") @@ -123,15 +139,25 @@ func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusBadRequest, "email and password required") return } + isJMAP := req.Provider == string(models.ProviderJMAP) if req.IMAPHost == "" { - h.writeError(w, http.StatusBadRequest, "IMAP host required") + msg := "IMAP host required" + if isJMAP { + msg = "JMAP server URL required" + } + h.writeError(w, http.StatusBadRequest, msg) return } - if req.IMAPPort == 0 { - req.IMAPPort = 993 - } - if req.SMTPPort == 0 { - req.SMTPPort = 587 + provider := models.ProviderIMAPSMTP + if isJMAP { + provider = models.ProviderJMAP + } else { + if req.IMAPPort == 0 { + req.IMAPPort = 993 + } + if req.SMTPPort == 0 { + req.SMTPPort = 587 + } } userID := middleware.GetUserID(r) @@ -140,21 +166,29 @@ func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) { color := colors[len(accounts)%len(colors)] account := &models.EmailAccount{ - UserID: userID, Provider: models.ProviderIMAPSMTP, + UserID: userID, Provider: provider, EmailAddress: req.Email, DisplayName: req.DisplayName, AccessToken: req.Password, IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, + CalDAVURL: req.CalDAVURL, CardDAVURL: req.CardDAVURL, Color: color, IsActive: true, } + if isJMAP { + if _, err := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken).Session(r.Context()); err != nil { + h.writeError(w, http.StatusBadGateway, "JMAP connection failed: "+err.Error()) + return + } + } + if err := h.db.CreateAccount(account); err != nil { h.writeError(w, http.StatusInternalServerError, "failed to create account") return } uid := userID - h.db.WriteAudit(&uid, models.AuditAccountAdd, "imap:"+req.Email, middleware.ClientIP(r), r.UserAgent()) + h.db.WriteAudit(&uid, models.AuditAccountAdd, string(provider)+":"+req.Email, middleware.ClientIP(r), r.UserAgent()) // Trigger an immediate sync in background go h.syncer.SyncAccountNow(account.ID) @@ -190,6 +224,8 @@ func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { IMAPPort int `json:"imap_port"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` + CalDAVURL *string `json:"caldav_url"` + CardDAVURL *string `json:"carddav_url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request") @@ -214,32 +250,56 @@ func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { if req.SMTPPort > 0 { account.SMTPPort = req.SMTPPort } + if req.CalDAVURL != nil { + account.CalDAVURL = *req.CalDAVURL + } + if req.CardDAVURL != nil { + account.CardDAVURL = *req.CardDAVURL + } if err := h.db.UpdateAccount(account); err != nil { h.writeError(w, http.StatusInternalServerError, "update failed") return } + if account.CalDAVURL != "" || account.CardDAVURL != "" { + go h.syncer.SyncAccountNow(account.ID) + } h.writeJSON(w, map[string]bool{"ok": true}) } func (h *APIHandler) TestConnection(w http.ResponseWriter, r *http.Request) { var req struct { - Email string `json:"email"` - Password string `json:"password"` - IMAPHost string `json:"imap_host"` - IMAPPort int `json:"imap_port"` - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` + Email string `json:"email"` + Password string `json:"password"` + Provider string `json:"provider"` // "jmap" or "" / "imap_smtp" (default) + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + AccountID int64 `json:"account_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request") return } + + if req.Provider == string(models.ProviderJMAP) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + if _, err := jmap.New(req.IMAPHost, req.Email, req.Password).Session(ctx); err != nil { + h.writeJSON(w, map[string]interface{}{"ok": false, "error": map[string]string{"type": "connection_error", "message": err.Error()}}) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) + return + } + if req.IMAPPort == 0 { req.IMAPPort = 993 } testAccount := &models.EmailAccount{ + ID: req.AccountID, Provider: models.ProviderIMAPSMTP, EmailAddress: req.Email, AccessToken: req.Password, @@ -249,8 +309,28 @@ func (h *APIHandler) TestConnection(w http.ResponseWriter, r *http.Request) { SMTPPort: req.SMTPPort, } - if err := email.TestConnection(testAccount); err != nil { - h.writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) + connErr := email.TestConnectionDetailed(testAccount, h.db) + if connErr != nil { + h.writeJSON(w, map[string]interface{}{"ok": false, "error": connErr}) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// TrustCertificate adds a certificate to the trusted list for an account. +func (h *APIHandler) TrustCertificate(w http.ResponseWriter, r *http.Request) { + var req struct { + AccountID int64 `json:"account_id"` + CertHash string `json:"cert_hash"` + CertPEM string `json:"cert_pem"` + Hostname string `json:"hostname"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if err := h.db.TrustCertificate(req.AccountID, req.CertHash, req.CertPEM, req.Hostname); err != nil { + h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to trust certificate: %v", err)) return } h.writeJSON(w, map[string]bool{"ok": true}) @@ -415,6 +495,53 @@ func (h *APIHandler) CountFolderMessages(w http.ResponseWriter, r *http.Request) h.writeJSON(w, map[string]int{"count": count}) } +func (h *APIHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "account_id") + + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + h.writeError(w, http.StatusBadRequest, "folder name required") + return + } + + account, err := h.db.GetAccount(accountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "account not found") + return + } + + imapClient, err := email.Connect(context.Background(), account) + if err != nil { + h.writeError(w, http.StatusBadGateway, "could not connect to mailbox") + return + } + defer imapClient.Close() + if err := imapClient.CreateMailbox(req.Name); err != nil { + h.writeError(w, http.StatusBadGateway, "create failed: "+err.Error()) + return + } + + folder := &models.Folder{AccountID: accountID, Name: req.Name, FullPath: req.Name, FolderType: "custom"} + if err := h.db.UpsertFolder(folder); err != nil { + h.writeError(w, http.StatusInternalServerError, "saved on server but failed to save locally") + return + } + saved, err := h.db.GetFolderByPath(accountID, req.Name) + if err != nil || saved == nil { + h.writeError(w, http.StatusInternalServerError, "created but failed to load folder") + return + } + h.writeJSON(w, map[string]interface{}{"ok": true, "folder": saved}) +} + func (h *APIHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) folderID := pathInt64(r, "id") @@ -593,18 +720,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) { } h.db.MarkMessageRead(messageID, userID, true) - // For Graph accounts: fetch body lazily on open (not stored during list sync) + // For Graph/JMAP accounts: fetch body lazily on open (not stored during list sync) if msg.BodyHTML == "" && msg.BodyText == "" { - if graphMsgID, _, account, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && - account != nil && account.Provider == models.ProviderOutlookPersonal { - if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), graphMsgID); gErr == nil { - if gMsg.Body.ContentType == "html" { - msg.BodyHTML = gMsg.Body.Content - } else { - msg.BodyText = gMsg.Body.Content + if remoteID, _, account, rerr := h.db.GetMessageGraphInfo(messageID, userID); rerr == nil && account != nil { + switch account.Provider { + case models.ProviderOutlookPersonal: + if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), remoteID); gErr == nil { + if gMsg.Body.ContentType == "html" { + msg.BodyHTML = gMsg.Body.Content + } else { + msg.BodyText = gMsg.Body.Content + } + // Persist so next open is instant + h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML) + } + case models.ProviderJMAP: + if jMsg, jErr := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken).GetEmailBody(context.Background(), remoteID); jErr == nil { + msg.BodyHTML = jMsg.HTMLValue() + msg.BodyText = jMsg.TextValue() + h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML) } - // Persist so next open is instant - h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML) } } } @@ -640,10 +775,13 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&req) h.db.MarkMessageRead(messageID, userID, req.Read) - if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil && - account.Provider == models.ProviderOutlookPersonal { - go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read) - } else { + remoteID, _, account, rerr := h.db.GetMessageGraphInfo(messageID, userID) + switch { + case rerr == nil && account != nil && account.Provider == models.ProviderOutlookPersonal: + go graphpkg.New(account).MarkRead(context.Background(), remoteID, req.Read) + case rerr == nil && account != nil && account.Provider == models.ProviderJMAP: + go jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken).SetKeyword(context.Background(), remoteID, "$seen", req.Read) + default: uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID) if err2 == nil && uid != 0 && acc != nil { val := "0" @@ -666,10 +804,13 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusInternalServerError, "failed to toggle star") return } - if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil && - account.Provider == models.ProviderOutlookPersonal { - go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred) - } else { + remoteID, _, account, rerr := h.db.GetMessageGraphInfo(messageID, userID) + switch { + case rerr == nil && account != nil && account.Provider == models.ProviderOutlookPersonal: + go graphpkg.New(account).MarkFlagged(context.Background(), remoteID, starred) + case rerr == nil && account != nil && account.Provider == models.ProviderJMAP: + go jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken).SetKeyword(context.Background(), remoteID, "$flagged", starred) + default: uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID) if ierr == nil && uid != 0 && acc != nil { val := "0" @@ -705,10 +846,12 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) { return } - // Route to Graph or IMAP - if graphMsgID, _, graphAcc, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && graphAcc != nil && - graphAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil { - go graphpkg.New(graphAcc).MoveMessage(context.Background(), graphMsgID, destFolder.FullPath) + // Route to Graph, JMAP, or IMAP + remoteID, _, remoteAcc, rerr := h.db.GetMessageGraphInfo(messageID, userID) + if rerr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil { + go graphpkg.New(remoteAcc).MoveMessage(context.Background(), remoteID, destFolder.FullPath) + } else if rerr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderJMAP && destFolder != nil { + go jmap.New(remoteAcc.IMAPHost, remoteAcc.EmailAddress, remoteAcc.AccessToken).MoveEmail(context.Background(), remoteID, destFolder.FullPath) } else if imapErr == nil && uid != 0 && account != nil && destFolder != nil { h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ AccountID: account.ID, OpType: "move", @@ -724,7 +867,7 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { messageID := pathInt64(r, "id") // Get message info before deleting from DB - graphMsgID, _, graphAcc, graphErr := h.db.GetMessageGraphInfo(messageID, userID) + remoteID, _, remoteAcc, remoteErr := h.db.GetMessageGraphInfo(messageID, userID) uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID) if err := h.db.DeleteMessage(messageID, userID); err != nil { @@ -732,8 +875,10 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { return } - if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal { - go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID) + if remoteErr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderOutlookPersonal { + go graphpkg.New(remoteAcc).DeleteMessage(context.Background(), remoteID) + } else if remoteErr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderJMAP { + go jmap.New(remoteAcc.IMAPHost, remoteAcc.EmailAddress, remoteAcc.AccessToken).DeleteEmail(context.Background(), remoteID) } else if imapErr == nil && uid != 0 && account != nil { h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ AccountID: account.ID, OpType: "delete", @@ -853,8 +998,14 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str return } - if err := email.SendMessageFull(context.Background(), account, &req); err != nil { - log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err) + sendCtx, sendCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer sendCancel() + sendFn := email.SendMessageFull + if account.Provider == models.ProviderJMAP { + sendFn = email.SendMessageJMAP + } + if err := sendFn(sendCtx, account, &req, h.newSigner(userID)); err != nil { + log.Printf("send failed account=%d user=%d: %v", req.AccountID, userID, err) h.db.WriteAudit(&userID, models.AuditAppError, fmt.Sprintf("send failed account:%d – %v", req.AccountID, err), middleware.ClientIP(r), r.UserAgent()) @@ -905,14 +1056,40 @@ func (h *APIHandler) ListAccountFolders(w http.ResponseWriter, r *http.Request) func (h *APIHandler) Search(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) q := strings.TrimSpace(r.URL.Query().Get("q")) - if q == "" { - h.writeError(w, http.StatusBadRequest, "q parameter required") - return - } page := queryInt(r, "page", 1) pageSize := queryInt(r, "page_size", 50) - result, err := h.db.SearchMessages(userID, q, page, pageSize) + filters := db.SearchFilters{ + Scope: r.URL.Query().Get("scope"), + DateFrom: r.URL.Query().Get("date_from"), + DateTo: r.URL.Query().Get("date_to"), + } + if v := r.URL.Query().Get("has_attachment"); v != "" { + b := v == "1" || v == "true" + filters.HasAttachment = &b + } + if v, err := strconv.Atoi(r.URL.Query().Get("min_size_kb")); err == nil { + filters.MinSizeKB = &v + } + if v, err := strconv.Atoi(r.URL.Query().Get("max_size_kb")); err == nil { + filters.MaxSizeKB = &v + } + if v, err := strconv.ParseInt(r.URL.Query().Get("account_id"), 10, 64); err == nil { + filters.AccountID = &v + } + if v, err := strconv.ParseInt(r.URL.Query().Get("folder_id"), 10, 64); err == nil { + filters.FolderID = &v + } + // q may be blank when the caller is filtering only (attachment/date/size/mailbox) — + // an empty LIKE pattern matches everything, so that's a no-op text filter, not an error. + if q == "" && filters.Scope == "" && filters.HasAttachment == nil && filters.DateFrom == "" && + filters.DateTo == "" && filters.MinSizeKB == nil && filters.MaxSizeKB == nil && + filters.AccountID == nil && filters.FolderID == nil { + h.writeError(w, http.StatusBadRequest, "q or a filter is required") + return + } + + result, err := h.db.SearchMessages(userID, q, filters, page, pageSize) if err != nil { h.writeError(w, http.StatusInternalServerError, "search failed") return @@ -1414,25 +1591,55 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) { raw := []byte(buf.String()) - // Append to IMAP Drafts in background - go func() { - c, err := email.Connect(context.Background(), account) - if err != nil { - log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err) - return - } - defer c.Close() - draftsFolder, err := c.AppendToDrafts(raw) - if err != nil { - log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err) - return - } - if draftsFolder != "" { - // Trigger a sync of the drafts folder to pick up the saved draft - h.syncer.TriggerAccountSync(account.ID) - } - }() + c, err := email.Connect(context.Background(), account) + if err != nil { + h.writeError(w, http.StatusBadGateway, "could not connect to mailbox") + return + } + defer c.Close() + draftsFolder, newUID, err := c.AppendToDrafts(raw, req.DraftUID) + if err != nil { + h.writeError(w, http.StatusBadGateway, "save failed: "+err.Error()) + return + } + if draftsFolder != "" { + // Trigger a sync of the drafts folder to pick up the saved draft + h.syncer.TriggerAccountSync(account.ID) + } + h.writeJSON(w, map[string]interface{}{"ok": true, "draft_uid": newUID}) +} + +// DiscardDraft deletes a previously-autosaved draft (identified by its IMAP UID, returned +// from an earlier SaveDraft call) from the account's Drafts folder — used when the user +// closes a compose panel and chooses not to keep the draft that autosave already wrote to +// the server. +func (h *APIHandler) DiscardDraft(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + AccountID int64 `json:"account_id"` + DraftUID uint32 `json:"draft_uid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DraftUID == 0 { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + account, err := h.db.GetAccount(req.AccountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusBadRequest, "account not found") + return + } + c, err := email.Connect(context.Background(), account) + if err != nil { + h.writeError(w, http.StatusBadGateway, "could not connect to mailbox") + return + } + defer c.Close() + if err := c.DiscardDraftUID(req.DraftUID); err != nil { + h.writeError(w, http.StatusBadGateway, "delete failed: "+err.Error()) + return + } + h.syncer.TriggerAccountSync(account.ID) h.writeJSON(w, map[string]bool{"ok": true}) } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index cb2b0ac..93dd1f8 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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: "/", diff --git a/internal/handlers/certs.go b/internal/handlers/certs.go new file mode 100644 index 0000000..1366847 --- /dev/null +++ b/internal/handlers/certs.go @@ -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}) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index eb52921..dbf8be4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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}, } } diff --git a/internal/handlers/rules.go b/internal/handlers/rules.go new file mode 100644 index 0000000..6813bf2 --- /dev/null +++ b/internal/handlers/rules.go @@ -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}) +} diff --git a/internal/handlers/signatures.go b/internal/handlers/signatures.go new file mode 100644 index 0000000..4fe2318 --- /dev/null +++ b/internal/handlers/signatures.go @@ -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}) +} diff --git a/internal/jmap/client.go b/internal/jmap/client.go new file mode 100644 index 0000000..d28042c --- /dev/null +++ b/internal/jmap/client.go @@ -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 +} diff --git a/internal/models/models.go b/internal/models/models.go index f304a02..c2b47ae 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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" @@ -199,6 +207,7 @@ type MessageSummary struct { IsRead bool `json:"is_read"` IsStarred bool `json:"is_starred"` HasAttachment bool `json:"has_attachment"` + Size int64 `json:"size,omitempty"` // approximate; only populated by search results } // ---- Compose ---- @@ -217,6 +226,10 @@ type ComposeRequest struct { ForwardFromID int64 `json:"forward_from_id,omitempty"` // Attachments: populated from multipart/form-data or inline base64 Attachments []Attachment `json:"attachments,omitempty"` + // DraftUID is the IMAP UID of this compose session's previously-autosaved draft (0 if + // never saved). A resave deletes that copy before appending the new one, so repeated + // autosaves replace the draft in place instead of piling up duplicates. + DraftUID uint32 `json:"draft_uid,omitempty"` } // ---- Search ---- @@ -249,6 +262,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 +304,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"` +} diff --git a/internal/pgp/cache.go b/internal/pgp/cache.go new file mode 100644 index 0000000..00438d0 --- /dev/null +++ b/internal/pgp/cache.go @@ -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) +} diff --git a/internal/pgp/mime_test.go b/internal/pgp/mime_test.go new file mode 100644 index 0000000..f78f3b5 --- /dev/null +++ b/internal/pgp/mime_test.go @@ -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 \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) + } +} diff --git a/internal/pgp/pgp.go b/internal/pgp/pgp.go new file mode 100644 index 0000000..4a19314 --- /dev/null +++ b/internal/pgp/pgp.go @@ -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 +} diff --git a/internal/pgp/pgp_test.go b/internal/pgp/pgp_test.go new file mode 100644 index 0000000..722e6cb --- /dev/null +++ b/internal/pgp/pgp_test.go @@ -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") + } +} diff --git a/internal/rules/rules.go b/internal/rules/rules.go new file mode 100644 index 0000000..1ef3bdc --- /dev/null +++ b/internal/rules/rules.go @@ -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 + } +} diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go new file mode 100644 index 0000000..825e0f1 --- /dev/null +++ b/internal/rules/rules_test.go @@ -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) + } + }) + } +} diff --git a/internal/smime/mime_test.go b/internal/smime/mime_test.go new file mode 100644 index 0000000..16cbfae --- /dev/null +++ b/internal/smime/mime_test.go @@ -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 \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) + } +} diff --git a/internal/smime/smime.go b/internal/smime/smime.go new file mode 100644 index 0000000..67e667c --- /dev/null +++ b/internal/smime/smime.go @@ -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() +} diff --git a/internal/smime/smime_test.go b/internal/smime/smime_test.go new file mode 100644 index 0000000..0e76832 --- /dev/null +++ b/internal/smime/smime_test.go @@ -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) + } +} diff --git a/internal/syncer/rules.go b/internal/syncer/rules.go new file mode 100644 index 0000000..7461b21 --- /dev/null +++ b/internal/syncer/rules.go @@ -0,0 +1,246 @@ +package syncer + +import ( + "context" + "fmt" + "log" + + "github.com/ghostersk/gowebmail/internal/db" + "github.com/ghostersk/gowebmail/internal/email" + "github.com/ghostersk/gowebmail/internal/graph" + "github.com/ghostersk/gowebmail/internal/models" + "github.com/ghostersk/gowebmail/internal/rules" +) + +// matchRule evaluates a message against an account's active rules (as loaded from the DB) +// and returns the matching models.Rule (with full action data), or nil if none match. +func matchRule(msg *models.Message, accountEmail string, activeRules []models.Rule) *models.Rule { + if len(activeRules) == 0 { + return nil + } + engineRules := make([]rules.Rule, 0, len(activeRules)) + for _, r := range activeRules { + conds := make([]rules.Condition, 0, len(r.Conditions)) + for _, c := range r.Conditions { + conds = append(conds, rules.Condition{Field: c.Field, Op: c.Op, Value: c.Value}) + } + engineRules = append(engineRules, rules.Rule{ + ID: r.ID, Priority: r.Priority, Conditions: conds, MatchType: r.MatchType, + Action: r.Action, ActionValue: r.ActionValue, + }) + } + mf := rules.MessageFields{ + From: msg.FromEmail, To: msg.ToList, Subject: msg.Subject, Body: msg.BodyText, + HasAttachment: msg.HasAttachment, RecipientType: recipientType(msg, accountEmail), + } + matched := rules.Match(mf, engineRules) + if matched == nil { + return nil + } + for i := range activeRules { + if activeRules[i].ID == matched.ID { + return &activeRules[i] + } + } + return nil +} + +func recipientType(msg *models.Message, accountEmail string) string { + if msg.CCList != "" && containsAddress(msg.CCList, accountEmail) { + return "cc" + } + if msg.BCCList != "" && containsAddress(msg.BCCList, accountEmail) { + return "bcc" + } + return "to" +} + +func containsAddress(list, addr string) bool { + // list is comma-separated; a substring check is enough since we only use this + // to pick a synthetic recipient_type label, not for anything security-relevant. + for _, part := range splitAndTrim(list) { + if part == addr { + return true + } + } + return false +} + +func splitAndTrim(s string) []string { + var out []string + cur := "" + for _, r := range s { + if r == ',' { + out = append(out, trimLower(cur)) + cur = "" + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, trimLower(cur)) + } + return out +} + +func trimLower(s string) string { + // minimal trim, avoids pulling in strings just for this + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} + +func parseUID(s string) uint32 { + var uid uint32 + fmt.Sscanf(s, "%d", &uid) + return uid +} + +// ---- IMAP path ---- + +func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) { + uid := parseUID(msg.RemoteUID) + switch rule.Action { + case "move_to_folder": + dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue) + if err != nil || dest == nil { + log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress) + return + } + s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: dest.FullPath}) + s.TriggerAccountSync(account.ID) + case "mark_as_spam": + junk, err := s.db.GetFolderByType(account.ID, "spam") + if err != nil || junk == nil { + log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress) + return + } + s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath}) + s.TriggerAccountSync(account.ID) + case "delete": + s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "delete", RemoteUID: uid, FolderPath: dbFolder.FullPath}) + s.TriggerAccountSync(account.ID) + case "mark_read": + if err := c.SetFlagByUID(dbFolder.FullPath, uid, `\Seen`, true); err != nil { + log.Printf("[rules] mark_read: %v", err) + } + case "forward": + s.ruleForwardIMAP(account, msg, rule.ActionValue) + case "auto_reply": + s.ruleAutoReplyIMAP(account, msg, rule) + } +} + +func (s *Scheduler) ruleForwardIMAP(account *models.EmailAccount, msg *models.Message, to string) { + req := &models.ComposeRequest{ + AccountID: account.ID, + To: []string{to}, + Subject: "Fwd: " + msg.Subject, + BodyHTML: msg.BodyHTML, + BodyText: msg.BodyText, + } + if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil { + log.Printf("[rules] forward to %s: %v", to, err) + } +} + +func (s *Scheduler) ruleAutoReplyIMAP(account *models.EmailAccount, msg *models.Message, rule *models.Rule) { + recipient := msg.FromEmail + if recipient == "" { + return // never reply to a bounce/empty sender — avoids loops + } + if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent { + return + } + req := &models.ComposeRequest{ + AccountID: account.ID, + To: []string{recipient}, + Subject: rule.ActionValue, + BodyText: rule.ActionOptions.Body, + BodyHTML: rule.ActionOptions.Body, + } + if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil { + log.Printf("[rules] auto_reply to %s: %v", recipient, err) + return + } + s.db.LogAutoReply(account.ID, rule.ID, recipient) +} + +// ---- Graph (personal Outlook.com) path ---- +// msg.RemoteUID already holds the opaque Graph message ID (set at construction in graphDeltaSync). + +func (s *Scheduler) applyRuleGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) { + ctx := context.Background() + switch rule.Action { + case "move_to_folder": + dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue) + if err != nil || dest == nil { + log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress) + return + } + if err := gc.MoveMessage(ctx, msg.RemoteUID, dest.FullPath); err != nil { + log.Printf("[rules] graph move: %v", err) + } + case "mark_as_spam": + junk, err := s.db.GetFolderByType(account.ID, "spam") + if err != nil || junk == nil { + log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress) + return + } + if err := gc.MoveMessage(ctx, msg.RemoteUID, junk.FullPath); err != nil { + log.Printf("[rules] graph move: %v", err) + } + case "delete": + if err := gc.DeleteMessage(ctx, msg.RemoteUID); err != nil { + log.Printf("[rules] graph delete: %v", err) + } + case "mark_read": + if err := gc.MarkRead(ctx, msg.RemoteUID, true); err != nil { + log.Printf("[rules] graph mark_read: %v", err) + } + case "forward": + s.ruleForwardGraph(gc, account, msg, rule.ActionValue) + case "auto_reply": + s.ruleAutoReplyGraph(gc, account, msg, rule) + } +} + +func (s *Scheduler) ruleForwardGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, to string) { + req := &models.ComposeRequest{ + AccountID: account.ID, + To: []string{to}, + Subject: "Fwd: " + msg.Subject, + BodyHTML: msg.BodyHTML, + BodyText: msg.BodyText, + } + if err := gc.SendMail(context.Background(), req); err != nil { + log.Printf("[rules] graph forward to %s: %v", to, err) + } +} + +func (s *Scheduler) ruleAutoReplyGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) { + recipient := msg.FromEmail + if recipient == "" { + return + } + if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent { + return + } + req := &models.ComposeRequest{ + AccountID: account.ID, + To: []string{recipient}, + Subject: rule.ActionValue, + BodyText: rule.ActionOptions.Body, + BodyHTML: rule.ActionOptions.Body, + } + if err := gc.SendMail(context.Background(), req); err != nil { + log.Printf("[rules] graph auto_reply to %s: %v", recipient, err) + return + } + s.db.LogAutoReply(account.ID, rule.ID, recipient) +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 4edb851..ee3eee2 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -16,9 +16,11 @@ import ( "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 +207,15 @@ func (s *Scheduler) reconcileWorkers( func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) { log.Printf("[sync] worker started for %s", account.EmailAddress) + // CalDAV/CardDAV sync is optional and independent of the mail provider above, + // so it runs for every account regardless of which branch below is taken. + // davWorker no-ops on each tick if neither URL is configured. + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.davWorker(account, stop) + }() + // Fresh account data function (interval can change at runtime) getAccount := func() *models.EmailAccount { a, _ := s.db.GetAccount(account.ID) @@ -220,6 +231,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 +484,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db storedValidity, lastSeenUID := s.db.GetFolderSyncState(dbFolder.ID) newMessages := 0 + // Fetched once per folder-sync, not per message — rules rarely change mid-sync. + activeRules, _ := s.db.ListActiveRules(account.ID) + // UIDVALIDITY changed = folder was recreated on server; wipe local and re-fetch all if storedValidity != 0 && status.UIDValidity != storedValidity { log.Printf("[sync] UIDVALIDITY changed for %s/%s — full re-sync", account.EmailAddress, dbFolder.FullPath) @@ -499,6 +519,9 @@ 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 rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil { + s.applyRuleIMAP(c, account, dbFolder, msg, rule) + } } uid := uint32(0) fmt.Sscanf(msg.RemoteUID, "%d", &uid) @@ -528,7 +551,15 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db // Save sync state s.db.SetFolderSyncState(dbFolder.ID, status.UIDValidity, maxUID) - s.db.UpdateFolderCounts(dbFolder.ID) + + // Use the server's real total/unread counts (STATUS), not just what's synced locally — + // with a limited sync_days window, the local messages table only holds a recent subset, + // which would otherwise undercount folders that have older mail sitting on the server. + if total, unread, cerr := c.GetFolderCounts(dbFolder.FullPath); cerr == nil { + s.db.UpdateFolderCountsDirect(dbFolder.ID, int(total), int(unread)) + } else { + s.db.UpdateFolderCounts(dbFolder.ID) + } return newMessages, nil } @@ -537,8 +568,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db // Applies queued IMAP write operations (delete/move/flag) with retry logic. func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { - // Graph accounts don't use the IMAP ops queue - if account.Provider == models.ProviderOutlookPersonal { + // 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) @@ -659,7 +691,15 @@ func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) { return 0, fmt.Errorf("account %d not found", accountID) } s.drainPendingOps(account) - s.deltaSync(account) + switch account.Provider { + case models.ProviderOutlookPersonal: + s.graphDeltaSync(account) + case models.ProviderJMAP: + s.jmapDeltaSync(account) + default: + s.deltaSync(account) + } + s.davSync(account) return 0, nil } @@ -710,6 +750,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 +889,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 +910,11 @@ 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 rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil { + s.applyRuleGraph(gc, account, msg, rule) + } } } @@ -847,3 +927,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) + } + } +} diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 07f4873..2e81e57 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -47,13 +47,16 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San z-index:100;display:flex;align-items:center;justify-content:center; opacity:0;pointer-events:none;transition:opacity .2s} .modal-overlay.open{opacity:1;pointer-events:all} +/* Account add/edit modals open from inside the Settings modal and must stack above it, + regardless of DOM order, so Settings stays visible (and reachable) underneath. */ +#add-account-modal,#edit-account-modal{z-index:110} .modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2); - border:1px solid var(--border2);border-radius:14px;padding:26px; + border:1px solid var(--border2);border-radius:10px;padding:22px; transform:scale(.95);transition:transform .2s} .modal-overlay.open .modal{transform:scale(1)} -.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px} -.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px} -.modal-field{margin-bottom:12px} +.modal h2{font-family:'DM Serif Display',serif;font-size:19px;font-weight:400;margin-bottom:6px} +.modal > p{font-size:13px;color:var(--muted);margin-bottom:16px} +.modal-field{margin-bottom:10px} .modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase; letter-spacing:.8px;color:var(--muted);margin-bottom:5px} .modal-field input,.modal-field select,.modal-field textarea{ @@ -140,11 +143,57 @@ body.auth-page{display:flex;align-items:center;justify-content:center;min-height body.app-page{overflow:hidden} .app{display:flex;height:100vh} +/* Mail view wrapper (list + detail) — lets the reading-pane position be + flipped from the right (default) to the bottom without touching the + sidebar column. */ +.mail-view{display:flex;flex:1;min-width:0;overflow:hidden} +@media (min-width:701px){ + #app-root[data-reading-pane="bottom"] .mail-view{flex-direction:column} + #app-root[data-reading-pane="bottom"] .mail-view .message-list-panel{ + width:100%;height:38%;min-height:160px;border-right:none;border-bottom:1px solid var(--border)} + #app-root[data-reading-pane="bottom"] .mail-view .message-detail{flex:1;min-height:0} +} + +/* Drag handle between the message list and reading pane — desktop only (mobile + switches full-screen between the two, there's nothing to split). Direction + flips with reading-pane position; size is persisted via uiPrefs (server-side, + not a cookie, so it follows the user across browsers/devices). */ +.panel-resize-handle{display:none} +@media (min-width:701px){ + .panel-resize-handle{display:block;flex-shrink:0;width:5px;cursor:col-resize; + background:transparent;position:relative;z-index:5} + .panel-resize-handle::after{content:'';position:absolute;top:0;bottom:0;left:1px;right:1px; + background:var(--border2);transition:background .15s} + .panel-resize-handle:hover::after,.panel-resize-handle.dragging::after{background:var(--accent)} + #app-root[data-reading-pane="bottom"] .panel-resize-handle{width:100%;height:5px;cursor:row-resize} + #app-root[data-reading-pane="bottom"] .panel-resize-handle::after{top:1px;bottom:1px;left:0;right:0} +} + +/* Sidebar collapse / auto-hide (desktop only — mobile keeps its own drawer below). + #sidebar-expand-btn lives inline in .panel-header (before the folder name) so it + never overlaps content — it's only shown while the sidebar itself is hidden. */ +#sidebar-expand-btn{display:none} +@media (min-width:701px){ + #app-root[data-sidebar="collapsed"] .sidebar, + #app-root[data-sidebar="auto"] .sidebar{width:0;min-width:0;border-right:none;padding:0} + #app-root[data-sidebar="auto"] .sidebar{ + position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w); + transform:translateX(-100%);transition:transform .15s ease; + z-index:60;box-shadow:4px 0 24px rgba(0,0,0,.4);border-right:1px solid var(--border)} + #app-root[data-sidebar="collapsed"] #sidebar-expand-btn, + #app-root[data-sidebar="auto"] #sidebar-expand-btn{display:flex} + /* Auto-hide: peek the sidebar in as an overlay while hovering the expand button + or the sidebar itself (once revealed), pure CSS via :has() — no JS timers. */ + #app-root[data-sidebar="auto"]:has(#sidebar-expand-btn:hover) .sidebar, + #app-root[data-sidebar="auto"] .sidebar:hover{transform:translateX(0)} +} +.sidebar-collapse-btn{flex-shrink:0} + /* Sidebar */ .sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface); border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden} -.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border); - display:flex;align-items:center;justify-content:space-between} +.sidebar-header{padding:12px 14px 10px;border-bottom:1px solid var(--border); + display:flex;flex-direction:column} .sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)} .logo{display:flex;align-items:center;gap:8px} .logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px; @@ -156,17 +205,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 +226,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 +235,11 @@ body.app-page{overflow:hidden} /* Message list panel */ .message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border); display:flex;flex-direction:column;background:var(--surface)} -.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border); +.panel-header{padding:10px 12px 8px;border-bottom:1px solid var(--border); display:flex;align-items:center;justify-content:space-between;flex-shrink:0} -.panel-title{font-family:'DM Serif Display',serif;font-size:17px} +.panel-title{font-family:'DM Serif Display',serif;font-size:16px} .panel-count{font-size:12px;color:var(--muted)} -.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0} +.search-bar{padding:6px 10px;border-bottom:1px solid var(--border);flex-shrink:0} .search-wrap{position:relative} .search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%); width:13px;height:13px;fill:var(--muted);pointer-events:none} @@ -197,28 +249,43 @@ body.app-page{overflow:hidden} .search-input:focus{border-color:var(--accent)} .search-input::placeholder{color:var(--muted)} .message-list{flex:1;overflow-y:auto} -.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative} +.message-item{padding:6px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative} .message-item:hover{background:var(--surface2)} .message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px} -/* Unread: lighter background + bold sender so it pops clearly */ -.message-item.unread{background:rgba(255,255,255,.035)} -.message-item.unread:hover{background:rgba(255,255,255,.055)} -.message-item.unread .msg-from{color:var(--text);font-weight:600} -.message-item.unread .msg-subject{font-weight:600;color:var(--text)} +/* Read messages: everything dimmed down so unread has something to stand out against */ +.msg-from{font-size:13px;font-weight:500;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} +/* Unread: accent-tinted background + a solid dot + bold bright sender/subject + left bar, + so it reads as unread at a glance instead of only on close inspection. */ +.message-item.unread{background:rgba(91,141,239,.07)} +.message-item.unread:hover{background:rgba(91,141,239,.12)} +.message-item.unread .msg-from{color:var(--text);font-weight:700} +.message-item.unread .msg-subject{font-weight:700;color:var(--text)} .message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0; width:3px;background:var(--accent);border-radius:0 2px 2px 0} .message-item.unread.active{background:var(--accent-dim)} .message-item.unread.active::before{display:none} -.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px} -.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} +.msg-unread-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:transparent} +.message-item.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)} +/* Compact 2-line row (default): sender+date, then subject–preview with trailing icons */ +.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px} .msg-date{font-size:11px;color:var(--muted);flex-shrink:0} -.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px} -.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px} -.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0} -.msg-acct{font-size:10px;color:var(--muted)} -.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer} +.msg-line2{display:flex;align-items:center;gap:6px} +.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4} +.msg-subject{color:var(--text2)} +.msg-preview{color:var(--muted)} +.msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0} +.msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0} +.msg-size{font-size:10px;color:var(--muted)} +.msg-star{color:var(--muted);font-size:11px;cursor:pointer} .msg-star.on{color:var(--star)} + +/* 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 +302,19 @@ body.app-page{overflow:hidden} .no-message svg{width:48px;height:48px;fill:var(--border2)} .no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)} .no-message p{font-size:13px} -.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0} -.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px} +.detail-header{padding:12px 20px 10px;border-bottom:1px solid var(--border);flex-shrink:0} +.detail-subject{font-family:'DM Serif Display',serif;font-size:18px;margin-bottom:8px} .detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} .detail-from{font-size:13px} .detail-from strong{color:var(--text)} .detail-from span{color:var(--muted);font-size:12px} .detail-date{font-size:12px;color:var(--muted);flex-shrink:0} -.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0} -.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px; +.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0} +.action-btn{padding:4px 10px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px; color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s} .action-btn:hover{background:var(--surface3);color:var(--text)} .action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)} -.detail-body{flex:1;overflow-y:auto;padding:20px} +.detail-body{flex:1;overflow-y:auto;padding:16px 20px} .detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word} .detail-body iframe{width:100%;border:none;min-height:400px} @@ -256,13 +323,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 +339,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 +404,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 +430,11 @@ body.admin-page{overflow:auto;background:var(--bg)} .setting-control input[type=password]{font-family:monospace;letter-spacing:.1em} /* ---- Rich text compose editor ---- */ -.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap} +.compose-toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap} .fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s} .fmt-btn:hover{background:var(--border2);color:var(--text)} .fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px} -.compose-editor{flex:1;overflow-y:auto;padding:12px 14px; +.compose-editor{flex:1;overflow-y:auto;padding:10px 12px; font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0} .compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none} .compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)} @@ -400,25 +476,13 @@ body.admin-page{overflow:auto;background:var(--bg)} .tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px; font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text} -/* ── Accounts popup ──────────────────────────────────────────── */ -.accounts-popup{ - position:fixed;bottom:52px;left:8px; - width:300px;background:var(--surface2);border:1px solid var(--border2); - border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55); - z-index:300;display:none;flex-direction:column;overflow:hidden; -} -.accounts-popup.open{display:flex} -.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299} -.accounts-popup-backdrop.open{display:block} -.accounts-popup-inner{padding:12px} -.accounts-popup-header{display:flex;align-items:center;justify-content:space-between; - font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px; - color:var(--muted);margin-bottom:8px} -.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px; - transition:background .1s} -.acct-popup-item:hover{background:var(--surface3)} -.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px; - margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px; +/* ── Settings: connected-accounts list (Accounts tab) ──────────── */ +.acct-row{display:flex;align-items:center;gap:8px;padding:9px 8px;border-radius:6px; + transition:background .1s;border-bottom:1px solid var(--border)} +.acct-row:last-child{border-bottom:none} +.acct-row:hover{background:var(--surface3)} +.accounts-add-btn{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px 6px; + margin-top:10px;background:none;border:1px dashed var(--border2);border-radius:7px; color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer; transition:background .1s} .accounts-add-btn:hover{background:var(--accent-dim)} @@ -548,6 +612,8 @@ 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} diff --git a/web/static/js/app.js b/web/static/js/app.js index c8b884d..2fa21a5 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -2,7 +2,7 @@ // ── State ────────────────────────────────────────────────────────────────── const S = { - me: null, accounts: [], providers: {gmail:false,outlook:false}, + me: null, accounts: [], providers: {gmail:false,outlook:false}, signatures: [], folders: [], messages: [], totalMessages: 0, currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox', currentMessage: null, selectedMessageId: null, @@ -10,7 +10,13 @@ const S = { filterUnread: false, filterAttachment: false, sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc' uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.) + // Advanced search filters (Filter icon next to the search box) — cleared by clearSearchFilters(). + searchFilters: { scope:'', hasAttachment:'', dateFrom:'', dateTo:'', minSizeKB:'', maxSizeKB:'', accountId:'', folderId:'' }, }; +function hasActiveSearchFilters() { + const f = S.searchFilters; + return !!(f.scope || f.hasAttachment !== '' || f.dateFrom || f.dateTo || f.minSizeKB || f.maxSizeKB || f.accountId || f.folderId); +} // ── UI Preferences (server-persisted, cross-device) ───────────────────────── let _uiPrefsSaveTimer = null; @@ -25,6 +31,96 @@ function uiPrefsSet(key, val) { function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); } function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); } +// ── View settings (density / reading pane position / sidebar mode) ────────── +function applyViewPrefs() { + const density = uiPrefsGet('density', 'compact'); + const pane = uiPrefsGet('readingPane', 'right'); + const sidebarMode = uiPrefsGet('sidebarMode', 'expanded'); + const root = document.getElementById('app-root'); + if (root) { root.dataset.density = density; root.dataset.readingPane = pane; root.dataset.sidebar = sidebarMode; } + [['compact','Compact'],['comfortable','Comfortable']].forEach(([v,label]) => { + const el = document.getElementById('vopt-density-'+v); + if (el) el.textContent = (v===density?'✓ ':'○ ') + label; + }); + [['right','Right'],['bottom','Bottom']].forEach(([v,label]) => { + const el = document.getElementById('vopt-pane-'+v); + if (el) el.textContent = (v===pane?'✓ ':'○ ') + label; + }); + [['expanded','Pinned (always visible)'],['collapsed','Minimized'],['auto','Auto-hide (peek on hover)']].forEach(([v,label]) => { + const el = document.getElementById('vopt-sidebar-'+v); + if (el) el.textContent = (v===sidebarMode?'✓ ':'○ ') + label; + }); + const listPanel = document.querySelector('.message-list-panel'); + if (listPanel) { + const widthPx = uiPrefsGet('panelWidthPx', null); + const heightPct = uiPrefsGet('panelHeightPct', null); + if (widthPx) listPanel.style.width = widthPx + 'px'; + if (heightPct) listPanel.style.height = heightPct + '%'; + } +} + +// ── Draggable list/reading-pane divider (desktop only) ─────────────────────── +// Persisted via uiPrefs (server-side, cross-device) rather than a cookie. +function initPanelResize() { + const handle = document.getElementById('panel-resize-handle'); + const listPanel = document.querySelector('.message-list-panel'); + if (!handle || !listPanel) return; + let dragging = false; + handle.addEventListener('mousedown', e => { + dragging = true; + handle.classList.add('dragging'); + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + document.addEventListener('mousemove', e => { + if (!dragging) return; + const mailView = document.getElementById('mail-view'); + const r = mailView.getBoundingClientRect(); + if (document.getElementById('app-root').dataset.readingPane === 'bottom') { + const pct = Math.min(80, Math.max(15, ((e.clientY - r.top) / r.height) * 100)); + listPanel.style.height = pct + '%'; + } else { + const w = Math.min(680, Math.max(220, e.clientX - r.left)); + listPanel.style.width = w + 'px'; + } + }); + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + handle.classList.remove('dragging'); + document.body.style.userSelect = ''; + if (document.getElementById('app-root').dataset.readingPane === 'bottom') { + uiPrefsSet('panelHeightPct', parseFloat(listPanel.style.height)); + } else { + uiPrefsSet('panelWidthPx', parseInt(listPanel.style.width)); + } + }); +} +function setViewPref(key, val) { + uiPrefsSet(key, val); + applyViewPrefs(); + closeViewDropdown(); +} +// Quick manual toggle (header button + edge tab): flip between fully visible +// and hidden. Doesn't touch 'auto' as a saved preference — pick that from the +// View menu; this button just pins/unpins whatever's currently hidden. +function toggleSidebarCollapse() { + const cur = uiPrefsGet('sidebarMode', 'expanded'); + setViewPref('sidebarMode', cur === 'expanded' ? 'collapsed' : 'expanded'); +} +function toggleViewDropdown(e) { + e.stopPropagation(); + const menu = document.getElementById('view-dropdown-menu'); + if (!menu) return; + const isOpen = menu.style.display !== 'none'; + menu.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) setTimeout(() => document.addEventListener('click', closeViewDropdown, { once: true }), 0); +} +function closeViewDropdown() { + const menu = document.getElementById('view-dropdown-menu'); + if (menu) menu.style.display = 'none'; +} + // ── Boot ─────────────────────────────────────────────────────────────────── async function init() { const [me, providers, wl, uiPrefsRaw] = await Promise.all([ @@ -39,6 +135,7 @@ async function init() { if (providers) { S.providers = providers; updateProviderButtons(); } if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist); if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw; + applyViewPrefs(); await loadAccounts(); await loadFolders(); @@ -52,22 +149,8 @@ async function init() { if (p.get('connected')) { toast('Account connected! Loading…', 'success'); history.replaceState({},'','/'); - // Reload accounts immediately — new account may already be in DB await loadAccounts(); - await loadFolders(); - // Poll for folder population (syncer takes a moment after account creation) - let tries = 0; - const poll = setInterval(async () => { - tries++; - await loadAccounts(); - await loadFolders(); - // Stop when at least one account now has folders, or after ~30s - const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id)); - if (hasFolders || tries >= 12) { - clearInterval(poll); - toast('Account ready!', 'success'); - } - }, 2500); + pollForNewFolders(); } if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); } @@ -98,6 +181,7 @@ async function init() { }); initComposeDragResize(); + initPanelResize(); startPoller(); mobSetView('list'); // initialise mobile view state } @@ -111,47 +195,31 @@ function updateProviderButtons() { }); } -// ── Accounts popup ───────────────────────────────────────────────────────── -function toggleAccountsMenu(e) { - e.stopPropagation(); - const popup = document.getElementById('accounts-popup'); - const backdrop = document.getElementById('accounts-popup-backdrop'); - if (popup.classList.contains('open')) { - closeAccountsMenu(); return; - } - renderAccountsPopup(); - popup.classList.add('open'); - backdrop.classList.add('open'); -} -function closeAccountsMenu() { - document.getElementById('accounts-popup').classList.remove('open'); - document.getElementById('accounts-popup-backdrop').classList.remove('open'); -} - -function renderAccountsPopup() { - const el = document.getElementById('accounts-popup-list'); +// ── Settings: connected-accounts list ──────────────────────────────────────── +function renderAccountsSettingsList() { + const el = document.getElementById('settings-accounts-list'); + if (!el) return; // Settings modal not open yet — nothing to render into if (!S.accounts.length) { el.innerHTML = '
No accounts connected.
'; return; } el.innerHTML = S.accounts.map(a => { const hasWarning = a.last_error || a.token_expired; - const warningTitle = a.token_expired ? 'OAuth token expired — click Settings to reconnect' : (a.last_error ? '⚠ '+a.last_error : ''); + const warningTitle = a.token_expired ? 'OAuth token expired — click Manage to reconnect' : (a.last_error ? '⚠ '+a.last_error : ''); return ` -
-
- - ${esc(a.display_name||a.email_address)} - ${a.token_expired?'🔑': - a.last_error?'':''} +
+ +
+
${esc(a.display_name||a.email_address)}
+
${esc(a.email_address)}
+ ${a.token_expired?'🔑': + a.last_error?'':''}
- + @@ -165,8 +233,40 @@ async function loadAccounts() { const data = await api('GET','/accounts'); if (!data) return; S.accounts = data; - renderAccountsPopup(); + renderAccountsSettingsList(); populateComposeFrom(); + loadSignatures(); +} + +// ── Signatures (compose prefill) ──────────────────────────────────────────── +async function loadSignatures() { + const data = await api('GET','/signatures'); + if (data) S.signatures = data; +} + +// Returns the HTML for an account's default-for-new or default-for-reply signature, or ''. +function getSignatureHTML(accountId, forReply) { + const acc = S.accounts.find(a => a.id === accountId); + if (!acc) return ''; + const sigId = forReply ? acc.default_signature_reply_id : acc.default_signature_new_id; + if (!sigId) return ''; + const sig = S.signatures.find(s => s.id === sigId); + return sig ? sig.content_html : ''; +} + +// Renders the (possibly empty) signature block with a stable id so it can be swapped +// live if the From-account changes mid-compose, without touching the rest of the body. +function signatureBlockHTML(accountId, forReply) { + const html = getSignatureHTML(accountId, forReply); + return `
${html ? '
' + html : ''}
`; +} + +// Bound to #compose-from's change event — swaps just the signature block. +function onComposeFromChange() { + const accountId = parseInt(document.getElementById('compose-from')?.value || 0); + const forReply = S.composeMode !== 'new'; + const block = document.getElementById('sig-block'); + if (block) block.outerHTML = signatureBlockHTML(accountId, forReply); } function connectOAuth(p) { @@ -178,34 +278,61 @@ function connectOAuth(p) { } function openAddAccountModal() { - ['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); + ['imap-email','imap-name','imap-password','imap-host','smtp-host','imap-caldav-url','imap-carddav-url'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); document.getElementById('imap-port').value='993'; document.getElementById('smtp-port').value='587'; + document.getElementById('use-jmap').checked=false; + toggleJMAPFields(); const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';} - closeAccountsMenu(); openModal('add-account-modal'); } +// Toggles the Add Account modal between IMAP/SMTP fields and a single JMAP +// server URL field (JMAP has no separate SMTP host/port or IMAP port — one +// base URL covers both mail access and sending). +function toggleJMAPFields() { + const jmap=document.getElementById('use-jmap').checked; + document.getElementById('imap-port-field').style.display=jmap?'none':''; + document.getElementById('smtp-fields').style.display=jmap?'none':''; + document.getElementById('imap-hint').style.display=jmap?'none':''; + const label=document.getElementById('imap-host-label'), host=document.getElementById('imap-host'); + label.textContent=jmap?'JMAP Server URL':'IMAP Host'; + host.placeholder=jmap?'https://mail.example.com:8443':'imap.example.com'; +} + async function testNewConnection() { const btn=document.getElementById('test-btn'), result=document.getElementById('test-result'); + const jmap=document.getElementById('use-jmap').checked; const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value, - imap_host:document.getElementById('imap-host').value.trim(),imap_port:parseInt(document.getElementById('imap-port').value)||993, - smtp_host:document.getElementById('smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('smtp-port').value)||587}; - if (!body.email||!body.password||!body.imap_host){result.textContent='Email, password and IMAP host required.';result.className='test-result err';result.style.display='block';return;} + imap_host:document.getElementById('imap-host').value.trim()}; + if (jmap) { body.provider='jmap'; } + else { + body.imap_port=parseInt(document.getElementById('imap-port').value)||993; + body.smtp_host=document.getElementById('smtp-host').value.trim(); + body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587; + } + if (!body.email||!body.password||!body.imap_host){result.textContent=(jmap?'Email, password and JMAP server URL required.':'Email, password and IMAP host required.');result.className='test-result err';result.style.display='block';return;} btn.innerHTML='Testing...';btn.disabled=true; - const r=await api('POST','/accounts/test',body); + const r=await api('POST','/accounts/test',body,20000); btn.textContent='Test Connection';btn.disabled=false; - result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error)||'Connection failed'); + result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error?.message||r?.error)||'Connection failed'); result.className='test-result '+((r?.ok)?'ok':'err'); result.style.display='block'; } async function addIMAPAccount() { const btn=document.getElementById('save-acct-btn'); + const jmap=document.getElementById('use-jmap').checked; const body={email:document.getElementById('imap-email').value.trim(),display_name:document.getElementById('imap-name').value.trim(), - password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim(), - imap_port:parseInt(document.getElementById('imap-port').value)||993,smtp_host:document.getElementById('smtp-host').value.trim(), - smtp_port:parseInt(document.getElementById('smtp-port').value)||587}; - if (!body.email||!body.password||!body.imap_host){toast('Email, password and IMAP host required','error');return;} + password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim()}; + if (jmap) { body.provider='jmap'; } + else { + body.imap_port=parseInt(document.getElementById('imap-port').value)||993; + body.smtp_host=document.getElementById('smtp-host').value.trim(); + body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587; + } + body.caldav_url=document.getElementById('imap-caldav-url').value.trim(); + body.carddav_url=document.getElementById('imap-carddav-url').value.trim(); + if (!body.email||!body.password||!body.imap_host){toast((jmap?'Email, password and JMAP server URL required':'Email, password and IMAP host required'),'error');return;} btn.disabled=true;btn.textContent='Connecting...'; const r=await api('POST','/accounts',body); btn.disabled=false;btn.textContent='Connect'; @@ -213,11 +340,28 @@ async function addIMAPAccount() { toast('Account added — syncing…','success'); closeModal('add-account-modal'); await loadAccounts(); - // Background sync takes a moment — reload folders/messages after a short wait - setTimeout(async ()=>{ await loadFolders(); await loadMessages(); toast('Sync complete','success'); }, 3000); + pollForNewFolders(); } else toast(r?.error||'Failed to add account','error'); } +// Repeatedly reloads folders/accounts until the newly-added account's folders show up +// (initial IMAP sync takes a few seconds), instead of guessing a fixed delay. Also +// refreshes messages/unread counts so the sidebar updates without a manual page reload. +function pollForNewFolders() { + let tries = 0; + const poll = setInterval(async () => { + tries++; + await loadAccounts(); + await loadFolders(); + await loadMessages(); + const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id)); + if (hasFolders || tries >= 12) { + clearInterval(poll); + if (hasFolders) toast('Account ready!', 'success'); + } + }, 2500); +} + async function detectMailSettings() { const email=document.getElementById('imap-email').value.trim(); if (!email||!email.includes('@')){toast('Enter your email address first','error');return;} @@ -243,8 +387,10 @@ async function syncNow(id, e) { } // ── Edit Account modal ───────────────────────────────────────────────────── +// Opened from the Settings modal's Accounts tab; stacks above it (see +// #edit-account-modal z-index in CSS) rather than closing Settings, so +// Cancel/Save land the user back in Settings automatically. async function openEditAccount(id) { - closeAccountsMenu(); const r=await api('GET','/accounts/'+id); if (!r) return; document.getElementById('edit-account-id').value=id; @@ -273,14 +419,21 @@ async function openEditAccount(id) { }; } + const isJMAP = r.provider==='jmap'; if (!isOAuth) { document.getElementById('edit-password').value=''; document.getElementById('edit-imap-host').value=r.imap_host||''; document.getElementById('edit-imap-port').value=r.imap_port||993; document.getElementById('edit-smtp-host').value=r.smtp_host||''; document.getElementById('edit-smtp-port').value=r.smtp_port||587; + document.getElementById('edit-imap-port-field').style.display=isJMAP?'none':''; + document.getElementById('edit-smtp-fields').style.display=isJMAP?'none':''; + document.getElementById('edit-imap-host-label').textContent=isJMAP?'JMAP Server URL':'IMAP Host'; } + document.getElementById('edit-caldav-url').value=r.caldav_url||''; + document.getElementById('edit-carddav-url').value=r.carddav_url||''; + document.getElementById('edit-sync-days').value=r.sync_days||30; const sel = document.getElementById('edit-sync-mode'); if (r.sync_mode==='all' || !r.sync_days) { @@ -347,12 +500,18 @@ async function testEditConnection() { const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result'); const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim(); if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;} + const isJMAP=document.getElementById('edit-imap-port-field').style.display==='none'; + const testBody={email,password:pw,imap_host:document.getElementById('edit-imap-host').value.trim()}; + if (isJMAP) { testBody.provider='jmap'; } + else { + testBody.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993; + testBody.smtp_host=document.getElementById('edit-smtp-host').value.trim(); + testBody.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587; + } btn.innerHTML='Testing...';btn.disabled=true; - const r=await api('POST','/accounts/test',{email,password:pw, - imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993, - smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587}); + const r=await api('POST','/accounts/test',testBody,20000); btn.textContent='Test Connection';btn.disabled=false; - connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error)||'Failed'); + connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error?.message||r?.error)||'Failed'); connEl.className='test-result '+((r?.ok)?'ok':'err'); connEl.style.display='block'; } @@ -368,6 +527,8 @@ async function saveAccountEdit() { const pw=document.getElementById('edit-password').value; if (pw) body.password=pw; } + body.caldav_url=document.getElementById('edit-caldav-url').value.trim(); + body.carddav_url=document.getElementById('edit-carddav-url').value.trim(); const modeVal = document.getElementById('edit-sync-mode').value; let syncMode='all', syncDays=0; if (modeVal==='days') { @@ -392,7 +553,7 @@ async function deleteAccount(id) { 'Remove '+(a?a.email_address:'this account')+'? All synced messages will be deleted.', async () => { const r=await api('DELETE','/accounts/'+id); - if (r?.ok){toast('Account removed','success');closeAccountsMenu();loadAccounts();loadFolders();loadMessages();} + if (r?.ok){toast('Account removed','success');loadAccounts();loadFolders();loadMessages();} else toast('Remove failed','error'); } ); @@ -411,6 +572,22 @@ function inlineConfirm(message, onOk, onCancel) { cancel.onclick = () => { cleanup(); onCancel && onCancel(); }; } +// ── Inline prompt (replaces browser prompt()) ────────────────────────────── +function inlinePrompt(message, onOk, defaultValue) { + const el = document.getElementById('inline-prompt'); + const msg = document.getElementById('inline-prompt-msg'); + const input = document.getElementById('inline-prompt-input'); + const ok = document.getElementById('inline-prompt-ok'); + const cancel = document.getElementById('inline-prompt-cancel'); + msg.textContent = message; + input.value = defaultValue || ''; + el.classList.add('open'); + setTimeout(() => { input.focus(); input.select(); }, 50); + const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; }; + ok.onclick = () => { const v=input.value.trim(); if(!v) return; cleanup(); onOk && onOk(v); }; + cancel.onclick = () => { cleanup(); }; +} + // ── Folders ──────────────────────────────────────────────────────────────── async function loadFolders() { const data=await api('GET','/folders'); @@ -479,8 +656,11 @@ function renderFolders() { oncontextmenu="showFolderMenu(event,${f.id})"> ${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom} ${esc(f.name)} - ${f.unread_count>0?`${f.unread_count}`:''} - ${!f.sync_enabled?'\u29b8':''} + + ${f.unread_count>0?`${f.unread_count}`:''} + ${f.total_count>0?`${f.unread_count>0?'/':''}${f.total_count}`:''} + + ${!f.sync_enabled?'\u29b8':''}
`).join(''); return `