layout and sync adjustment

This commit is contained in:
2026-08-29 11:06:22 +01:00
parent d470c8b71f
commit f506183b6d
38 changed files with 6013 additions and 526 deletions
+5 -1
View File
@@ -6,4 +6,8 @@ data/gowebmail.conf
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace
webmail.code-workspace
graphify-out
GEMINI.md
tests/
+35
View File
@@ -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))
-90
View File
@@ -1,90 +0,0 @@
# GoMail Configuration
# =====================
# Auto-generated and updated on each startup.
# Edit freely — your values are always preserved.
# Environment variables (or GOMAIL_<KEY>) override values here.
#
# --- Server ---
# Public hostname of this GoMail instance (no port, no protocol).
# Examples: localhost | mail.example.com | 192.168.1.10
# Used to build BASE_URL and OAuth redirect URIs automatically.
# Also used in security checks to reject requests with unexpected Host headers.
HOSTNAME = localhost
# Address and port to listen on. Format: [host]:port
# :8080 — all interfaces, port 8080
# 0.0.0.0:8080 — all interfaces (explicit)
# 127.0.0.1:8080 — localhost only
LISTEN_ADDR = :8080
# Public URL of this instance (no trailing slash). Leave blank to auto-build
# from HOSTNAME and LISTEN_ADDR port (recommended).
# Auto-build examples:
# HOSTNAME=localhost + :8080 → http://localhost:8080
# HOSTNAME=mail.example.com + :443 → https://mail.example.com
# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080
# Override here only if you need a custom path prefix or your proxy rewrites the URL.
BASE_URL =
# Set to true when GoMail is served over HTTPS (directly or via proxy).
# Marks session cookies as Secure so browsers only send them over TLS.
SECURE_COOKIE = false
# How long a login session lasts, in seconds. Default: 604800 (7 days).
SESSION_MAX_AGE = 604800
# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.
# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,
# which GoMail uses to determine the real client IP and whether TLS is in use.
# Examples:
# 127.0.0.1 (loopback only — Nginx/Traefik on same host)
# 10.0.0.0/8,172.16.0.0/12 (private networks)
# 192.168.1.50,192.168.1.51 (specific IPs)
# Leave blank to disable proxy trust (requests are taken at face value).
# NOTE: Do not add untrusted IPs — clients could spoof their source address.
TRUSTED_PROXIES =
# --- Storage ---
# Path to the SQLite database file.
DB_PATH = ./data/gowebmail.db
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.
# NOTE: Back this up. Losing it makes the entire database permanently unreadable.
# openssl rand -hex 32
ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97
# Secret used to sign session cookies. Auto-generated on first run.
# Changing this invalidates all active sessions (everyone gets logged out).
SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e
# --- Gmail / Google OAuth2 ---
# Create at: https://console.cloud.google.com/apis/credentials
# Application type : Web application
# Required scope : https://mail.google.com/
# Redirect URI : <BASE_URL>/auth/gmail/callback
GOOGLE_CLIENT_ID =
GOOGLE_CLIENT_SECRET =
# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
# Must exactly match what is registered in Google Cloud Console.
GOOGLE_REDIRECT_URL =
# --- Outlook / Microsoft 365 OAuth2 ---
# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email
# Redirect URI : <BASE_URL>/auth/outlook/callback
MICROSOFT_CLIENT_ID =
MICROSOFT_CLIENT_SECRET =
# Use 'common' to allow any Microsoft account,
# or your Azure tenant ID to restrict to one organisation.
MICROSOFT_TENANT_ID = common
# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
# Must exactly match what is registered in Azure.
MICROSOFT_REDIRECT_URL =
+13 -4
View File
@@ -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
)
+24 -6
View File
@@ -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=
+157
View File
@@ -0,0 +1,157 @@
// Package caldav pulls calendar events and contacts from a remote
// CalDAV/CardDAV server so they can be mirrored into gowebmail's local DB.
// One-way (server -> gowebmail) read sync only.
package caldav
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-vcard"
dav "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"github.com/ghostersk/gowebmail/internal/models"
)
const timeout = 30 * time.Second
// SyncCalendar fetches all VEVENTs from the calendar collection at url
// (HTTP basic auth) and returns them as CalendarEvent rows tagged with accountID.
func SyncCalendar(ctx context.Context, url, user, pass string, accountID int64) ([]*models.CalendarEvent, error) {
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
c, err := caldav.NewClient(hc, url)
if err != nil {
return nil, fmt.Errorf("caldav client: %w", err)
}
objs, err := c.QueryCalendar(ctx, "", &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{AllProps: true, AllComps: true},
CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}},
})
if err != nil {
return nil, fmt.Errorf("caldav query: %w", err)
}
var out []*models.CalendarEvent
for _, obj := range objs {
if obj.Data == nil {
continue
}
for _, ev := range obj.Data.Events() {
ev := ev
out = append(out, eventFromICal(&ev, accountID))
}
}
return out, nil
}
func eventFromICal(ev *ical.Event, accountID int64) *models.CalendarEvent {
uid, _ := ev.Props.Text(ical.PropUID)
summary, _ := ev.Props.Text(ical.PropSummary)
desc, _ := ev.Props.Text(ical.PropDescription)
loc, _ := ev.Props.Text(ical.PropLocation)
allDay := false
if p := ev.Props.Get(ical.PropDateTimeStart); p != nil {
allDay = p.ValueType() == ical.ValueDate
}
start, _ := ev.DateTimeStart(time.UTC)
end, _ := ev.DateTimeEnd(time.UTC)
if end.IsZero() {
end = start
}
status := ""
if s, err := ev.Status(); err == nil {
status = strings.ToLower(string(s))
}
organizer := ""
if p := ev.Props.Get(ical.PropOrganizer); p != nil {
organizer = strings.TrimPrefix(p.Value, "mailto:")
}
var attendees []string
for _, p := range ev.Props.Values(ical.PropAttendee) {
attendees = append(attendees, strings.TrimPrefix(p.Value, "mailto:"))
}
rrule := ""
if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil {
rrule = p.Value
}
return &models.CalendarEvent{
AccountID: &accountID,
UID: uid,
Title: summary,
Description: desc,
Location: loc,
StartTime: formatEventTime(start, allDay),
EndTime: formatEventTime(end, allDay),
AllDay: allDay,
RecurrenceRule: rrule,
Status: status,
OrganizerEmail: organizer,
Attendees: strings.Join(attendees, ", "),
}
}
func formatEventTime(t time.Time, allDay bool) string {
if allDay {
return t.Format("2006-01-02")
}
return t.UTC().Format("2006-01-02T15:04:05Z")
}
// SyncContacts fetches all vCards from the address book collection at url
// (HTTP basic auth) and returns them as Contact rows tagged with accountID.
func SyncContacts(ctx context.Context, url, user, pass string, accountID int64) ([]*models.Contact, error) {
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
c, err := carddav.NewClient(hc, url)
if err != nil {
return nil, fmt.Errorf("carddav client: %w", err)
}
objs, err := c.QueryAddressBook(ctx, "", &carddav.AddressBookQuery{
DataRequest: carddav.AddressDataRequest{AllProp: true},
})
if err != nil {
return nil, fmt.Errorf("carddav query: %w", err)
}
var out []*models.Contact
for _, obj := range objs {
if obj.Card == nil {
continue
}
out = append(out, contactFromVCard(obj.Card, obj.Path, accountID))
}
return out, nil
}
func contactFromVCard(card vcard.Card, path string, accountID int64) *models.Contact {
uid := card.PreferredValue(vcard.FieldUID)
if uid == "" {
// vCard UID is only a SHOULD in vCard 3.0 — fall back to the stable
// resource path so contacts without one don't collide on upsert.
uid = path
}
name := card.PreferredValue(vcard.FieldFormattedName)
org := card.PreferredValue(vcard.FieldOrganization)
if i := strings.Index(org, ";"); i >= 0 {
org = org[:i]
}
return &models.Contact{
AccountID: &accountID,
UID: uid,
DisplayName: name,
Email: card.PreferredValue(vcard.FieldEmail),
Phone: card.PreferredValue(vcard.FieldTelephone),
Company: org,
Notes: card.PreferredValue(vcard.FieldNote),
}
}
+466 -38
View File
@@ -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
}
+107
View File
@@ -0,0 +1,107 @@
package db
import (
"database/sql"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- PGP identities ----
// private_key_armor relies on OpenPGP's own native S2K passphrase protection (no app-layer
// encryption needed here, unlike smime.go's key_pem) — stored exactly as produced.
func (d *DB) ListPGPIdentities(accountID int64) ([]models.PGPIdentity, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM pgp_identities WHERE account_id=? ORDER BY created_at DESC`, accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PGPIdentity
for rows.Next() {
var p models.PGPIdentity
if err := rows.Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (d *DB) GetPGPIdentity(accountID, id int64) (*models.PGPIdentity, error) {
p := &models.PGPIdentity{}
err := d.sql.QueryRow(
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM pgp_identities WHERE account_id=? AND id=?`, accountID, id,
).Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (d *DB) CreatePGPIdentity(accountID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
res, err := d.sql.Exec(
`INSERT INTO pgp_identities (account_id, label, email, fingerprint, public_key_armor, private_key_armor) VALUES (?,?,?,?,?,?)`,
accountID, label, email, fingerprint, publicKeyArmor, privateKeyArmor,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) DeletePGPIdentity(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM pgp_identities WHERE id=? AND account_id=?`, id, accountID)
return err
}
// ---- PGP contact public keys (per-user address book) ----
func (d *DB) ListPGPContacts(userID int64) ([]models.PGPContact, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? ORDER BY email`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PGPContact
for rows.Next() {
var c models.PGPContact
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetPGPContactByEmail looks up a contact's public key by address. Returns nil, nil if not found.
func (d *DB) GetPGPContactByEmail(userID int64, email string) (*models.PGPContact, error) {
c := &models.PGPContact{}
err := d.sql.QueryRow(
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
userID, email,
).Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
func (d *DB) UpsertPGPContact(userID int64, email, label, fingerprint, publicKeyArmor string) error {
_, err := d.sql.Exec(
`INSERT INTO pgp_contacts (user_id, email, label, fingerprint, public_key_armor) VALUES (?,?,?,?,?)
ON CONFLICT(user_id, email) DO UPDATE SET label=excluded.label, fingerprint=excluded.fingerprint, public_key_armor=excluded.public_key_armor`,
userID, email, label, fingerprint, publicKeyArmor,
)
return err
}
func (d *DB) DeletePGPContact(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM pgp_contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
+152
View File
@@ -0,0 +1,152 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- Rules (filters) ----
func scanRule(rowConditions, rowActionOptions string, r *models.Rule) {
_ = json.Unmarshal([]byte(rowConditions), &r.Conditions)
_ = json.Unmarshal([]byte(rowActionOptions), &r.ActionOptions)
}
// ListRules returns all rules for an account, ordered by priority (lowest first, then id).
func (d *DB) ListRules(accountID int64) ([]models.Rule, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
action_options, is_active, created_at
FROM rules WHERE account_id=? ORDER BY priority ASC, id ASC`, accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Rule
for rows.Next() {
var r models.Rule
var conditionsJSON, optionsJSON string
var isActive int
if err := rows.Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt); err != nil {
return nil, err
}
r.IsActive = isActive == 1
scanRule(conditionsJSON, optionsJSON, &r)
out = append(out, r)
}
return out, rows.Err()
}
// ListActiveRules returns only is_active rules for an account, same ordering as ListRules.
func (d *DB) ListActiveRules(accountID int64) ([]models.Rule, error) {
all, err := d.ListRules(accountID)
if err != nil {
return nil, err
}
var active []models.Rule
for _, r := range all {
if r.IsActive {
active = append(active, r)
}
}
return active, nil
}
// GetRule fetches a single rule scoped to an account (so one user can't touch another's rule by id).
func (d *DB) GetRule(accountID, id int64) (*models.Rule, error) {
r := &models.Rule{}
var conditionsJSON, optionsJSON string
var isActive int
err := d.sql.QueryRow(
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
action_options, is_active, created_at
FROM rules WHERE account_id=? AND id=?`, accountID, id,
).Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
r.IsActive = isActive == 1
scanRule(conditionsJSON, optionsJSON, r)
return r, nil
}
// CreateRule inserts a new rule and returns its id.
func (d *DB) CreateRule(r *models.Rule) (int64, error) {
conditionsJSON, err := json.Marshal(r.Conditions)
if err != nil {
return 0, fmt.Errorf("marshal conditions: %w", err)
}
optionsJSON, err := json.Marshal(r.ActionOptions)
if err != nil {
return 0, fmt.Errorf("marshal action_options: %w", err)
}
if r.MatchType == "" {
r.MatchType = "all"
}
res, err := d.sql.Exec(
`INSERT INTO rules (account_id, name, priority, conditions, match_type, action, action_value, action_options, is_active)
VALUES (?,?,?,?,?,?,?,?,?)`,
r.AccountID, r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateRule replaces an existing rule's fields (scoped to account_id).
func (d *DB) UpdateRule(r *models.Rule) error {
conditionsJSON, err := json.Marshal(r.Conditions)
if err != nil {
return fmt.Errorf("marshal conditions: %w", err)
}
optionsJSON, err := json.Marshal(r.ActionOptions)
if err != nil {
return fmt.Errorf("marshal action_options: %w", err)
}
_, err = d.sql.Exec(
`UPDATE rules SET name=?, priority=?, conditions=?, match_type=?, action=?, action_value=?, action_options=?, is_active=?
WHERE id=? AND account_id=?`,
r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
r.ID, r.AccountID,
)
return err
}
// DeleteRule removes a rule (scoped to account_id).
func (d *DB) DeleteRule(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM rules WHERE id=? AND account_id=?`, id, accountID)
return err
}
// HasRecentAutoReply reports whether an auto-reply was already sent to recipientEmail
// for this rule within the last 24h, to prevent auto-reply loops.
func (d *DB) HasRecentAutoReply(accountID, ruleID int64, recipientEmail string) (bool, error) {
var n int
err := d.sql.QueryRow(
`SELECT COUNT(*) FROM auto_reply_log
WHERE account_id=? AND rule_id=? AND recipient_email=? COLLATE NOCASE
AND sent_at > datetime('now', '-1 day')`,
accountID, ruleID, recipientEmail,
).Scan(&n)
return n > 0, err
}
// LogAutoReply records that an auto-reply was just sent, for HasRecentAutoReply's window check.
func (d *DB) LogAutoReply(accountID, ruleID int64, recipientEmail string) error {
_, err := d.sql.Exec(
`INSERT INTO auto_reply_log (account_id, rule_id, recipient_email) VALUES (?,?,?)`,
accountID, ruleID, recipientEmail,
)
return err
}
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"database/sql"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- Signatures ----
// ListSignatures returns all signatures owned by a user.
func (d *DB) ListSignatures(userID int64) ([]models.Signature, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? ORDER BY name`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Signature
for rows.Next() {
var s models.Signature
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetSignature fetches one signature scoped to its owning user.
func (d *DB) GetSignature(userID, id int64) (*models.Signature, error) {
s := &models.Signature{}
err := d.sql.QueryRow(
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? AND id=?`, userID, id,
).Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return s, err
}
// CreateSignature inserts a new signature and returns its id.
func (d *DB) CreateSignature(userID int64, name, contentHTML string) (int64, error) {
res, err := d.sql.Exec(
`INSERT INTO signatures (user_id, name, content_html) VALUES (?,?,?)`, userID, name, contentHTML,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateSignature updates name/content of a signature (scoped to owner).
func (d *DB) UpdateSignature(userID, id int64, name, contentHTML string) error {
_, err := d.sql.Exec(
`UPDATE signatures SET name=?, content_html=? WHERE id=? AND user_id=?`, name, contentHTML, id, userID,
)
return err
}
// DeleteSignature removes a signature (scoped to owner). Any signature_defaults rows
// pointing at it are cleared automatically via ON DELETE SET NULL.
func (d *DB) DeleteSignature(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM signatures WHERE id=? AND user_id=?`, id, userID)
return err
}
// GetSignatureDefaults returns the default-new/default-reply signature ids for an account.
// Returns a zero-value struct (no error) if the account has no defaults row yet.
func (d *DB) GetSignatureDefaults(accountID int64) (models.SignatureDefaults, error) {
sd := models.SignatureDefaults{AccountID: accountID}
var newID, replyID sql.NullInt64
err := d.sql.QueryRow(
`SELECT default_new_id, default_reply_id FROM signature_defaults WHERE account_id=?`, accountID,
).Scan(&newID, &replyID)
if err == sql.ErrNoRows {
return sd, nil
}
if err != nil {
return sd, err
}
sd.DefaultNewID = newID.Int64
sd.DefaultReplyID = replyID.Int64
return sd, nil
}
// SetSignatureDefaults upserts which signature is default-for-new / default-for-reply on an account.
// A ProviderID of 0 clears that default (stored as NULL).
func (d *DB) SetSignatureDefaults(accountID, defaultNewID, defaultReplyID int64) error {
var newVal, replyVal interface{}
if defaultNewID > 0 {
newVal = defaultNewID
}
if defaultReplyID > 0 {
replyVal = defaultReplyID
}
_, err := d.sql.Exec(
`INSERT INTO signature_defaults (account_id, default_new_id, default_reply_id) VALUES (?,?,?)
ON CONFLICT(account_id) DO UPDATE SET default_new_id=excluded.default_new_id, default_reply_id=excluded.default_reply_id`,
accountID, newVal, replyVal,
)
return err
}
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"database/sql"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
// ---- S/MIME identities ----
// key_pem is encrypted at rest via d.enc (internal/crypto.Encryptor), same as OAuth tokens elsewhere.
// ListSMIMEIdentities returns all S/MIME identities for an account (key_pem decrypted).
func (d *DB) ListSMIMEIdentities(accountID int64) ([]models.SMIMEIdentity, error) {
rows, err := d.sql.Query(
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? ORDER BY created_at DESC`,
accountID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SMIMEIdentity
for rows.Next() {
var s models.SMIMEIdentity
var keyEnc string
if err := rows.Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt); err != nil {
return nil, err
}
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
out = append(out, s)
}
return out, rows.Err()
}
// GetSMIMEIdentity fetches one S/MIME identity scoped to its account (key_pem decrypted).
func (d *DB) GetSMIMEIdentity(accountID, id int64) (*models.SMIMEIdentity, error) {
s := &models.SMIMEIdentity{}
var keyEnc string
err := d.sql.QueryRow(
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? AND id=?`,
accountID, id,
).Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
return s, nil
}
// CreateSMIMEIdentity encrypts keyPEM at rest and inserts a new identity, returning its id.
func (d *DB) CreateSMIMEIdentity(accountID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
keyEnc, err := d.enc.Encrypt(keyPEM)
if err != nil {
return 0, err
}
res, err := d.sql.Exec(
`INSERT INTO smime_identities (account_id, cert_pem, key_pem, not_after) VALUES (?,?,?,?)`,
accountID, certPEM, keyEnc, notAfter,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// DeleteSMIMEIdentity removes an identity (scoped to account_id).
func (d *DB) DeleteSMIMEIdentity(accountID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM smime_identities WHERE id=? AND account_id=?`, id, accountID)
return err
}
// ---- S/MIME contact certs (per-user address book, unencrypted — public certs only) ----
func (d *DB) ListSMIMEContacts(userID int64) ([]models.SMIMEContact, error) {
rows, err := d.sql.Query(
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? ORDER BY email`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SMIMEContact
for rows.Next() {
var c models.SMIMEContact
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetSMIMEContactByEmail looks up a contact's cert by address (used when signer/encryptor
// needs to know if a recipient has a cert on file). Returns nil, nil if not found.
func (d *DB) GetSMIMEContactByEmail(userID int64, email string) (*models.SMIMEContact, error) {
c := &models.SMIMEContact{}
err := d.sql.QueryRow(
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
userID, email,
).Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
// UpsertSMIMEContact adds or replaces a contact's cert for an email address.
func (d *DB) UpsertSMIMEContact(userID int64, email, certPEM string) error {
_, err := d.sql.Exec(
`INSERT INTO smime_contacts (user_id, email, cert_pem) VALUES (?,?,?)
ON CONFLICT(user_id, email) DO UPDATE SET cert_pem=excluded.cert_pem`,
userID, email, certPEM,
)
return err
}
// DeleteSMIMEContact removes a contact cert (scoped to owner).
func (d *DB) DeleteSMIMEContact(userID, id int64) error {
_, err := d.sql.Exec(`DELETE FROM smime_contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
+273 -40
View File
@@ -4,8 +4,11 @@ package email
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -13,6 +16,7 @@ import (
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"path/filepath"
@@ -26,6 +30,50 @@ import (
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// defaultNetTimeout bounds any dial/command whose caller passed a context
// with no deadline (e.g. TestConnection). Callers with a deadline (deltaSync,
// idleWatcher) get that deadline instead — see dialTimeout.
const defaultNetTimeout = 20 * time.Second
func dialTimeout(ctx context.Context) time.Duration {
if dl, ok := ctx.Deadline(); ok {
if d := time.Until(dl); d > 0 {
return d
}
}
return defaultNetTimeout
}
// connectIMAP dials host:port, trying implicit TLS first — this covers both
// the standard 993 port and non-standard implicit-SSL ports (e.g. 40993).
// If the server isn't speaking TLS at all (tls.RecordHeaderError), it falls
// back to plaintext + STARTTLS. A genuine TLS error (bad/self-signed cert)
// is NOT retried in plaintext — it's returned so callers can surface it.
// The dial and every subsequent IMAP command are bounded by timeout, so a
// misconfigured or unreachable server can never hang a caller forever.
func connectIMAP(ctx context.Context, host string, port int) (*client.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
c, err := client.DialWithDialerTLS(dialer, addr, &tls.Config{ServerName: host})
if err != nil {
if _, notTLS := err.(tls.RecordHeaderError); !notTLS {
return nil, err
}
c, err = client.DialWithDialer(dialer, addr)
if err != nil {
return nil, err
}
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
c.Logout()
return nil, fmt.Errorf("STARTTLS: %w", err)
}
}
c.Timeout = timeout
return c, nil
}
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
switch provider {
case gomailModels.ProviderGmail:
@@ -111,21 +159,9 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
return nil, fmt.Errorf("IMAP host not configured for account %s", account.EmailAddress)
}
addr := fmt.Sprintf("%s:%d", host, port)
var c *client.Client
var err error
if port == 993 {
c, err = client.DialTLS(addr, &tls.Config{ServerName: host})
} else {
c, err = client.Dial(addr)
if err == nil {
// Attempt STARTTLS; ignore error if server doesn't support it
_ = c.StartTLS(&tls.Config{ServerName: host})
}
}
c, err := connectIMAP(ctx, host, port)
if err != nil {
return nil, fmt.Errorf("IMAP connect %s: %w", addr, err)
return nil, fmt.Errorf("IMAP connect %s:%d: %w", host, port, err)
}
switch account.Provider {
@@ -172,6 +208,15 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
return &Client{imap: c, account: account}, nil
}
// TestConnectionError details why a connection failed.
type TestConnectionError struct {
Type string `json:"type"` // "connection_error", "cert_error", "auth_error"
Message string `json:"message"`
CertPEM string `json:"cert_pem,omitempty"`
CertHash string `json:"cert_hash,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func TestConnection(account *gomailModels.EmailAccount) error {
c, err := Connect(context.Background(), account)
if err != nil {
@@ -181,12 +226,104 @@ func TestConnection(account *gomailModels.EmailAccount) error {
return nil
}
// TestConnectionDetailed returns structured error info including cert details on failure.
func TestConnectionDetailed(account *gomailModels.EmailAccount, db interface{}) *TestConnectionError {
host, port := imapHostFor(account.Provider)
if account.IMAPHost != "" {
host = account.IMAPHost
port = account.IMAPPort
}
addr := fmt.Sprintf("%s:%d", host, port)
ctx, cancel := context.WithTimeout(context.Background(), defaultNetTimeout)
defer cancel()
c, err := connectIMAP(ctx, host, port)
if err != nil {
errInfo := &TestConnectionError{Message: err.Error()}
// Check if it's a cert error
if certErr, ok := err.(tls.RecordHeaderError); ok && certErr.Msg != "" {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Try to dial and capture the cert for display
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil {
if len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
conn.Close()
}
} else {
// Check if underlying error is a cert error
if e, ok := err.(*x509.UnknownAuthorityError); ok {
errInfo.Type = "cert_error"
errInfo.Hostname = host
if cert := e.Cert; cert != nil {
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
} else if strings.Contains(err.Error(), "certificate") {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Dial insecurely to get the cert
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil && len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
conn.Close()
}
} else if strings.Contains(err.Error(), "auth") {
errInfo.Type = "auth_error"
} else {
errInfo.Type = "connection_error"
}
}
return errInfo
}
// Try auth
switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
if err := c.Authenticate(sasl); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("OAuth auth failed: %v", err)}
}
default:
if err := c.Login(account.EmailAddress, account.AccessToken); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("Login failed: %v", err)}
}
}
c.Close()
return nil
}
func mustEncodeCert(derBytes []byte) []byte {
// Encode DER to PEM
return []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
base64.StdEncoding.EncodeToString(derBytes)))
}
func (c *Client) Close() { c.imap.Logout() }
func (c *Client) DeleteMailbox(name string) error {
return c.imap.Delete(name)
}
func (c *Client) CreateMailbox(name string) error {
return c.imap.Create(name)
}
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
if _, err := c.imap.Select(srcMailbox, false); err != nil {
@@ -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)
+27
View File
@@ -0,0 +1,27 @@
package email
import (
"context"
"fmt"
"github.com/ghostersk/gowebmail/internal/jmap"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// SendMessageJMAP sends via the account's JMAP server instead of SMTP — used
// for ProviderJMAP accounts. Builds the same RFC822 body as SendMessageFull
// (optionally signed/encrypted via signer), then uploads + imports + submits
// it over JMAP; the import into Sent replaces SMTP's separate append-to-Sent
// step, since JMAP's Email/import already files the message.
func SendMessageJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
rawMsg, err := BuildRawMessage(account, req, signer)
if err != nil {
return err
}
jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken)
sentID, err := jc.FindMailboxByRole(ctx, "sent")
if err != nil {
return fmt.Errorf("jmap find Sent folder: %w", err)
}
return jc.Send(ctx, sentID, rawMsg)
}
+277 -70
View File
@@ -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})
}
+5
View File
@@ -20,6 +20,7 @@ import (
"github.com/ghostersk/gowebmail/internal/mfa"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/pgp"
"golang.org/x/oauth2"
)
@@ -30,6 +31,7 @@ type AuthHandler struct {
cfg *config.Config
renderer *Renderer
syncer interface{ TriggerReconcile() }
pgpCache *pgp.Cache
}
// ---- Login ----
@@ -101,6 +103,9 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent())
}
h.db.DeleteSession(cookie.Value)
if h.pgpCache != nil {
h.pgpCache.ClearSession(cookie.Value)
}
}
http.SetCookie(w, &http.Cookie{
Name: "gomail_session", Value: "", MaxAge: -1, Path: "/",
+462
View File
@@ -0,0 +1,462 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/pgp"
"github.com/ghostersk/gowebmail/internal/smime"
)
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
// PGP contact keys the sending account/user actually has on file. Implements
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
// design: "S/MIME certificates sign... PGP keys encrypt...".
type dbSigner struct {
db *db.DB
userID int64
}
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
out := raw
identities, err := s.db.ListSMIMEIdentities(account.ID)
if err == nil && len(identities) > 0 {
id := identities[0]
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
if err != nil {
return nil, fmt.Errorf("smime sign: %w", err)
}
out = signed
}
if len(recipients) > 0 {
var pgpEntities []*openpgp.Entity
allHaveKeys := true
for _, addr := range recipients {
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
if err != nil || contact == nil {
allHaveKeys = false
break
}
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
if err != nil {
allHaveKeys = false
break
}
pgpEntities = append(pgpEntities, entity)
}
if allHaveKeys && len(pgpEntities) > 0 {
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
if err != nil {
return nil, fmt.Errorf("pgp encrypt: %w", err)
}
out = encrypted
}
}
return out, nil
}
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
func (h *APIHandler) newSigner(userID int64) *dbSigner {
return &dbSigner{db: h.db, userID: userID}
}
// ---- S/MIME handlers ----
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListSMIMEIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
account, _ := h.db.GetAccount(req.AccountID)
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
return
}
cert, _ := smime.ParseCertPEM(certPEM)
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
file, _, err := r.FormFile("p12_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "p12_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
password := r.FormValue("p12_password")
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
cert, _ := smime.ParseCertPEM(certPEM)
notAfter := time.Now().Add(smime.DefaultValidity)
if cert != nil {
notAfter = cert.NotAfter
}
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListSMIMEContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
file, _, err := r.FormFile("cert_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "cert_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
if _, err := smime.ParseCertPEM(data); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
return
}
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// ---- PGP handlers ----
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListPGPIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
Label string `json:"label"`
Passphrase string `json:"passphrase"`
Confirm string `json:"passphrase_confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
if len(req.Passphrase) < 8 {
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
return
}
if req.Passphrase != req.Confirm {
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
return
}
account, _ := h.db.GetAccount(req.AccountID)
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
fingerprint := ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
}
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
passphrase := r.FormValue("passphrase")
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
email, fingerprint := "", ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
for name := range entity.Identities {
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
email = id.UserId.Email
break
}
}
}
account, _ := h.db.GetAccount(accountID)
if email == "" && account != nil {
email = account.EmailAddress
}
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
// logout (AuthHandler.Logout).
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IdentityID int64 `json:"identity_id"`
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
h.writeError(w, http.StatusBadRequest, "identity_id required")
return
}
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
if err != nil || identity == nil {
h.writeError(w, http.StatusNotFound, "identity not found")
return
}
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
return
}
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
return
}
if h.pgpCache != nil {
if cookie, err := r.Cookie("gomail_session"); err == nil {
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
}
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListPGPContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
entity, err := pgp.ParsePublicKey(data)
if err != nil {
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
return
}
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeletePGPContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+7 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/pgp"
"github.com/ghostersk/gowebmail/internal/syncer"
)
@@ -21,10 +22,14 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
log.Fatalf("failed to load templates: %v", err)
}
// Shared unlocked-PGP-key cache: populated by APIHandler.PGPUnlock, cleared by
// AuthHandler.Logout. No TTL — memory-bounded by active sessions (see internal/pgp.Cache).
pgpCache := pgp.NewCache()
return &Handlers{
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc, pgpCache: pgpCache},
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
API: &APIHandler{db: database, cfg: cfg, syncer: sc, pgpCache: pgpCache},
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
}
}
+153
View File
@@ -0,0 +1,153 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
var validRuleFields = map[string]bool{
"from": true, "to": true, "subject": true, "body": true, "has_attachment": true, "recipient_type": true,
}
var validRuleOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
var validRuleActions = map[string]bool{
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
}
// ownAccount verifies accountID belongs to the current user, writing a 404 and returning false if not.
func (h *APIHandler) ownAccount(w http.ResponseWriter, r *http.Request, accountID int64) bool {
userID := middleware.GetUserID(r)
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return false
}
return true
}
// ---- Rules ----
func (h *APIHandler) ListRules(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
rules, err := h.db.ListRules(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list rules")
return
}
h.writeJSON(w, rules)
}
func validateRule(r *models.Rule) string {
if strings.TrimSpace(r.Name) == "" {
return "name required"
}
if len(r.Conditions) == 0 {
return "at least one condition required"
}
for _, c := range r.Conditions {
if !validRuleFields[c.Field] {
return "invalid condition field: " + c.Field
}
if !validRuleOps[c.Op] {
return "invalid condition op: " + c.Op
}
if strings.TrimSpace(c.Value) == "" {
return "condition value required"
}
}
if r.MatchType != "any" {
r.MatchType = "all"
}
if !validRuleActions[r.Action] {
return "invalid action: " + r.Action
}
if r.Action == "move_to_folder" && strings.TrimSpace(r.ActionValue) == "" {
return "folder name required for move_to_folder"
}
if r.Action == "forward" && !strings.Contains(r.ActionValue, "@") {
return "valid forward address required"
}
if r.Action == "auto_reply" && strings.TrimSpace(r.ActionValue) == "" {
return "auto-reply subject required"
}
return ""
}
func (h *APIHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
var req models.Rule
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
if req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if msg := validateRule(&req); msg != "" {
h.writeError(w, http.StatusBadRequest, msg)
return
}
id, err := h.db.CreateRule(&req)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create rule")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
var req models.Rule
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
if req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
existing, err := h.db.GetRule(req.AccountID, id)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "rule not found")
return
}
if msg := validateRule(&req); msg != "" {
h.writeError(w, http.StatusBadRequest, msg)
return
}
req.ID = id
if err := h.db.UpdateRule(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update rule")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeleteRule(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete rule")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+106
View File
@@ -0,0 +1,106 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/ghostersk/gowebmail/internal/middleware"
)
func (h *APIHandler) ListSignatures(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
sigs, err := h.db.ListSignatures(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list signatures")
return
}
h.writeJSON(w, sigs)
}
func (h *APIHandler) CreateSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Name string `json:"name"`
ContentHTML string `json:"content_html"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
h.writeError(w, http.StatusBadRequest, "name required")
return
}
id, err := h.db.CreateSignature(userID, req.Name, req.ContentHTML)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create signature")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req struct {
Name string `json:"name"`
ContentHTML string `json:"content_html"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
h.writeError(w, http.StatusBadRequest, "name required")
return
}
existing, err := h.db.GetSignature(userID, id)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "signature not found")
return
}
if err := h.db.UpdateSignature(userID, id, req.Name, req.ContentHTML); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update signature")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteSignature(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete signature")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// SetSignatureDefaults sets which signature (if any) is default-for-new / default-for-reply
// on one account. A ProviderID of 0 in the request clears that default.
func (h *APIHandler) SetSignatureDefaults(w http.ResponseWriter, r *http.Request) {
accountID := pathInt64(r, "id")
if !h.ownAccount(w, r, accountID) {
return
}
var req struct {
DefaultNewID int64 `json:"default_new_id"`
DefaultReplyID int64 `json:"default_reply_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// A signature id of 0 is "clear this default" — otherwise verify the user actually owns it.
userID := middleware.GetUserID(r)
if req.DefaultNewID > 0 {
if s, err := h.db.GetSignature(userID, req.DefaultNewID); err != nil || s == nil {
h.writeError(w, http.StatusBadRequest, "invalid default_new_id")
return
}
}
if req.DefaultReplyID > 0 {
if s, err := h.db.GetSignature(userID, req.DefaultReplyID); err != nil || s == nil {
h.writeError(w, http.StatusBadRequest, "invalid default_reply_id")
return
}
}
if err := h.db.SetSignatureDefaults(accountID, req.DefaultNewID, req.DefaultReplyID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to set defaults")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
+507
View File
@@ -0,0 +1,507 @@
// Package jmap is a minimal JMAP (RFC 8620 Core + RFC 8621 Mail) client for
// ProviderJMAP accounts — an alternative to IMAP/SMTP for mail servers that
// speak JMAP instead. It follows internal/graph's shape (a thin REST/JSON
// wrapper), since both are HTTP+JSON providers unlike IMAP's binary protocol.
//
// Authenticated via HTTP Basic (mailbox email + app password), matching the
// reference server this was built against — see tests/jmap-client.md.
// One HTTP call per JMAP method call: no request batching or back-references,
// since sync here isn't latency-sensitive enough to justify that complexity.
package jmap
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client wraps JMAP API calls for a single mailbox account.
type Client struct {
baseURL string
username string
password string
http *http.Client
accountID string // resolved lazily from /jmap/session
apiURL string
uploadURL string
}
// New creates a JMAP client. baseURL is the server's base URL, e.g.
// "https://mail.example.com:8443" (no trailing slash needed).
func New(baseURL, username, password string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
http: &http.Client{
Timeout: 30 * time.Second,
// Force HTTP/1.1: the reference server (tests/jmap-client.md)
// closes the connection with no response over HTTP/2 — verified
// live (curl negotiates h2 by default and gets a broken pipe;
// --http1.1 works). TLSNextProto disables Go's automatic h2 ALPN
// upgrade for HTTPS requests.
Transport: &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}},
},
}
}
func (c *Client) doReq(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
url := path
if !strings.HasPrefix(path, "http") {
url = c.baseURL + path
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("jmap %s %s returned %d: %s", method, path, resp.StatusCode, string(b))
}
return resp, nil
}
// Session is the RFC 8620 §2 session resource.
type Session struct {
PrimaryAccounts map[string]string `json:"primaryAccounts"`
Username string `json:"username"`
APIURL string `json:"apiUrl"`
UploadURL string `json:"uploadUrl"`
State string `json:"state"`
}
// Session fetches /jmap/session and resolves the mail account id + API/upload
// URLs. Also serves as a pure connectivity/auth test (used by TestConnection).
func (c *Client) Session(ctx context.Context) (*Session, error) {
resp, err := c.doReq(ctx, http.MethodGet, "/jmap/session", nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var s Session
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return nil, fmt.Errorf("decode jmap session: %w", err)
}
c.accountID = s.PrimaryAccounts["urn:ietf:params:jmap:mail"]
if c.accountID == "" {
return nil, fmt.Errorf("jmap session: no mail account found")
}
c.apiURL = s.APIURL
c.uploadURL = strings.ReplaceAll(s.UploadURL, "{accountId}", c.accountID)
return &s, nil
}
func (c *Client) ensureSession(ctx context.Context) error {
if c.accountID != "" {
return nil
}
_, err := c.Session(ctx)
return err
}
type apiRequest struct {
Using []string `json:"using"`
MethodCalls [][3]interface{} `json:"methodCalls"`
}
type apiResponse struct {
MethodResponses [][]json.RawMessage `json:"methodResponses"`
}
// call makes a single JMAP method call and decodes its result args into out
// (which may be nil if the caller doesn't need the response body).
func (c *Client) call(ctx context.Context, method string, args map[string]interface{}, out interface{}) error {
if err := c.ensureSession(ctx); err != nil {
return err
}
body := apiRequest{
Using: []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
MethodCalls: [][3]interface{}{{method, args, "c1"}},
}
b, err := json.Marshal(body)
if err != nil {
return err
}
resp, err := c.doReq(ctx, http.MethodPost, c.apiURL, bytes.NewReader(b), "application/json")
if err != nil {
return err
}
defer resp.Body.Close()
var ar apiResponse
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
return fmt.Errorf("decode jmap response: %w", err)
}
if len(ar.MethodResponses) == 0 || len(ar.MethodResponses[0]) < 2 {
return fmt.Errorf("jmap %s: empty or malformed response", method)
}
first := ar.MethodResponses[0]
var name string
json.Unmarshal(first[0], &name)
if name == "error" {
return fmt.Errorf("jmap %s error: %s", method, string(first[1]))
}
if out != nil {
return json.Unmarshal(first[1], out)
}
return nil
}
func (c *Client) withAccount(args map[string]interface{}) map[string]interface{} {
if args == nil {
args = map[string]interface{}{}
}
args["accountId"] = c.accountID
return args
}
// ---- Mailboxes ----
// Mailbox is a JMAP folder.
type Mailbox struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parentId"`
Role string `json:"role"` // "inbox","sent","drafts","trash","junk", or "" for custom folders
TotalEmails int `json:"totalEmails"`
UnreadEmails int `json:"unreadEmails"`
}
// InferFolderType maps a JMAP Mailbox role to gowebmail's folder type.
func InferFolderType(role string) string {
switch role {
case "inbox":
return "inbox"
case "sent":
return "sent"
case "drafts":
return "drafts"
case "trash":
return "trash"
case "junk":
return "spam"
default:
return "custom"
}
}
// ListMailboxes returns every mailbox (folder) for the account.
func (c *Client) ListMailboxes(ctx context.Context) ([]Mailbox, error) {
var out struct {
List []Mailbox `json:"list"`
}
if err := c.call(ctx, "Mailbox/get", c.withAccount(nil), &out); err != nil {
return nil, err
}
return out.List, nil
}
// FindMailboxByRole returns the id of the mailbox with the given role (e.g.
// "sent", "inbox"), or an error if none is found.
func (c *Client) FindMailboxByRole(ctx context.Context, role string) (string, error) {
boxes, err := c.ListMailboxes(ctx)
if err != nil {
return "", err
}
for _, b := range boxes {
if b.Role == role {
return b.ID, nil
}
}
return "", fmt.Errorf("no mailbox with role %q", role)
}
// ---- Emails ----
// EmailAddr is a JMAP EmailAddress object.
type EmailAddr struct {
Name string `json:"name"`
Email string `json:"email"`
}
// BodyPart is an entry in an Email's textBody/htmlBody list.
type BodyPart struct {
PartID string `json:"partId"`
Type string `json:"type"`
}
// BodyValue is the decoded content for one BodyPart, keyed by partId in Email.BodyValues.
type BodyValue struct {
Value string `json:"value"`
}
// Email is a JMAP message. Keywords/mailboxIds mirror IMAP flags/folder
// membership, except a message here lives in exactly one mailbox (see
// tests/jmap-client.md — "single-mailbox membership").
type Email struct {
ID string `json:"id"`
MailboxIDs map[string]bool `json:"mailboxIds"`
Keywords map[string]bool `json:"keywords"`
Size int `json:"size"`
ReceivedAt time.Time `json:"receivedAt"`
Subject string `json:"subject"`
From []EmailAddr `json:"from"`
To []EmailAddr `json:"to"`
Preview string `json:"preview"`
HasAttachment bool `json:"hasAttachment"`
TextBody []BodyPart `json:"textBody"`
HTMLBody []BodyPart `json:"htmlBody"`
BodyValues map[string]BodyValue `json:"bodyValues"`
}
func (e *Email) FromName() string {
if len(e.From) == 0 {
return ""
}
return e.From[0].Name
}
func (e *Email) FromEmail() string {
if len(e.From) == 0 {
return ""
}
return e.From[0].Email
}
func (e *Email) ToList() string {
parts := make([]string, 0, len(e.To))
for _, t := range e.To {
parts = append(parts, t.Email)
}
return strings.Join(parts, ", ")
}
func (e *Email) IsRead() bool { return e.Keywords["$seen"] }
func (e *Email) IsFlagged() bool { return e.Keywords["$flagged"] }
// TextValue returns the plain-text body, if fetched via GetEmailBody.
func (e *Email) TextValue() string {
for _, p := range e.TextBody {
if bv, ok := e.BodyValues[p.PartID]; ok {
return bv.Value
}
}
return ""
}
// HTMLValue returns the HTML body, if fetched via GetEmailBody.
func (e *Email) HTMLValue() string {
for _, p := range e.HTMLBody {
if bv, ok := e.BodyValues[p.PartID]; ok {
return bv.Value
}
}
return ""
}
// ListEmails returns cheap-field emails in mailboxID. Newest-first order is
// not guaranteed (the reference server's Email/query sort support is
// undocumented — see tests/jmap-client.md — so no sort is requested; callers
// that need a specific order should sort client-side).
func (c *Client) ListEmails(ctx context.Context, mailboxID string, limit int) ([]Email, error) {
if limit <= 0 {
limit = 100
}
var qout struct {
IDs []string `json:"ids"`
}
qargs := c.withAccount(map[string]interface{}{
"filter": map[string]string{"inMailbox": mailboxID},
"limit": limit,
})
if err := c.call(ctx, "Email/query", qargs, &qout); err != nil {
return nil, err
}
if len(qout.IDs) == 0 {
return nil, nil
}
return c.GetEmails(ctx, qout.IDs, false)
}
// GetEmails fetches full Email objects for ids. withBody also fetches
// text/html body content (an expensive decrypt+MIME-parse server-side).
func (c *Client) GetEmails(ctx context.Context, ids []string, withBody bool) ([]Email, error) {
var out struct {
List []Email `json:"list"`
}
args := c.withAccount(map[string]interface{}{"ids": ids})
if withBody {
args["fetchTextBodyValues"] = true
args["fetchHTMLBodyValues"] = true
}
if err := c.call(ctx, "Email/get", args, &out); err != nil {
return nil, err
}
return out.List, nil
}
// GetEmailBody fetches a single email with its full text/html body.
func (c *Client) GetEmailBody(ctx context.Context, id string) (*Email, error) {
list, err := c.GetEmails(ctx, []string{id}, true)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, fmt.Errorf("email %s not found", id)
}
return &list[0], nil
}
// SetKeyword sets or clears a single keyword (e.g. "$seen", "$flagged") on a message.
func (c *Client) SetKeyword(ctx context.Context, emailID, keyword string, on bool) error {
args := c.withAccount(map[string]interface{}{
"update": map[string]interface{}{
emailID: map[string]interface{}{"keywords/" + keyword: on},
},
})
var out struct {
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotUpdated[emailID]; bad {
return fmt.Errorf("jmap keyword update rejected: %s", e)
}
return nil
}
// MoveEmail reassigns a message to a different (single) mailbox.
func (c *Client) MoveEmail(ctx context.Context, emailID, destMailboxID string) error {
args := c.withAccount(map[string]interface{}{
"update": map[string]interface{}{
emailID: map[string]interface{}{"mailboxIds": map[string]bool{destMailboxID: true}},
},
})
var out struct {
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotUpdated[emailID]; bad {
return fmt.Errorf("jmap move rejected: %s", e)
}
return nil
}
// DeleteEmail hard-deletes a message. Unlike Mailbox/set destroy (soft, see
// tests/jmap-client.md), Email/set destroy is a real, unrecoverable delete.
func (c *Client) DeleteEmail(ctx context.Context, emailID string) error {
args := c.withAccount(map[string]interface{}{"destroy": []string{emailID}})
var out struct {
NotDestroyed map[string]json.RawMessage `json:"notDestroyed"`
}
if err := c.call(ctx, "Email/set", args, &out); err != nil {
return err
}
if e, bad := out.NotDestroyed[emailID]; bad {
return fmt.Errorf("jmap delete rejected: %s", e)
}
return nil
}
// ---- Sending ----
// UploadBlob uploads raw message bytes and returns the blob id.
func (c *Client) UploadBlob(ctx context.Context, data []byte) (string, error) {
if err := c.ensureSession(ctx); err != nil {
return "", err
}
resp, err := c.doReq(ctx, http.MethodPost, c.uploadURL, bytes.NewReader(data), "message/rfc822")
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
BlobID string `json:"blobId"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode jmap upload response: %w", err)
}
return out.BlobID, nil
}
// ImportEmail imports an uploaded blob as a message into mailboxID, returning
// the new email id. There is no Email/set create (see tests/jmap-client.md) —
// this upload+import step is the only way to add a message.
func (c *Client) ImportEmail(ctx context.Context, blobID, mailboxID string) (string, error) {
args := c.withAccount(map[string]interface{}{
"emails": map[string]interface{}{
"c1": map[string]interface{}{
"blobId": blobID,
"mailboxIds": map[string]bool{mailboxID: true},
},
},
})
var out struct {
Created map[string]struct {
ID string `json:"id"`
} `json:"created"`
NotCreated map[string]json.RawMessage `json:"notCreated"`
}
if err := c.call(ctx, "Email/import", args, &out); err != nil {
return "", err
}
if created, ok := out.Created["c1"]; ok {
return created.ID, nil
}
return "", fmt.Errorf("jmap import failed: %s", out.NotCreated["c1"])
}
// Submit sends a previously-imported message via EmailSubmission/set.
func (c *Client) Submit(ctx context.Context, emailID string) error {
args := c.withAccount(map[string]interface{}{
"create": map[string]interface{}{
"s1": map[string]interface{}{"emailId": emailID},
},
})
var out struct {
NotCreated map[string]json.RawMessage `json:"notCreated"`
}
if err := c.call(ctx, "EmailSubmission/set", args, &out); err != nil {
return err
}
if e, bad := out.NotCreated["s1"]; bad {
return fmt.Errorf("jmap submission rejected: %s", e)
}
return nil
}
// Send uploads rawMessage, imports it into mailboxID (typically the Sent
// mailbox — the server doesn't auto-file after submission), and submits it
// for delivery.
func (c *Client) Send(ctx context.Context, mailboxID string, rawMessage []byte) error {
blobID, err := c.UploadBlob(ctx, rawMessage)
if err != nil {
return fmt.Errorf("jmap upload: %w", err)
}
emailID, err := c.ImportEmail(ctx, blobID, mailboxID)
if err != nil {
return fmt.Errorf("jmap import: %w", err)
}
if err := c.Submit(ctx, emailID); err != nil {
return fmt.Errorf("jmap submit: %w", err)
}
return nil
}
+105 -1
View File
@@ -87,6 +87,7 @@ const (
ProviderOutlook AccountProvider = "outlook"
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
ProviderIMAPSMTP AccountProvider = "imap_smtp"
ProviderJMAP AccountProvider = "jmap" // generic JMAP (RFC 8620/8621) server
)
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
@@ -100,11 +101,18 @@ type EmailAccount struct {
AccessToken string `json:"-"`
RefreshToken string `json:"-"`
TokenExpiry time.Time `json:"-"`
// IMAP/SMTP settings (optional, stored encrypted)
// IMAP/SMTP settings (optional, stored encrypted).
// For ProviderJMAP accounts, IMAPHost holds the JMAP server base URL
// (e.g. "https://mail.example.com:8443") and AccessToken holds the app
// password — IMAPPort/SMTPHost/SMTPPort are unused for that provider.
IMAPHost string `json:"imap_host,omitempty"`
IMAPPort int `json:"imap_port,omitempty"`
SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
// CalDAV/CardDAV sync — optional, works alongside any provider above.
// Blank = disabled. Uses EmailAddress + AccessToken for HTTP basic auth.
CalDAVURL string `json:"caldav_url,omitempty"`
CardDAVURL string `json:"carddav_url,omitempty"`
// Sync settings
SyncDays int `json:"sync_days"` // how many days back to fetch (0 = all)
SyncMode string `json:"sync_mode"` // "days" or "all"
@@ -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"`
}
+51
View File
@@ -0,0 +1,51 @@
package pgp
import (
"sync"
"github.com/ProtonMail/go-crypto/openpgp"
)
// Cache holds unlocked (passphrase-decrypted) PGP identities in memory, scoped to the
// session that unlocked them — never written to disk. No TTL: memory-bounded by active
// sessions, cleared only on explicit logout (see internal/handlers/auth.go Logout).
type Cache struct {
mu sync.Mutex
byTok map[string]map[int64]*openpgp.Entity // sessionToken -> identityID -> unlocked entity
}
// NewCache creates an empty unlocked-key cache.
func NewCache() *Cache {
return &Cache{byTok: make(map[string]map[int64]*openpgp.Entity)}
}
// Get returns the unlocked entity for identityID under sessionToken, if present.
func (c *Cache) Get(sessionToken string, identityID int64) (*openpgp.Entity, bool) {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.byTok[sessionToken]
if !ok {
return nil, false
}
e, ok := m[identityID]
return e, ok
}
// Put stores an unlocked entity under sessionToken.
func (c *Cache) Put(sessionToken string, identityID int64, entity *openpgp.Entity) {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.byTok[sessionToken]
if !ok {
m = make(map[int64]*openpgp.Entity)
c.byTok[sessionToken] = m
}
m[identityID] = entity
}
// ClearSession discards every unlocked identity for a session (call on logout).
func (c *Cache) ClearSession(sessionToken string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byTok, sessionToken)
}
+85
View File
@@ -0,0 +1,85 @@
package pgp
import (
"bytes"
"io"
"mime"
"mime/multipart"
"net/mail"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
)
func TestEncryptMIMERoundTrip(t *testing.T) {
pubArmor, privArmor, err := GenerateKeyPair("frank@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
pubEntity, err := ParsePublicKey(pubArmor)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
raw := []byte(
"Message-ID: <1.frank.example.com@example.com>\r\n" +
"From: Frank <frank@example.com>\r\n" +
"To: grace@example.com\r\n" +
"Subject: Secret\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: quoted-printable\r\n" +
"\r\n" +
"Hello, Grace! This is secret.\r\n")
encryptedMsg, err := EncryptMIME(raw, []*openpgp.Entity{pubEntity})
if err != nil {
t.Fatalf("EncryptMIME: %v", err)
}
msg, err := mail.ReadMessage(bytes.NewReader(encryptedMsg))
if err != nil {
t.Fatalf("mail.ReadMessage: %v", err)
}
if got := msg.Header.Get("Subject"); got != "Secret" {
t.Errorf("Subject header = %q, want %q (top-level headers must survive encryption)", got, "Secret")
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType: %v", err)
}
if mediaType != "multipart/encrypted" {
t.Fatalf("Content-Type = %q, want multipart/encrypted", mediaType)
}
mr := multipart.NewReader(msg.Body, params["boundary"])
if _, err := mr.NextPart(); err != nil { // control part: application/pgp-encrypted, Version: 1
t.Fatalf("first part: %v", err)
}
part2, err := mr.NextPart()
if err != nil {
t.Fatalf("second part: %v", err)
}
armored, err := io.ReadAll(part2)
if err != nil {
t.Fatalf("read second part: %v", err)
}
privEntity, err := ParsePrivateKey(privArmor)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if err := UnlockPrivateKey(privEntity, "hunter2hunter2"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(armored, privEntity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
want := "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello, Grace! This is secret.\r\n"
if string(decrypted) != want {
t.Errorf("DecryptEntity() = %q, want %q", decrypted, want)
}
}
+285
View File
@@ -0,0 +1,285 @@
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
// "unsafe by design". This package is encryption-only: no PGP signature generation
// or verification (S/MIME, internal/smime, handles signing).
package pgp
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
func defaultConfig() *packet.Config {
return &packet.Config{
DefaultCipher: packet.CipherAES256, // library default is AES-128
RSABits: 2048,
}
}
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
config := defaultConfig()
entity, err := openpgp.NewEntity(email, "", email, config)
if err != nil {
return nil, nil, fmt.Errorf("generate entity: %w", err)
}
if err := lockEntity(entity, passphrase); err != nil {
return nil, nil, err
}
publicArmor, err = serializePublic(entity)
if err != nil {
return nil, nil, err
}
privateArmor, err = serializePrivate(entity, config)
if err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
func lockEntity(entity *openpgp.Entity, passphrase string) error {
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("lock primary key: %w", err)
}
for _, sub := range entity.Subkeys {
if sub.PrivateKey == nil {
continue
}
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("lock subkey: %w", err)
}
}
return nil
}
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.Serialize(w); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
// which requires the (now-encrypted) private key and fails once it's locked.
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
entity, err := ParsePrivateKey(armoredData)
if err != nil {
return nil, nil, err
}
// Verify the passphrase actually unlocks it before accepting the import.
if err := UnlockPrivateKey(entity, passphrase); err != nil {
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
}
publicArmor, err = serializePublic(entity)
if err != nil {
return nil, nil, err
}
privateArmor = armoredData
return publicArmor, privateArmor, nil
}
// ParsePublicKey reads a single armored public key.
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
return parseEntity(armoredData)
}
// ParsePrivateKey reads a single armored private key. The key remains locked
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
return parseEntity(armoredData)
}
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
if err != nil {
return nil, fmt.Errorf("parse key: %w", err)
}
if len(entities) == 0 {
return nil, fmt.Errorf("no key found in armored data")
}
return entities[0], nil
}
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("unlock primary key: %w", err)
}
}
for _, sub := range entity.Subkeys {
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("unlock subkey: %w", err)
}
}
}
return nil
}
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
func Fingerprint(entity *openpgp.Entity) string {
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
}
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
// recipients' public keys.
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
if err != nil {
return nil, err
}
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
if _, err := pt.Write(raw); err != nil {
return nil, err
}
if err := pt.Close(); err != nil {
return nil, err
}
if err := aw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// DecryptEntity opens an armored PGP message using an already-unlocked identity
// (see UnlockPrivateKey).
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
block, err := armor.Decode(bytes.NewReader(armored))
if err != nil {
return nil, fmt.Errorf("decode armor: %w", err)
}
keyring := openpgp.EntityList{unlockedIdentity}
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
if err != nil {
return nil, fmt.Errorf("read message: %w", err)
}
return io.ReadAll(md.UnverifiedBody)
}
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
// what was encrypted regardless of a trailing CRLF.
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
topLines, entity, err := splitMIMEEntity(raw)
if err != nil {
return nil, err
}
encrypted, err := EncryptEntity(entity, recipients)
if err != nil {
return nil, err
}
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
var out bytes.Buffer
for _, l := range topLines {
out.WriteString(l + "\r\n")
}
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
out.WriteString("--" + boundary + "\r\n")
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
out.WriteString("--" + boundary + "\r\n")
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
out.Write(encrypted)
out.WriteString("\r\n--" + boundary + "--\r\n")
return out.Bytes(), nil
}
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
// stay behind as a stray top-level header of the wrapper message.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
// Encoding/Content-Disposition headers plus blank line plus body.
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
return nil, nil, errors.New("no header/body separator found in message")
}
headerBlock := string(raw[:idx])
body := raw[idx+4:]
rest := strings.Split(headerBlock, "\r\n")
var entityLines []string
for _, name := range entityHeaderNames {
var val string
val, rest = extractHeader(rest, name)
if val != "" {
entityLines = append(entityLines, val)
}
}
if len(entityLines) == 0 {
return nil, nil, errors.New("no Content-Type header found in message")
}
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
return rest, entity, nil
}
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
// returning its full value and the remaining lines with it removed.
func extractHeader(lines []string, name string) (value string, rest []string) {
prefix := strings.ToLower(name) + ":"
for i, l := range lines {
if strings.HasPrefix(strings.ToLower(l), prefix) {
value = l
j := i + 1
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
value += "\r\n" + lines[j]
j++
}
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
return value, rest
}
}
return "", lines
}
+68
View File
@@ -0,0 +1,68 @@
package pgp
import (
"bytes"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
)
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
pubArmor, privArmor, err := GenerateKeyPair("carol@example.com", "correct-horse-battery-staple")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
pubEntity, err := ParsePublicKey(pubArmor)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
raw := []byte("the secret message body")
encrypted, err := EncryptEntity(raw, []*openpgp.Entity{pubEntity})
if err != nil {
t.Fatalf("EncryptEntity: %v", err)
}
privEntity, err := ParsePrivateKey(privArmor)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if !privEntity.PrivateKey.Encrypted {
t.Fatal("private key should be Encrypted (passphrase-protected) before unlocking")
}
// Wrong passphrase must fail.
if err := UnlockPrivateKey(privEntity, "wrong-passphrase"); err == nil {
t.Error("UnlockPrivateKey succeeded with wrong passphrase, want error")
}
if err := UnlockPrivateKey(privEntity, "correct-horse-battery-staple"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(encrypted, privEntity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
if !bytes.Equal(decrypted, raw) {
t.Errorf("DecryptEntity() = %q, want %q", decrypted, raw)
}
}
func TestCache(t *testing.T) {
c := NewCache()
if _, ok := c.Get("tok1", 1); ok {
t.Fatal("expected empty cache miss")
}
e := &openpgp.Entity{}
c.Put("tok1", 1, e)
got, ok := c.Get("tok1", 1)
if !ok || got != e {
t.Fatal("expected cache hit for tok1/1")
}
c.ClearSession("tok1")
if _, ok := c.Get("tok1", 1); ok {
t.Fatal("expected cache miss after ClearSession")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package rules implements mail-filter matching: given a message and an account's
// active rules (already ordered by priority), find the first rule that matches.
package rules
import "strings"
// Condition is one field/op/value test. Mirrors models.RuleCondition but this package
// stays free of the models/db dependency so Match is trivially unit-testable.
type Condition struct {
Field string
Op string
Value string
}
// MessageFields is the subset of a message's data rules can match against.
type MessageFields struct {
From string
To string
Subject string
Body string
HasAttachment bool
RecipientType string // "to" | "cc" | "bcc"
}
// Rule is one filter: conditions (AND'd or OR'd per MatchType) plus an action.
type Rule struct {
ID int64
Priority int
Conditions []Condition
MatchType string // "all" (AND, default) | "any" (OR)
Action string
ActionValue string
ActionOptions map[string]any
}
// Match returns the first rule (by priority, ascending) whose conditions match msg,
// or nil if none match. Callers must pass rules pre-filtered to is_active and pre-sorted
// by priority ascending (ListActiveRules already does this).
func Match(msg MessageFields, activeRules []Rule) *Rule {
for i := range activeRules {
if ruleMatches(&activeRules[i], msg) {
return &activeRules[i]
}
}
return nil
}
func ruleMatches(r *Rule, msg MessageFields) bool {
if len(r.Conditions) == 0 {
return false
}
if r.MatchType == "any" {
for _, c := range r.Conditions {
if conditionMatches(c, msg) {
return true
}
}
return false
}
// default "all" (AND)
for _, c := range r.Conditions {
if !conditionMatches(c, msg) {
return false
}
}
return true
}
func conditionMatches(c Condition, msg MessageFields) bool {
var target string
switch c.Field {
case "from":
target = msg.From
case "to":
target = msg.To
case "subject":
target = msg.Subject
case "body":
target = msg.Body
case "has_attachment":
if msg.HasAttachment {
target = "yes"
} else {
target = "no"
}
case "recipient_type":
target = msg.RecipientType
default:
return false
}
return matchOp(c.Op, c.Value, target)
}
func matchOp(op, value, target string) bool {
value = strings.ToLower(strings.TrimSpace(value))
target = strings.ToLower(target)
switch op {
case "contains":
return value != "" && strings.Contains(target, value)
case "equals":
return target == value
case "starts_with":
return value != "" && strings.HasPrefix(target, value)
default:
return false
}
}
+98
View File
@@ -0,0 +1,98 @@
package rules
import "testing"
func TestMatch(t *testing.T) {
msg := MessageFields{
From: "boss@work.com",
To: "me@example.com",
Subject: "Weekly Report Due",
Body: "please see attached",
HasAttachment: true,
RecipientType: "to",
}
cases := []struct {
name string
rules []Rule
want string // expected matched rule action value marker, "" for no match
}{
{
name: "single contains condition matches",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "from", Op: "contains", Value: "work.com"}}},
},
want: "hit",
},
{
name: "equals is case-insensitive and exact",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "to", Op: "equals", Value: "ME@EXAMPLE.COM"}}},
},
want: "hit",
},
{
name: "starts_with no match",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "subject", Op: "starts_with", Value: "URGENT"}}},
},
want: "",
},
{
name: "match_type all requires every condition",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit", Conditions: []Condition{
{Field: "from", Op: "contains", Value: "work.com"},
{Field: "subject", Op: "contains", Value: "NOPE"},
}},
},
want: "",
},
{
name: "match_type any needs only one condition",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "any", ActionValue: "hit", Conditions: []Condition{
{Field: "from", Op: "contains", Value: "NOPE"},
{Field: "subject", Op: "contains", Value: "Report"},
}},
},
want: "hit",
},
{
name: "has_attachment field",
rules: []Rule{
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
Conditions: []Condition{{Field: "has_attachment", Op: "equals", Value: "yes"}}},
},
want: "hit",
},
{
name: "first matching rule in list order wins, later matching rules ignored",
rules: []Rule{
{ID: 1, Priority: 5, MatchType: "all", ActionValue: "nope",
Conditions: []Condition{{Field: "from", Op: "contains", Value: "does-not-appear"}}},
{ID: 2, Priority: 0, MatchType: "all", ActionValue: "first",
Conditions: []Condition{{Field: "to", Op: "contains", Value: "example"}}},
{ID: 3, Priority: 10, MatchType: "all", ActionValue: "second",
Conditions: []Condition{{Field: "subject", Op: "contains", Value: "Report"}}},
},
want: "first", // Match trusts caller ordering (ListActiveRules sorts by priority ASC before calling)
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Match(msg, tc.rules)
gotVal := ""
if got != nil {
gotVal = got.ActionValue
}
if gotVal != tc.want {
t.Errorf("Match() = %q, want %q", gotVal, tc.want)
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package smime
import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/mail"
"testing"
)
func TestSignMIMERoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("dave@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
// CTE deliberately "7bit", not "quoted-printable": Go's mime/multipart.Part.Read
// auto-decodes quoted-printable/base64 parts, which would make this test compare
// decoded bytes against the raw wire bytes that were actually signed — a test-harness
// footgun, not a production concern (a spec-compliant S/MIME verifier signs/checks the
// encoded wire octets, never the decoded form).
raw := []byte(
"Message-ID: <1.dave.example.com@example.com>\r\n" +
"From: Dave <dave@example.com>\r\n" +
"To: eve@example.com\r\n" +
"Subject: Hello\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: 7bit\r\n" +
"\r\n" +
"Hello, Eve!\r\n")
signed, err := SignMIME(certPEM, keyPEM, raw)
if err != nil {
t.Fatalf("SignMIME: %v", err)
}
// Parse it back like a real mail client would: read top-level headers, find the
// multipart/signed boundary, split into the two parts, and verify.
msg, err := mail.ReadMessage(bytes.NewReader(signed))
if err != nil {
t.Fatalf("mail.ReadMessage: %v", err)
}
if got := msg.Header.Get("Subject"); got != "Hello" {
t.Errorf("Subject header = %q, want %q (top-level headers must survive signing)", got, "Hello")
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType: %v", err)
}
if mediaType != "multipart/signed" {
t.Fatalf("Content-Type = %q, want multipart/signed", mediaType)
}
mr := multipart.NewReader(msg.Body, params["boundary"])
part1, err := mr.NextPart()
if err != nil {
t.Fatalf("first part: %v", err)
}
part1Headers := "Content-Type: " + part1.Header.Get("Content-Type") + "\r\n"
if cte := part1.Header.Get("Content-Transfer-Encoding"); cte != "" {
part1Headers += "Content-Transfer-Encoding: " + cte + "\r\n"
}
part1Body, err := io.ReadAll(part1)
if err != nil {
t.Fatalf("read first part: %v", err)
}
entity := append([]byte(part1Headers+"\r\n"), part1Body...)
part2, err := mr.NextPart()
if err != nil {
t.Fatalf("second part: %v", err)
}
sigB64, err := io.ReadAll(part2)
if err != nil {
t.Fatalf("read second part: %v", err)
}
sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigB64)))
if err != nil {
t.Fatalf("decode signature base64: %v", err)
}
signer, err := VerifySigned(entity, sig)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer.EmailAddresses[0] != "dave@example.com" {
t.Errorf("signer = %v, want dave@example.com", signer.EmailAddresses)
}
}
+289
View File
@@ -0,0 +1,289 @@
// Package smime provides S/MIME certificate generation, signing, and encryption
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
//
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
// signature matches the given certificate, not that the certificate is trusted by any
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
// that want a "known sender" UI hint should compare against the user's own S/MIME
// contact address book, not treat a successful Verify as proof of identity.
package smime
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math/big"
"strings"
"time"
"go.mozilla.org/pkcs7"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
func init() {
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
}
// DefaultValidity is the lifetime used for a freshly self-signed identity.
const DefaultValidity = 365 * 24 * time.Hour
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("generate serial: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: email},
EmailAddresses: []string{email},
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
key, cert, err := pkcs12.Decode(data, password)
if err != nil {
return nil, nil, fmt.Errorf("decode p12: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
}
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("invalid certificate PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("invalid key PEM")
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return key, nil
}
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
sd, err := pkcs7.NewSignedData(raw)
if err != nil {
return nil, fmt.Errorf("new signed data: %w", err)
}
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
return nil, fmt.Errorf("add signer: %w", err)
}
sd.Detach()
return sd.Finish()
}
// VerifySigned checks a detached signature against the original content and returns the
// signer's certificate. It does NOT validate the certificate against any trust store —
// see the package doc comment.
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
p7, err := pkcs7.Parse(signature)
if err != nil {
return nil, fmt.Errorf("parse signature: %w", err)
}
p7.Content = raw
if err := p7.Verify(); err != nil {
return nil, fmt.Errorf("verify: %w", err)
}
signer := p7.GetOnlySigner()
if signer == nil {
return nil, errors.New("no signer certificate found in signature")
}
return signer, nil
}
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
// smime-type=enveloped-data) for the given recipient certificates.
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
return pkcs7.Encrypt(raw, recipients)
}
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
p7, err := pkcs7.Parse(enveloped)
if err != nil {
return nil, fmt.Errorf("parse enveloped data: %w", err)
}
return p7.Decrypt(cert, key)
}
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
//
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
// above only handle the CMS blob itself.
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
// original message's Content-Type + body become the first part, and a detached CMS
// signature over that part becomes the second. All other top-level headers (From, To,
// Subject, Date, Message-ID, ...) are preserved unchanged.
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
topLines, entity, err := splitMIMEEntity(raw)
if err != nil {
return nil, err
}
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
// the delimiter, not the signed content — a compliant multipart parser hands back the
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
sig, err := Sign(certPEM, keyPEM, signedContent)
if err != nil {
return nil, err
}
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
var out bytes.Buffer
for _, l := range topLines {
out.WriteString(l + "\r\n")
}
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
out.WriteString("--" + boundary + "\r\n")
out.Write(signedContent)
out.WriteString("\r\n--" + boundary + "\r\n")
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
out.WriteString("Content-Transfer-Encoding: base64\r\n")
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
out.WriteString(base64Wrap(sig))
out.WriteString("\r\n--" + boundary + "--\r\n")
return out.Bytes(), nil
}
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
// not stay behind as a stray top-level header of the wrapper message.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
return nil, nil, errors.New("no header/body separator found in message")
}
headerBlock := string(raw[:idx])
body := raw[idx+4:]
rest := strings.Split(headerBlock, "\r\n")
var entityLines []string
for _, name := range entityHeaderNames {
var val string
val, rest = extractHeader(rest, name)
if val != "" {
entityLines = append(entityLines, val)
}
}
if len(entityLines) == 0 {
return nil, nil, errors.New("no Content-Type header found in message")
}
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
return rest, entity, nil
}
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
// returning its full value and the remaining lines with it removed.
func extractHeader(lines []string, name string) (value string, rest []string) {
prefix := strings.ToLower(name) + ":"
for i, l := range lines {
if strings.HasPrefix(strings.ToLower(l), prefix) {
value = l
j := i + 1
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
value += "\r\n" + lines[j]
j++
}
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
return value, rest
}
}
return "", lines
}
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
func base64Wrap(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var out strings.Builder
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
out.WriteString(encoded[i:end])
if end < len(encoded) {
out.WriteString("\r\n")
}
}
return out.String()
}
+60
View File
@@ -0,0 +1,60 @@
package smime
import (
"bytes"
"crypto/x509"
"testing"
)
func TestSignVerifyRoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("alice@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
raw := []byte("this is the raw MIME message body")
sig, err := Sign(certPEM, keyPEM, raw)
if err != nil {
t.Fatalf("Sign: %v", err)
}
signer, err := VerifySigned(raw, sig)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if len(signer.EmailAddresses) == 0 || signer.EmailAddresses[0] != "alice@example.com" {
t.Errorf("signer email = %v, want [alice@example.com]", signer.EmailAddresses)
}
// Tamper one byte of the content — verification must fail.
tampered := bytes.Clone(raw)
tampered[0] ^= 0xFF
if _, err := VerifySigned(tampered, sig); err == nil {
t.Error("VerifySigned succeeded against tampered content, want error")
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned("bob@example.com", DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
cert, err := ParseCertPEM(certPEM)
if err != nil {
t.Fatalf("ParseCertPEM: %v", err)
}
raw := []byte("secret message body")
enveloped, err := Encrypt(raw, []*x509.Certificate{cert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
decrypted, err := Decrypt(enveloped, certPEM, keyPEM)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !bytes.Equal(decrypted, raw) {
t.Errorf("Decrypt() = %q, want %q", decrypted, raw)
}
}
+246
View File
@@ -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)
}
+271 -4
View File
@@ -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)
}
}
}
+126 -60
View File
@@ -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 subjectpreview with trailing icons */
.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px}
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
.msg-acct{font-size:10px;color:var(--muted)}
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
.msg-line2{display:flex;align-items:center;gap:6px}
.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4}
.msg-subject{color:var(--text2)}
.msg-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}
+765 -94
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -12,13 +12,12 @@ function _setView(view) {
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
// Show/hide panels — mail-view wraps the message list + reading pane together
// so they hide/show as one unit rather than two separately-toggled panels.
const mailView = document.getElementById('mail-view');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
if (mailView) mailView.style.display = view === 'mail' ? '' : 'none';
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
}
+4 -1
View File
@@ -1,10 +1,13 @@
// GoWebMail shared utilities - loaded on every page
// ---- API helper ----
async function api(method, path, body) {
async function api(method, path, body, timeoutMs) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
try {
const controller = new AbortController();
if (timeoutMs) setTimeout(() => controller.abort(), timeoutMs);
opts.signal = controller.signal;
const r = await fetch('/api' + path, opts);
if (r.status === 401) { location.href = '/auth/login'; return null; }
return r.json().catch(() => null);
+349 -108
View File
@@ -20,19 +20,22 @@
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<button class="icon-btn sidebar-collapse-btn" onclick="toggleSidebarCollapse()" title="Collapse sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
</div>
</div>
</div>
<div style="position:relative;display:inline-flex">
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
<div style="display:flex;margin-top:8px">
<button class="compose-btn" onclick="openCompose()" style="flex:1;border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
</div>
</div>
@@ -63,9 +66,6 @@
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</a>
</div>
<div class="footer-actions">
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
</button>
<button class="icon-btn" onclick="openSettings()" title="Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
@@ -78,12 +78,38 @@
<!-- Mobile sidebar backdrop -->
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
<!-- Message list -->
<!-- Mail view: message list + reading pane (position/density configurable via View menu) -->
<div class="mail-view" id="mail-view">
<div class="message-list-panel">
<div class="panel-header">
<span class="panel-title" id="panel-title">Unified Inbox</span>
<div style="display:flex;align-items:center;gap:6px;min-width:0">
<button class="icon-btn" id="sidebar-expand-btn" onclick="toggleSidebarCollapse()" title="Show sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
</button>
<span class="panel-title" id="panel-title">Unified Inbox</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span class="panel-count" id="panel-count"></span>
<div class="filter-dropdown" id="view-dropdown">
<button class="filter-dropdown-btn" id="view-dropdown-btn" title="View settings" onclick="toggleViewDropdown(event)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<span>View</span>
</button>
<div class="filter-dropdown-menu" id="view-dropdown-menu" style="display:none;min-width:190px">
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Reading pane</div>
<div class="filter-opt" id="vopt-pane-right" onclick="setViewPref('readingPane','right');event.stopPropagation()">✓ Right</div>
<div class="filter-opt" id="vopt-pane-bottom" onclick="setViewPref('readingPane','bottom');event.stopPropagation()">○ Bottom</div>
<div class="filter-sep-line"></div>
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Density</div>
<div class="filter-opt" id="vopt-density-compact" onclick="setViewPref('density','compact');event.stopPropagation()">✓ Compact</div>
<div class="filter-opt" id="vopt-density-comfortable" onclick="setViewPref('density','comfortable');event.stopPropagation()">○ Comfortable</div>
<div class="filter-sep-line"></div>
<div style="padding:6px 12px 2px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Sidebar</div>
<div class="filter-opt" id="vopt-sidebar-expanded" onclick="setViewPref('sidebarMode','expanded');event.stopPropagation()">✓ Pinned (always visible)</div>
<div class="filter-opt" id="vopt-sidebar-collapsed" onclick="setViewPref('sidebarMode','collapsed');event.stopPropagation()">○ Minimized</div>
<div class="filter-opt" id="vopt-sidebar-auto" onclick="setViewPref('sidebarMode','auto');event.stopPropagation()">○ Auto-hide (peek on hover)</div>
</div>
</div>
<div class="filter-dropdown" id="filter-dropdown">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
@@ -103,15 +129,21 @@
</div>
</div>
<div class="search-bar">
<div class="search-wrap">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)">
<div style="display:flex;gap:6px;align-items:center">
<div class="search-wrap" style="flex:1">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)" onkeydown="if(event.key==='Enter')applySearchFilters()">
</div>
<button class="filter-dropdown-btn" id="search-filters-btn" title="Search filters" onclick="toggleSearchFilters(event)" style="flex-shrink:0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
</button>
</div>
</div>
<div class="message-list" id="message-list">
<div class="spinner" style="margin-top:60px"></div>
</div>
</div>
<div class="panel-resize-handle" id="panel-resize-handle" title="Drag to resize"></div>
<!-- Message detail -->
<main class="message-detail" id="message-detail">
@@ -121,6 +153,7 @@
<p>Choose a message from the list to read it</p>
</div>
</main>
</div>
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
@@ -222,24 +255,6 @@
</div>
</div>
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
<div class="accounts-popup" id="accounts-popup">
<div class="accounts-popup-inner">
<div class="accounts-popup-header">
<span>Accounts</span>
<button class="icon-btn" onclick="closeAccountsMenu()" style="margin:-4px -4px -4px 0">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div id="accounts-popup-list"></div>
<button class="accounts-add-btn" onclick="closeAccountsMenu();openAddAccountModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Connect new account
</button>
</div>
</div>
<div class="accounts-popup-backdrop" id="accounts-popup-backdrop" onclick="closeAccountsMenu()"></div>
<!-- ── Draggable Compose dialog ───────────────────────────────────────────── -->
<div class="compose-dialog" id="compose-dialog">
<div class="compose-dialog-header" id="compose-drag-handle">
@@ -250,7 +265,7 @@
</div>
</div>
<div class="compose-body-wrap" id="compose-body-wrap">
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
<div class="compose-field"><label>From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label>BCC</label><div id="compose-bcc-tags" class="tag-container"></div></div>
@@ -303,6 +318,29 @@
</div>
</div>
<!-- ── Inline prompt (replaces browser prompt()) ─────────────────────────── -->
<div class="inline-confirm" id="inline-prompt">
<p id="inline-prompt-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="modal-field">
<input type="text" id="inline-prompt-input"
onkeydown="if(event.key==='Enter'){document.getElementById('inline-prompt-ok').click();}else if(event.key==='Escape'){document.getElementById('inline-prompt-cancel').click();}">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-prompt-cancel">Cancel</button>
<button class="btn-primary" style="font-size:12px" id="inline-prompt-ok">Create</button>
</div>
</div>
<!-- ── Draft close confirm (save / delete / keep editing) ─────────────────── -->
<div class="inline-confirm" id="draft-close-confirm">
<p style="margin:0 0 14px;font-size:13px;line-height:1.5">Save this message as a draft before closing?</p>
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="font-size:12px" id="draft-close-cancel">Keep editing</button>
<button class="btn-danger" style="font-size:12px" id="draft-close-delete">Delete draft</button>
<button class="btn-primary" style="font-size:12px" id="draft-close-save">Save draft</button>
</div>
</div>
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="add-account-modal">
<div class="modal">
@@ -342,18 +380,25 @@
</div>
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
<div style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
<div class="modal-field" style="display:flex;align-items:center;gap:8px;flex-direction:row">
<input type="checkbox" id="use-jmap" onchange="toggleJMAPFields()" style="width:auto;flex:none">
<label for="use-jmap" style="margin:0;font-weight:400">Connect via JMAP instead of IMAP/SMTP</label>
</div>
<div id="imap-hint" style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
Common ports — IMAP: <strong>993</strong> TLS/SSL, <strong>143</strong> STARTTLS/Plain &nbsp;·&nbsp;
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
</div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
<div class="modal-field"><label id="imap-host-label">IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
<div class="modal-field" id="imap-port-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
</div>
<div class="modal-row">
<div class="modal-row" id="smtp-fields">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
</div>
<div class="modal-divider"><span>optional — sync calendar &amp; contacts</span></div>
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="imap-caldav-url" placeholder="https://mail.example.com/dav/calendars/user@example.com/default"></div>
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="imap-carddav-url" placeholder="https://mail.example.com/dav/addressbooks/user@example.com/default"></div>
<div class="test-result" id="test-result"></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
@@ -386,15 +431,19 @@
<div id="edit-creds-section">
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
<div class="modal-field"><label id="edit-imap-host-label">IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field" id="edit-imap-port-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
</div>
<div class="modal-row">
<div class="modal-row" id="edit-smtp-fields">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
</div>
</div>
<div class="settings-group-title" style="margin:16px 0 8px">Calendar &amp; Contacts (optional)</div>
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="edit-caldav-url" placeholder="leave blank to disable"></div>
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="edit-carddav-url" placeholder="leave blank to disable"></div>
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
<div class="modal-field">
<label>Email history to sync</label>
@@ -426,94 +475,286 @@
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
<div class="modal" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px">
<h2 style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
<div class="settings-nav">
<button data-tab="accounts" class="active" onclick="showSettingsTab('accounts')">Accounts</button>
<button data-tab="account" onclick="showSettingsTab('account')">Profile</button>
<button data-tab="rules" onclick="showSettingsTab('rules')">Rules</button>
<button data-tab="signatures" onclick="showSettingsTab('signatures')">Signatures</button>
<button data-tab="certs" onclick="showSettingsTab('certs')">Certificates</button>
</div>
<div style="flex:1;min-width:0;overflow-y:auto;padding:20px 24px">
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
<div class="settings-panel active" data-tab="accounts">
<div class="settings-group">
<div class="settings-group-title">Connected mailboxes</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Manage sync, credentials, CalDAV/CardDAV and per-account settings for each connected mailbox.</div>
<div id="settings-accounts-list"></div>
<button class="accounts-add-btn" onclick="openAddAccountModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Connect new account
</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
<div class="settings-panel" data-tab="account">
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Change Password</div>
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
</div>
<div id="mfa-panel">Loading...</div>
</div>
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
<div class="settings-panel" data-tab="rules">
<div class="settings-group">
<div class="settings-group-title">Rules apply to</div>
<select id="rules-account-select" onchange="loadRules()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
</div>
<div class="settings-group">
<div class="settings-group-title">Add Rule</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">Rules run in priority order (lowest first) against newly-synced mail; the first match wins.</div>
<div class="modal-field"><label>Rule name</label><input type="text" id="rule-name" placeholder="e.g. Invoices to Accounting"></div>
<div style="display:flex;gap:8px">
<div class="modal-field" style="flex:1"><label>Priority</label><input type="number" id="rule-priority" value="0"></div>
<div class="modal-field" style="flex:1"><label>Match</label>
<select id="rule-match-type"><option value="all">ALL of the following (AND)</option><option value="any">ANY of the following (OR)</option></select>
</div>
</div>
<div id="rule-conditions"></div>
<button class="btn-secondary" style="margin-bottom:14px" onclick="addRuleConditionRow()">+ Add condition</button>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
<div class="modal-field" style="flex:1;min-width:140px"><label>Then</label>
<select id="rule-action" onchange="updateRuleActionFields()">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Junk</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
<option value="forward">Forward to...</option>
<option value="auto_reply">Send auto-reply</option>
</select>
</div>
<div class="modal-field" style="flex:1;min-width:160px" id="rule-action-value-field"><label>Folder name</label><input type="text" id="rule-action-value" placeholder="folder name"></div>
</div>
<div class="modal-field" id="rule-autoreply-body-field" style="display:none"><label>Auto-reply body</label><textarea id="rule-autoreply-body" rows="3" style="width:100%"></textarea></div>
<button class="btn-primary" onclick="saveRule()">Add Rule</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Existing Rules</div>
<div id="rules-list"><p style="color:var(--muted);font-size:13px">No rules yet.</p></div>
</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Change Password</div>
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<div class="settings-panel" data-tab="signatures">
<div class="settings-group">
<div class="settings-group-title">Add Signature</div>
<div class="modal-field"><label>Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
<div class="modal-field"><label>Content (HTML)</label><textarea id="sig-content" rows="4" style="width:100%" placeholder="Best regards,&#10;Your Name"></textarea></div>
<button class="btn-primary" onclick="saveSignature()">Add Signature</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Your Signatures</div>
<div id="signatures-list"><p style="color:var(--muted);font-size:13px">No signatures yet.</p></div>
</div>
<div class="settings-group">
<div class="settings-group-title">Defaults per account</div>
<select id="sig-defaults-account-select" onchange="renderSignatureDefaultsForm()" style="width:100%;margin-bottom:10px;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
<div id="sig-defaults-form"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
<div class="settings-panel" data-tab="certs">
<div class="settings-group">
<div class="settings-group-title">Certificates apply to</div>
<select id="certs-account-select" onchange="loadCerts()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
<div style="font-size:12px;color:var(--muted);margin-top:10px">S/MIME certificates <b>sign</b> outgoing mail. PGP keys <b>encrypt</b> it. A message can use either, both, or neither.</div>
</div>
<div class="settings-group">
<div class="settings-group-title">S/MIME — for signing</div>
<div id="smime-identity-list"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
<button class="btn-primary" onclick="smimeGenerate()">Generate Self-Signed Certificate</button>
</div>
<div class="modal-field"><label>Import existing (.p12/.pfx)</label>
<input type="file" id="smime-import-file" accept=".p12,.pfx">
<input type="password" id="smime-import-password" placeholder=".p12 export password (if any)" style="margin-top:6px">
<button class="btn-secondary" style="margin-top:6px" onclick="smimeImport()">Import</button>
</div>
<div class="settings-group-title" style="margin-top:16px;font-size:13px">S/MIME contact certificates</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="email" id="smime-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
<input type="file" id="smime-contact-file" accept=".pem,.crt,.cer">
<button class="btn-secondary" onclick="smimeAddContact()">Add Contact</button>
</div>
<div id="smime-contacts-list"></div>
</div>
<div class="settings-group">
<div class="settings-group-title">PGP — for encryption</div>
<div id="pgp-identity-list"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
<input type="text" id="pgp-gen-label" placeholder="Label (optional)" style="flex:1;min-width:120px">
<input type="password" id="pgp-gen-pass" placeholder="Passphrase (min 8 chars)" style="flex:1;min-width:140px">
<input type="password" id="pgp-gen-pass2" placeholder="Confirm passphrase" style="flex:1;min-width:140px">
<button class="btn-primary" onclick="pgpGenerate()">Generate PGP Key</button>
</div>
<div class="modal-field"><label>Import existing (.asc)</label>
<input type="file" id="pgp-import-file" accept=".asc">
<input type="password" id="pgp-import-pass" placeholder="The key's passphrase" style="margin-top:6px">
<button class="btn-secondary" style="margin-top:6px" onclick="pgpImport()">Import</button>
</div>
<div class="settings-group-title" style="margin-top:16px;font-size:13px">PGP contact keys</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="email" id="pgp-contact-email" placeholder="someone@example.com" style="flex:1;min-width:160px">
<input type="text" id="pgp-contact-label" placeholder="Label (optional)" style="flex:1;min-width:100px">
<input type="file" id="pgp-contact-file" accept=".asc">
<button class="btn-secondary" onclick="pgpAddContact()">Add Contact</button>
</div>
<div id="pgp-contacts-list"></div>
</div>
</div>
<div id="mfa-panel">Loading...</div>
</div>
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
</div>
</div>
<!-- Compose split-button dropdown — fixed-position, JS-placed (see toggleComposeDropdown);
lives outside .sidebar so its overflow:hidden can't clip it -->
<div id="compose-dropdown" style="display:none;position:fixed;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:210;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
<!-- Search filters popover — fixed-position, JS-placed under the search bar (see
toggleSearchFilters); same reasoning as #compose-dropdown above. -->
<div id="search-filters-panel" style="display:none;position:fixed;padding:10px;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.5);z-index:210;max-height:80vh;overflow-y:auto">
<div class="modal-field" style="margin-bottom:8px">
<label>Search in mailbox</label>
<select id="sf-mailbox-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="">All mailboxes</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Search in</label>
<select id="sf-scope" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="all">All fields</option>
<option value="subject">Subject only</option>
<option value="body">Body only</option>
<option value="subject_body">Subject + Body</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Attachment</label>
<select id="sf-attachment" style="width:100%;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
<option value="">Any</option>
<option value="1">Has attachment</option>
<option value="0">No attachment</option>
</select>
</div>
<div class="modal-row" style="margin-bottom:8px;gap:8px">
<div class="modal-field" style="flex:1;margin-bottom:0"><label>From date</label>
<input type="date" id="sf-date-from" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-field" style="flex:1;margin-bottom:0"><label>To date</label>
<input type="date" id="sf-date-to" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
</div>
<div class="modal-field" style="margin-bottom:8px">
<label>Older than (days) <span style="color:var(--muted);font-size:10px">— fills in "To date"</span></label>
<input type="number" id="sf-older-days" min="0" placeholder="e.g. 30" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-row" style="margin-bottom:10px;gap:8px">
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Min size (KB)</label>
<input type="number" id="sf-min-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
<div class="modal-field" style="flex:1;margin-bottom:0"><label>Max size (KB)</label>
<input type="number" id="sf-max-size" min="0" style="width:100%;padding:5px 6px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px">
</div>
</div>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" onclick="clearSearchFilters()">Clear</button>
<button class="btn-primary" style="font-size:12px" onclick="applySearchFilters()">Apply</button>
</div>
</div>
<!-- Context menu -->
<div class="ctx-menu" id="ctx-menu"></div>
<div class="toast-container" id="toast-container"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
<script src="/static/js/app.js?v=67"></script>
<script src="/static/js/contacts_calendar.js?v=67"></script>
{{end}}
+2 -2
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=67">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=58"></script>
<script src="/static/js/gowebmail.js?v=67"></script>
{{block "scripts" .}}{{end}}
</body>
</html>