mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-13 23:30:37 +01:00
layout and sync adjustment
This commit is contained in:
+5
-1
@@ -6,4 +6,8 @@ data/gowebmail.conf
|
||||
data/*.txt
|
||||
gowebmail-devplan.md
|
||||
testrun/
|
||||
webmail.code-workspace
|
||||
webmail.code-workspace
|
||||
|
||||
graphify-out
|
||||
GEMINI.md
|
||||
tests/
|
||||
@@ -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))
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# GoMail Configuration
|
||||
# =====================
|
||||
# Auto-generated and updated on each startup.
|
||||
# Edit freely — your values are always preserved.
|
||||
# Environment variables (or GOMAIL_<KEY>) override values here.
|
||||
#
|
||||
|
||||
# --- Server ---
|
||||
# Public hostname of this GoMail instance (no port, no protocol).
|
||||
# Examples: localhost | mail.example.com | 192.168.1.10
|
||||
# Used to build BASE_URL and OAuth redirect URIs automatically.
|
||||
# Also used in security checks to reject requests with unexpected Host headers.
|
||||
HOSTNAME = localhost
|
||||
|
||||
# Address and port to listen on. Format: [host]:port
|
||||
# :8080 — all interfaces, port 8080
|
||||
# 0.0.0.0:8080 — all interfaces (explicit)
|
||||
# 127.0.0.1:8080 — localhost only
|
||||
LISTEN_ADDR = :8080
|
||||
|
||||
# Public URL of this instance (no trailing slash). Leave blank to auto-build
|
||||
# from HOSTNAME and LISTEN_ADDR port (recommended).
|
||||
# Auto-build examples:
|
||||
# HOSTNAME=localhost + :8080 → http://localhost:8080
|
||||
# HOSTNAME=mail.example.com + :443 → https://mail.example.com
|
||||
# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080
|
||||
# Override here only if you need a custom path prefix or your proxy rewrites the URL.
|
||||
BASE_URL =
|
||||
|
||||
# Set to true when GoMail is served over HTTPS (directly or via proxy).
|
||||
# Marks session cookies as Secure so browsers only send them over TLS.
|
||||
SECURE_COOKIE = false
|
||||
|
||||
# How long a login session lasts, in seconds. Default: 604800 (7 days).
|
||||
SESSION_MAX_AGE = 604800
|
||||
|
||||
# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.
|
||||
# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,
|
||||
# which GoMail uses to determine the real client IP and whether TLS is in use.
|
||||
# Examples:
|
||||
# 127.0.0.1 (loopback only — Nginx/Traefik on same host)
|
||||
# 10.0.0.0/8,172.16.0.0/12 (private networks)
|
||||
# 192.168.1.50,192.168.1.51 (specific IPs)
|
||||
# Leave blank to disable proxy trust (requests are taken at face value).
|
||||
# NOTE: Do not add untrusted IPs — clients could spoof their source address.
|
||||
TRUSTED_PROXIES =
|
||||
|
||||
# --- Storage ---
|
||||
# Path to the SQLite database file.
|
||||
DB_PATH = ./data/gowebmail.db
|
||||
|
||||
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
|
||||
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.
|
||||
# NOTE: Back this up. Losing it makes the entire database permanently unreadable.
|
||||
# openssl rand -hex 32
|
||||
ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97
|
||||
|
||||
# Secret used to sign session cookies. Auto-generated on first run.
|
||||
# Changing this invalidates all active sessions (everyone gets logged out).
|
||||
SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e
|
||||
|
||||
# --- Gmail / Google OAuth2 ---
|
||||
# Create at: https://console.cloud.google.com/apis/credentials
|
||||
# Application type : Web application
|
||||
# Required scope : https://mail.google.com/
|
||||
# Redirect URI : <BASE_URL>/auth/gmail/callback
|
||||
GOOGLE_CLIENT_ID =
|
||||
|
||||
GOOGLE_CLIENT_SECRET =
|
||||
|
||||
# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Google Cloud Console.
|
||||
GOOGLE_REDIRECT_URL =
|
||||
|
||||
# --- Outlook / Microsoft 365 OAuth2 ---
|
||||
# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
|
||||
# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email
|
||||
# Redirect URI : <BASE_URL>/auth/outlook/callback
|
||||
MICROSOFT_CLIENT_ID =
|
||||
|
||||
MICROSOFT_CLIENT_SECRET =
|
||||
|
||||
# Use 'common' to allow any Microsoft account,
|
||||
# or your Azure tenant ID to restrict to one organisation.
|
||||
MICROSOFT_TENANT_ID = common
|
||||
|
||||
# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.
|
||||
# Must exactly match what is registered in Azure.
|
||||
MICROSOFT_REDIRECT_URL =
|
||||
|
||||
@@ -1,17 +1,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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- PGP identities ----
|
||||
// private_key_armor relies on OpenPGP's own native S2K passphrase protection (no app-layer
|
||||
// encryption needed here, unlike smime.go's key_pem) — stored exactly as produced.
|
||||
|
||||
func (d *DB) ListPGPIdentities(accountID int64) ([]models.PGPIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? ORDER BY created_at DESC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPIdentity
|
||||
for rows.Next() {
|
||||
var p models.PGPIdentity
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetPGPIdentity(accountID, id int64) (*models.PGPIdentity, error) {
|
||||
p := &models.PGPIdentity{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
|
||||
FROM pgp_identities WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&p.ID, &p.AccountID, &p.Label, &p.Email, &p.Fingerprint, &p.PublicKeyArmor, &p.PrivateKeyArmor, &p.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (d *DB) CreatePGPIdentity(accountID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_identities (account_id, label, email, fingerprint, public_key_armor, private_key_armor) VALUES (?,?,?,?,?,?)`,
|
||||
accountID, label, email, fingerprint, publicKeyArmor, privateKeyArmor,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- PGP contact public keys (per-user address book) ----
|
||||
|
||||
func (d *DB) ListPGPContacts(userID int64) ([]models.PGPContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PGPContact
|
||||
for rows.Next() {
|
||||
var c models.PGPContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPGPContactByEmail looks up a contact's public key by address. Returns nil, nil if not found.
|
||||
func (d *DB) GetPGPContactByEmail(userID int64, email string) (*models.PGPContact, error) {
|
||||
c := &models.PGPContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, label, fingerprint, public_key_armor, created_at FROM pgp_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.Label, &c.Fingerprint, &c.PublicKeyArmor, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (d *DB) UpsertPGPContact(userID int64, email, label, fingerprint, publicKeyArmor string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO pgp_contacts (user_id, email, label, fingerprint, public_key_armor) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET label=excluded.label, fingerprint=excluded.fingerprint, public_key_armor=excluded.public_key_armor`,
|
||||
userID, email, label, fingerprint, publicKeyArmor,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeletePGPContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pgp_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Rules (filters) ----
|
||||
|
||||
func scanRule(rowConditions, rowActionOptions string, r *models.Rule) {
|
||||
_ = json.Unmarshal([]byte(rowConditions), &r.Conditions)
|
||||
_ = json.Unmarshal([]byte(rowActionOptions), &r.ActionOptions)
|
||||
}
|
||||
|
||||
// ListRules returns all rules for an account, ordered by priority (lowest first, then id).
|
||||
func (d *DB) ListRules(accountID int64) ([]models.Rule, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? ORDER BY priority ASC, id ASC`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Rule
|
||||
for rows.Next() {
|
||||
var r models.Rule
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
if err := rows.Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, &r)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListActiveRules returns only is_active rules for an account, same ordering as ListRules.
|
||||
func (d *DB) ListActiveRules(accountID int64) ([]models.Rule, error) {
|
||||
all, err := d.ListRules(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var active []models.Rule
|
||||
for _, r := range all {
|
||||
if r.IsActive {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// GetRule fetches a single rule scoped to an account (so one user can't touch another's rule by id).
|
||||
func (d *DB) GetRule(accountID, id int64) (*models.Rule, error) {
|
||||
r := &models.Rule{}
|
||||
var conditionsJSON, optionsJSON string
|
||||
var isActive int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, priority, conditions, match_type, action, action_value,
|
||||
action_options, is_active, created_at
|
||||
FROM rules WHERE account_id=? AND id=?`, accountID, id,
|
||||
).Scan(&r.ID, &r.AccountID, &r.Name, &r.Priority, &conditionsJSON, &r.MatchType,
|
||||
&r.Action, &r.ActionValue, &optionsJSON, &isActive, &r.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IsActive = isActive == 1
|
||||
scanRule(conditionsJSON, optionsJSON, r)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CreateRule inserts a new rule and returns its id.
|
||||
func (d *DB) CreateRule(r *models.Rule) (int64, error) {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
if r.MatchType == "" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO rules (account_id, name, priority, conditions, match_type, action, action_value, action_options, is_active)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
r.AccountID, r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateRule replaces an existing rule's fields (scoped to account_id).
|
||||
func (d *DB) UpdateRule(r *models.Rule) error {
|
||||
conditionsJSON, err := json.Marshal(r.Conditions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
optionsJSON, err := json.Marshal(r.ActionOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal action_options: %w", err)
|
||||
}
|
||||
_, err = d.sql.Exec(
|
||||
`UPDATE rules SET name=?, priority=?, conditions=?, match_type=?, action=?, action_value=?, action_options=?, is_active=?
|
||||
WHERE id=? AND account_id=?`,
|
||||
r.Name, r.Priority, string(conditionsJSON), r.MatchType, r.Action, r.ActionValue, string(optionsJSON), boolToInt(r.IsActive),
|
||||
r.ID, r.AccountID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRule removes a rule (scoped to account_id).
|
||||
func (d *DB) DeleteRule(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM rules WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// HasRecentAutoReply reports whether an auto-reply was already sent to recipientEmail
|
||||
// for this rule within the last 24h, to prevent auto-reply loops.
|
||||
func (d *DB) HasRecentAutoReply(accountID, ruleID int64, recipientEmail string) (bool, error) {
|
||||
var n int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM auto_reply_log
|
||||
WHERE account_id=? AND rule_id=? AND recipient_email=? COLLATE NOCASE
|
||||
AND sent_at > datetime('now', '-1 day')`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// LogAutoReply records that an auto-reply was just sent, for HasRecentAutoReply's window check.
|
||||
func (d *DB) LogAutoReply(accountID, ruleID int64, recipientEmail string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO auto_reply_log (account_id, rule_id, recipient_email) VALUES (?,?,?)`,
|
||||
accountID, ruleID, recipientEmail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- Signatures ----
|
||||
|
||||
// ListSignatures returns all signatures owned by a user.
|
||||
func (d *DB) ListSignatures(userID int64) ([]models.Signature, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? ORDER BY name`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Signature
|
||||
for rows.Next() {
|
||||
var s models.Signature
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSignature fetches one signature scoped to its owning user.
|
||||
func (d *DB) GetSignature(userID, id int64) (*models.Signature, error) {
|
||||
s := &models.Signature{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, name, content_html, created_at FROM signatures WHERE user_id=? AND id=?`, userID, id,
|
||||
).Scan(&s.ID, &s.UserID, &s.Name, &s.ContentHTML, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// CreateSignature inserts a new signature and returns its id.
|
||||
func (d *DB) CreateSignature(userID int64, name, contentHTML string) (int64, error) {
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO signatures (user_id, name, content_html) VALUES (?,?,?)`, userID, name, contentHTML,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateSignature updates name/content of a signature (scoped to owner).
|
||||
func (d *DB) UpdateSignature(userID, id int64, name, contentHTML string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`UPDATE signatures SET name=?, content_html=? WHERE id=? AND user_id=?`, name, contentHTML, id, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSignature removes a signature (scoped to owner). Any signature_defaults rows
|
||||
// pointing at it are cleared automatically via ON DELETE SET NULL.
|
||||
func (d *DB) DeleteSignature(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM signatures WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSignatureDefaults returns the default-new/default-reply signature ids for an account.
|
||||
// Returns a zero-value struct (no error) if the account has no defaults row yet.
|
||||
func (d *DB) GetSignatureDefaults(accountID int64) (models.SignatureDefaults, error) {
|
||||
sd := models.SignatureDefaults{AccountID: accountID}
|
||||
var newID, replyID sql.NullInt64
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT default_new_id, default_reply_id FROM signature_defaults WHERE account_id=?`, accountID,
|
||||
).Scan(&newID, &replyID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sd, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sd, err
|
||||
}
|
||||
sd.DefaultNewID = newID.Int64
|
||||
sd.DefaultReplyID = replyID.Int64
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
// SetSignatureDefaults upserts which signature is default-for-new / default-for-reply on an account.
|
||||
// A ProviderID of 0 clears that default (stored as NULL).
|
||||
func (d *DB) SetSignatureDefaults(accountID, defaultNewID, defaultReplyID int64) error {
|
||||
var newVal, replyVal interface{}
|
||||
if defaultNewID > 0 {
|
||||
newVal = defaultNewID
|
||||
}
|
||||
if defaultReplyID > 0 {
|
||||
replyVal = defaultReplyID
|
||||
}
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO signature_defaults (account_id, default_new_id, default_reply_id) VALUES (?,?,?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET default_new_id=excluded.default_new_id, default_reply_id=excluded.default_reply_id`,
|
||||
accountID, newVal, replyVal,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
// ---- S/MIME identities ----
|
||||
// key_pem is encrypted at rest via d.enc (internal/crypto.Encryptor), same as OAuth tokens elsewhere.
|
||||
|
||||
// ListSMIMEIdentities returns all S/MIME identities for an account (key_pem decrypted).
|
||||
func (d *DB) ListSMIMEIdentities(accountID int64) ([]models.SMIMEIdentity, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? ORDER BY created_at DESC`,
|
||||
accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEIdentity
|
||||
for rows.Next() {
|
||||
var s models.SMIMEIdentity
|
||||
var keyEnc string
|
||||
if err := rows.Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEIdentity fetches one S/MIME identity scoped to its account (key_pem decrypted).
|
||||
func (d *DB) GetSMIMEIdentity(accountID, id int64) (*models.SMIMEIdentity, error) {
|
||||
s := &models.SMIMEIdentity{}
|
||||
var keyEnc string
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, cert_pem, key_pem, not_after, created_at FROM smime_identities WHERE account_id=? AND id=?`,
|
||||
accountID, id,
|
||||
).Scan(&s.ID, &s.AccountID, &s.CertPEM, &keyEnc, &s.NotAfter, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.KeyPEM, _ = d.enc.Decrypt(keyEnc)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// CreateSMIMEIdentity encrypts keyPEM at rest and inserts a new identity, returning its id.
|
||||
func (d *DB) CreateSMIMEIdentity(accountID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
|
||||
keyEnc, err := d.enc.Encrypt(keyPEM)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := d.sql.Exec(
|
||||
`INSERT INTO smime_identities (account_id, cert_pem, key_pem, not_after) VALUES (?,?,?,?)`,
|
||||
accountID, certPEM, keyEnc, notAfter,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteSMIMEIdentity removes an identity (scoped to account_id).
|
||||
func (d *DB) DeleteSMIMEIdentity(accountID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_identities WHERE id=? AND account_id=?`, id, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- S/MIME contact certs (per-user address book, unencrypted — public certs only) ----
|
||||
|
||||
func (d *DB) ListSMIMEContacts(userID int64) ([]models.SMIMEContact, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? ORDER BY email`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.SMIMEContact
|
||||
for rows.Next() {
|
||||
var c models.SMIMEContact
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSMIMEContactByEmail looks up a contact's cert by address (used when signer/encryptor
|
||||
// needs to know if a recipient has a cert on file). Returns nil, nil if not found.
|
||||
func (d *DB) GetSMIMEContactByEmail(userID int64, email string) (*models.SMIMEContact, error) {
|
||||
c := &models.SMIMEContact{}
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, user_id, email, cert_pem, created_at FROM smime_contacts WHERE user_id=? AND email=? COLLATE NOCASE`,
|
||||
userID, email,
|
||||
).Scan(&c.ID, &c.UserID, &c.Email, &c.CertPEM, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// UpsertSMIMEContact adds or replaces a contact's cert for an email address.
|
||||
func (d *DB) UpsertSMIMEContact(userID int64, email, certPEM string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO smime_contacts (user_id, email, cert_pem) VALUES (?,?,?)
|
||||
ON CONFLICT(user_id, email) DO UPDATE SET cert_pem=excluded.cert_pem`,
|
||||
userID, email, certPEM,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSMIMEContact removes a contact cert (scoped to owner).
|
||||
func (d *DB) DeleteSMIMEContact(userID, id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM smime_contacts WHERE id=? AND user_id=?`, id, userID)
|
||||
return err
|
||||
}
|
||||
+273
-40
@@ -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)
|
||||
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/internal/mfa"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -30,6 +31,7 @@ type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
renderer *Renderer
|
||||
syncer interface{ TriggerReconcile() }
|
||||
pgpCache *pgp.Cache
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
@@ -101,6 +103,9 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent())
|
||||
}
|
||||
h.db.DeleteSession(cookie.Value)
|
||||
if h.pgpCache != nil {
|
||||
h.pgpCache.ClearSession(cookie.Value)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "gomail_session", Value: "", MaxAge: -1, Path: "/",
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
"github.com/ghostersk/gowebmail/internal/smime"
|
||||
)
|
||||
|
||||
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
|
||||
// PGP contact keys the sending account/user actually has on file. Implements
|
||||
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
|
||||
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
|
||||
// design: "S/MIME certificates sign... PGP keys encrypt...".
|
||||
type dbSigner struct {
|
||||
db *db.DB
|
||||
userID int64
|
||||
}
|
||||
|
||||
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
|
||||
out := raw
|
||||
|
||||
identities, err := s.db.ListSMIMEIdentities(account.ID)
|
||||
if err == nil && len(identities) > 0 {
|
||||
id := identities[0]
|
||||
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smime sign: %w", err)
|
||||
}
|
||||
out = signed
|
||||
}
|
||||
|
||||
if len(recipients) > 0 {
|
||||
var pgpEntities []*openpgp.Entity
|
||||
allHaveKeys := true
|
||||
for _, addr := range recipients {
|
||||
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
|
||||
if err != nil || contact == nil {
|
||||
allHaveKeys = false
|
||||
break
|
||||
}
|
||||
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
|
||||
if err != nil {
|
||||
allHaveKeys = false
|
||||
break
|
||||
}
|
||||
pgpEntities = append(pgpEntities, entity)
|
||||
}
|
||||
if allHaveKeys && len(pgpEntities) > 0 {
|
||||
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgp encrypt: %w", err)
|
||||
}
|
||||
out = encrypted
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
|
||||
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
|
||||
func (h *APIHandler) newSigner(userID int64) *dbSigner {
|
||||
return &dbSigner{db: h.db, userID: userID}
|
||||
}
|
||||
|
||||
// ---- S/MIME handlers ----
|
||||
|
||||
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identities, err := h.db.ListSMIMEIdentities(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, identities)
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
return
|
||||
}
|
||||
if !h.ownAccount(w, r, req.AccountID) {
|
||||
return
|
||||
}
|
||||
account, _ := h.db.GetAccount(req.AccountID)
|
||||
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
|
||||
return
|
||||
}
|
||||
cert, _ := smime.ParseCertPEM(certPEM)
|
||||
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
||||
accountID = a
|
||||
}
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("p12_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "p12_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
password := r.FormValue("p12_password")
|
||||
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
||||
return
|
||||
}
|
||||
cert, _ := smime.ParseCertPEM(certPEM)
|
||||
notAfter := time.Now().Add(smime.DefaultValidity)
|
||||
if cert != nil {
|
||||
notAfter = cert.NotAfter
|
||||
}
|
||||
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
contacts, err := h.db.ListSMIMEContacts(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, contacts)
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "email required")
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("cert_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "cert_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
if _, err := smime.ParseCertPEM(data); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// ---- PGP handlers ----
|
||||
|
||||
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identities, err := h.db.ListPGPIdentities(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, identities)
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Label string `json:"label"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Confirm string `json:"passphrase_confirm"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
return
|
||||
}
|
||||
if !h.ownAccount(w, r, req.AccountID) {
|
||||
return
|
||||
}
|
||||
if len(req.Passphrase) < 8 {
|
||||
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if req.Passphrase != req.Confirm {
|
||||
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
|
||||
return
|
||||
}
|
||||
account, _ := h.db.GetAccount(req.AccountID)
|
||||
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
|
||||
return
|
||||
}
|
||||
entity, _ := pgp.ParsePublicKey(pubArmor)
|
||||
fingerprint := ""
|
||||
if entity != nil {
|
||||
fingerprint = pgp.Fingerprint(entity)
|
||||
}
|
||||
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
||||
accountID = a
|
||||
}
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
passphrase := r.FormValue("passphrase")
|
||||
label := r.FormValue("label")
|
||||
file, _, err := r.FormFile("key_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "key_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
||||
return
|
||||
}
|
||||
entity, _ := pgp.ParsePublicKey(pubArmor)
|
||||
email, fingerprint := "", ""
|
||||
if entity != nil {
|
||||
fingerprint = pgp.Fingerprint(entity)
|
||||
for name := range entity.Identities {
|
||||
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
|
||||
email = id.UserId.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
account, _ := h.db.GetAccount(accountID)
|
||||
if email == "" && account != nil {
|
||||
email = account.EmailAddress
|
||||
}
|
||||
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
|
||||
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
|
||||
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
|
||||
// logout (AuthHandler.Logout).
|
||||
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IdentityID int64 `json:"identity_id"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "identity_id required")
|
||||
return
|
||||
}
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
|
||||
if err != nil || identity == nil {
|
||||
h.writeError(w, http.StatusNotFound, "identity not found")
|
||||
return
|
||||
}
|
||||
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
|
||||
return
|
||||
}
|
||||
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
|
||||
return
|
||||
}
|
||||
if h.pgpCache != nil {
|
||||
if cookie, err := r.Cookie("gomail_session"); err == nil {
|
||||
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
contacts, err := h.db.ListPGPContacts(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, contacts)
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "email required")
|
||||
return
|
||||
}
|
||||
label := r.FormValue("label")
|
||||
file, _, err := r.FormFile("key_file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "key_file required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
entity, err := pgp.ParsePublicKey(data)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeletePGPContact(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/pgp"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,14 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
|
||||
log.Fatalf("failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
// Shared unlocked-PGP-key cache: populated by APIHandler.PGPUnlock, cleared by
|
||||
// AuthHandler.Logout. No TTL — memory-bounded by active sessions (see internal/pgp.Cache).
|
||||
pgpCache := pgp.NewCache()
|
||||
|
||||
return &Handlers{
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc, pgpCache: pgpCache},
|
||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc, pgpCache: pgpCache},
|
||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
var validRuleFields = map[string]bool{
|
||||
"from": true, "to": true, "subject": true, "body": true, "has_attachment": true, "recipient_type": true,
|
||||
}
|
||||
var validRuleOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||
var validRuleActions = map[string]bool{
|
||||
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
|
||||
}
|
||||
|
||||
// ownAccount verifies accountID belongs to the current user, writing a 404 and returning false if not.
|
||||
func (h *APIHandler) ownAccount(w http.ResponseWriter, r *http.Request, accountID int64) bool {
|
||||
userID := middleware.GetUserID(r)
|
||||
account, err := h.db.GetAccount(accountID)
|
||||
if err != nil || account == nil || account.UserID != userID {
|
||||
h.writeError(w, http.StatusNotFound, "account not found")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Rules ----
|
||||
|
||||
func (h *APIHandler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
rules, err := h.db.ListRules(accountID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list rules")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, rules)
|
||||
}
|
||||
|
||||
func validateRule(r *models.Rule) string {
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
return "name required"
|
||||
}
|
||||
if len(r.Conditions) == 0 {
|
||||
return "at least one condition required"
|
||||
}
|
||||
for _, c := range r.Conditions {
|
||||
if !validRuleFields[c.Field] {
|
||||
return "invalid condition field: " + c.Field
|
||||
}
|
||||
if !validRuleOps[c.Op] {
|
||||
return "invalid condition op: " + c.Op
|
||||
}
|
||||
if strings.TrimSpace(c.Value) == "" {
|
||||
return "condition value required"
|
||||
}
|
||||
}
|
||||
if r.MatchType != "any" {
|
||||
r.MatchType = "all"
|
||||
}
|
||||
if !validRuleActions[r.Action] {
|
||||
return "invalid action: " + r.Action
|
||||
}
|
||||
if r.Action == "move_to_folder" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "folder name required for move_to_folder"
|
||||
}
|
||||
if r.Action == "forward" && !strings.Contains(r.ActionValue, "@") {
|
||||
return "valid forward address required"
|
||||
}
|
||||
if r.Action == "auto_reply" && strings.TrimSpace(r.ActionValue) == "" {
|
||||
return "auto-reply subject required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateRule(&req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
var req models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || !h.ownAccount(w, r, req.AccountID) {
|
||||
if req.AccountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetRule(req.AccountID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if msg := validateRule(&req); msg != "" {
|
||||
h.writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.db.UpdateRule(&req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
accountID := queryInt64(r, "account_id", 0)
|
||||
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteRule(accountID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete rule")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
)
|
||||
|
||||
func (h *APIHandler) ListSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
sigs, err := h.db.ListSignatures(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list signatures")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, sigs)
|
||||
}
|
||||
|
||||
func (h *APIHandler) CreateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
id, err := h.db.CreateSignature(userID, req.Name, req.ContentHTML)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
existing, err := h.db.GetSignature(userID, id)
|
||||
if err != nil || existing == nil {
|
||||
h.writeError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
if err := h.db.UpdateSignature(userID, id, req.Name, req.ContentHTML); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
id := pathInt64(r, "id")
|
||||
if err := h.db.DeleteSignature(userID, id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete signature")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// SetSignatureDefaults sets which signature (if any) is default-for-new / default-for-reply
|
||||
// on one account. A ProviderID of 0 in the request clears that default.
|
||||
func (h *APIHandler) SetSignatureDefaults(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := pathInt64(r, "id")
|
||||
if !h.ownAccount(w, r, accountID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DefaultNewID int64 `json:"default_new_id"`
|
||||
DefaultReplyID int64 `json:"default_reply_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
// A signature id of 0 is "clear this default" — otherwise verify the user actually owns it.
|
||||
userID := middleware.GetUserID(r)
|
||||
if req.DefaultNewID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultNewID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_new_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.DefaultReplyID > 0 {
|
||||
if s, err := h.db.GetSignature(userID, req.DefaultReplyID); err != nil || s == nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid default_reply_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.db.SetSignatureDefaults(accountID, req.DefaultNewID, req.DefaultReplyID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to set defaults")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Package jmap is a minimal JMAP (RFC 8620 Core + RFC 8621 Mail) client for
|
||||
// ProviderJMAP accounts — an alternative to IMAP/SMTP for mail servers that
|
||||
// speak JMAP instead. It follows internal/graph's shape (a thin REST/JSON
|
||||
// wrapper), since both are HTTP+JSON providers unlike IMAP's binary protocol.
|
||||
//
|
||||
// Authenticated via HTTP Basic (mailbox email + app password), matching the
|
||||
// reference server this was built against — see tests/jmap-client.md.
|
||||
// One HTTP call per JMAP method call: no request batching or back-references,
|
||||
// since sync here isn't latency-sensitive enough to justify that complexity.
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps JMAP API calls for a single mailbox account.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
accountID string // resolved lazily from /jmap/session
|
||||
apiURL string
|
||||
uploadURL string
|
||||
}
|
||||
|
||||
// New creates a JMAP client. baseURL is the server's base URL, e.g.
|
||||
// "https://mail.example.com:8443" (no trailing slash needed).
|
||||
func New(baseURL, username, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
username: username,
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
// Force HTTP/1.1: the reference server (tests/jmap-client.md)
|
||||
// closes the connection with no response over HTTP/2 — verified
|
||||
// live (curl negotiates h2 by default and gets a broken pipe;
|
||||
// --http1.1 works). TLSNextProto disables Go's automatic h2 ALPN
|
||||
// upgrade for HTTPS requests.
|
||||
Transport: &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doReq(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
url := path
|
||||
if !strings.HasPrefix(path, "http") {
|
||||
url = c.baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("jmap %s %s returned %d: %s", method, path, resp.StatusCode, string(b))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Session is the RFC 8620 §2 session resource.
|
||||
type Session struct {
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
Username string `json:"username"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// Session fetches /jmap/session and resolves the mail account id + API/upload
|
||||
// URLs. Also serves as a pure connectivity/auth test (used by TestConnection).
|
||||
func (c *Client) Session(ctx context.Context) (*Session, error) {
|
||||
resp, err := c.doReq(ctx, http.MethodGet, "/jmap/session", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var s Session
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("decode jmap session: %w", err)
|
||||
}
|
||||
c.accountID = s.PrimaryAccounts["urn:ietf:params:jmap:mail"]
|
||||
if c.accountID == "" {
|
||||
return nil, fmt.Errorf("jmap session: no mail account found")
|
||||
}
|
||||
c.apiURL = s.APIURL
|
||||
c.uploadURL = strings.ReplaceAll(s.UploadURL, "{accountId}", c.accountID)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureSession(ctx context.Context) error {
|
||||
if c.accountID != "" {
|
||||
return nil
|
||||
}
|
||||
_, err := c.Session(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
type apiRequest struct {
|
||||
Using []string `json:"using"`
|
||||
MethodCalls [][3]interface{} `json:"methodCalls"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
MethodResponses [][]json.RawMessage `json:"methodResponses"`
|
||||
}
|
||||
|
||||
// call makes a single JMAP method call and decodes its result args into out
|
||||
// (which may be nil if the caller doesn't need the response body).
|
||||
func (c *Client) call(ctx context.Context, method string, args map[string]interface{}, out interface{}) error {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
body := apiRequest{
|
||||
Using: []string{
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
},
|
||||
MethodCalls: [][3]interface{}{{method, args, "c1"}},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.apiURL, bytes.NewReader(b), "application/json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var ar apiResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
|
||||
return fmt.Errorf("decode jmap response: %w", err)
|
||||
}
|
||||
if len(ar.MethodResponses) == 0 || len(ar.MethodResponses[0]) < 2 {
|
||||
return fmt.Errorf("jmap %s: empty or malformed response", method)
|
||||
}
|
||||
first := ar.MethodResponses[0]
|
||||
var name string
|
||||
json.Unmarshal(first[0], &name)
|
||||
if name == "error" {
|
||||
return fmt.Errorf("jmap %s error: %s", method, string(first[1]))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(first[1], out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) withAccount(args map[string]interface{}) map[string]interface{} {
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
args["accountId"] = c.accountID
|
||||
return args
|
||||
}
|
||||
|
||||
// ---- Mailboxes ----
|
||||
|
||||
// Mailbox is a JMAP folder.
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parentId"`
|
||||
Role string `json:"role"` // "inbox","sent","drafts","trash","junk", or "" for custom folders
|
||||
TotalEmails int `json:"totalEmails"`
|
||||
UnreadEmails int `json:"unreadEmails"`
|
||||
}
|
||||
|
||||
// InferFolderType maps a JMAP Mailbox role to gowebmail's folder type.
|
||||
func InferFolderType(role string) string {
|
||||
switch role {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "trash":
|
||||
return "trash"
|
||||
case "junk":
|
||||
return "spam"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// ListMailboxes returns every mailbox (folder) for the account.
|
||||
func (c *Client) ListMailboxes(ctx context.Context) ([]Mailbox, error) {
|
||||
var out struct {
|
||||
List []Mailbox `json:"list"`
|
||||
}
|
||||
if err := c.call(ctx, "Mailbox/get", c.withAccount(nil), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// FindMailboxByRole returns the id of the mailbox with the given role (e.g.
|
||||
// "sent", "inbox"), or an error if none is found.
|
||||
func (c *Client) FindMailboxByRole(ctx context.Context, role string) (string, error) {
|
||||
boxes, err := c.ListMailboxes(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range boxes {
|
||||
if b.Role == role {
|
||||
return b.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no mailbox with role %q", role)
|
||||
}
|
||||
|
||||
// ---- Emails ----
|
||||
|
||||
// EmailAddr is a JMAP EmailAddress object.
|
||||
type EmailAddr struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// BodyPart is an entry in an Email's textBody/htmlBody list.
|
||||
type BodyPart struct {
|
||||
PartID string `json:"partId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// BodyValue is the decoded content for one BodyPart, keyed by partId in Email.BodyValues.
|
||||
type BodyValue struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Email is a JMAP message. Keywords/mailboxIds mirror IMAP flags/folder
|
||||
// membership, except a message here lives in exactly one mailbox (see
|
||||
// tests/jmap-client.md — "single-mailbox membership").
|
||||
type Email struct {
|
||||
ID string `json:"id"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
Keywords map[string]bool `json:"keywords"`
|
||||
Size int `json:"size"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Subject string `json:"subject"`
|
||||
From []EmailAddr `json:"from"`
|
||||
To []EmailAddr `json:"to"`
|
||||
Preview string `json:"preview"`
|
||||
HasAttachment bool `json:"hasAttachment"`
|
||||
TextBody []BodyPart `json:"textBody"`
|
||||
HTMLBody []BodyPart `json:"htmlBody"`
|
||||
BodyValues map[string]BodyValue `json:"bodyValues"`
|
||||
}
|
||||
|
||||
func (e *Email) FromName() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Name
|
||||
}
|
||||
|
||||
func (e *Email) FromEmail() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Email
|
||||
}
|
||||
|
||||
func (e *Email) ToList() string {
|
||||
parts := make([]string, 0, len(e.To))
|
||||
for _, t := range e.To {
|
||||
parts = append(parts, t.Email)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (e *Email) IsRead() bool { return e.Keywords["$seen"] }
|
||||
func (e *Email) IsFlagged() bool { return e.Keywords["$flagged"] }
|
||||
|
||||
// TextValue returns the plain-text body, if fetched via GetEmailBody.
|
||||
func (e *Email) TextValue() string {
|
||||
for _, p := range e.TextBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HTMLValue returns the HTML body, if fetched via GetEmailBody.
|
||||
func (e *Email) HTMLValue() string {
|
||||
for _, p := range e.HTMLBody {
|
||||
if bv, ok := e.BodyValues[p.PartID]; ok {
|
||||
return bv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListEmails returns cheap-field emails in mailboxID. Newest-first order is
|
||||
// not guaranteed (the reference server's Email/query sort support is
|
||||
// undocumented — see tests/jmap-client.md — so no sort is requested; callers
|
||||
// that need a specific order should sort client-side).
|
||||
func (c *Client) ListEmails(ctx context.Context, mailboxID string, limit int) ([]Email, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var qout struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
qargs := c.withAccount(map[string]interface{}{
|
||||
"filter": map[string]string{"inMailbox": mailboxID},
|
||||
"limit": limit,
|
||||
})
|
||||
if err := c.call(ctx, "Email/query", qargs, &qout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qout.IDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return c.GetEmails(ctx, qout.IDs, false)
|
||||
}
|
||||
|
||||
// GetEmails fetches full Email objects for ids. withBody also fetches
|
||||
// text/html body content (an expensive decrypt+MIME-parse server-side).
|
||||
func (c *Client) GetEmails(ctx context.Context, ids []string, withBody bool) ([]Email, error) {
|
||||
var out struct {
|
||||
List []Email `json:"list"`
|
||||
}
|
||||
args := c.withAccount(map[string]interface{}{"ids": ids})
|
||||
if withBody {
|
||||
args["fetchTextBodyValues"] = true
|
||||
args["fetchHTMLBodyValues"] = true
|
||||
}
|
||||
if err := c.call(ctx, "Email/get", args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.List, nil
|
||||
}
|
||||
|
||||
// GetEmailBody fetches a single email with its full text/html body.
|
||||
func (c *Client) GetEmailBody(ctx context.Context, id string) (*Email, error) {
|
||||
list, err := c.GetEmails(ctx, []string{id}, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("email %s not found", id)
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// SetKeyword sets or clears a single keyword (e.g. "$seen", "$flagged") on a message.
|
||||
func (c *Client) SetKeyword(ctx context.Context, emailID, keyword string, on bool) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"keywords/" + keyword: on},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap keyword update rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveEmail reassigns a message to a different (single) mailbox.
|
||||
func (c *Client) MoveEmail(ctx context.Context, emailID, destMailboxID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
emailID: map[string]interface{}{"mailboxIds": map[string]bool{destMailboxID: true}},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotUpdated map[string]json.RawMessage `json:"notUpdated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotUpdated[emailID]; bad {
|
||||
return fmt.Errorf("jmap move rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEmail hard-deletes a message. Unlike Mailbox/set destroy (soft, see
|
||||
// tests/jmap-client.md), Email/set destroy is a real, unrecoverable delete.
|
||||
func (c *Client) DeleteEmail(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{"destroy": []string{emailID}})
|
||||
var out struct {
|
||||
NotDestroyed map[string]json.RawMessage `json:"notDestroyed"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotDestroyed[emailID]; bad {
|
||||
return fmt.Errorf("jmap delete rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Sending ----
|
||||
|
||||
// UploadBlob uploads raw message bytes and returns the blob id.
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte) (string, error) {
|
||||
if err := c.ensureSession(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.doReq(ctx, http.MethodPost, c.uploadURL, bytes.NewReader(data), "message/rfc822")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
BlobID string `json:"blobId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("decode jmap upload response: %w", err)
|
||||
}
|
||||
return out.BlobID, nil
|
||||
}
|
||||
|
||||
// ImportEmail imports an uploaded blob as a message into mailboxID, returning
|
||||
// the new email id. There is no Email/set create (see tests/jmap-client.md) —
|
||||
// this upload+import step is the only way to add a message.
|
||||
func (c *Client) ImportEmail(ctx context.Context, blobID, mailboxID string) (string, error) {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"emails": map[string]interface{}{
|
||||
"c1": map[string]interface{}{
|
||||
"blobId": blobID,
|
||||
"mailboxIds": map[string]bool{mailboxID: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
Created map[string]struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"created"`
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "Email/import", args, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created, ok := out.Created["c1"]; ok {
|
||||
return created.ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("jmap import failed: %s", out.NotCreated["c1"])
|
||||
}
|
||||
|
||||
// Submit sends a previously-imported message via EmailSubmission/set.
|
||||
func (c *Client) Submit(ctx context.Context, emailID string) error {
|
||||
args := c.withAccount(map[string]interface{}{
|
||||
"create": map[string]interface{}{
|
||||
"s1": map[string]interface{}{"emailId": emailID},
|
||||
},
|
||||
})
|
||||
var out struct {
|
||||
NotCreated map[string]json.RawMessage `json:"notCreated"`
|
||||
}
|
||||
if err := c.call(ctx, "EmailSubmission/set", args, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if e, bad := out.NotCreated["s1"]; bad {
|
||||
return fmt.Errorf("jmap submission rejected: %s", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send uploads rawMessage, imports it into mailboxID (typically the Sent
|
||||
// mailbox — the server doesn't auto-file after submission), and submits it
|
||||
// for delivery.
|
||||
func (c *Client) Send(ctx context.Context, mailboxID string, rawMessage []byte) error {
|
||||
blobID, err := c.UploadBlob(ctx, rawMessage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap upload: %w", err)
|
||||
}
|
||||
emailID, err := c.ImportEmail(ctx, blobID, mailboxID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jmap import: %w", err)
|
||||
}
|
||||
if err := c.Submit(ctx, emailID); err != nil {
|
||||
return fmt.Errorf("jmap submit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+105
-1
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
// Cache holds unlocked (passphrase-decrypted) PGP identities in memory, scoped to the
|
||||
// session that unlocked them — never written to disk. No TTL: memory-bounded by active
|
||||
// sessions, cleared only on explicit logout (see internal/handlers/auth.go Logout).
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
byTok map[string]map[int64]*openpgp.Entity // sessionToken -> identityID -> unlocked entity
|
||||
}
|
||||
|
||||
// NewCache creates an empty unlocked-key cache.
|
||||
func NewCache() *Cache {
|
||||
return &Cache{byTok: make(map[string]map[int64]*openpgp.Entity)}
|
||||
}
|
||||
|
||||
// Get returns the unlocked entity for identityID under sessionToken, if present.
|
||||
func (c *Cache) Get(sessionToken string, identityID int64) (*openpgp.Entity, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
e, ok := m[identityID]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Put stores an unlocked entity under sessionToken.
|
||||
func (c *Cache) Put(sessionToken string, identityID int64, entity *openpgp.Entity) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.byTok[sessionToken]
|
||||
if !ok {
|
||||
m = make(map[int64]*openpgp.Entity)
|
||||
c.byTok[sessionToken] = m
|
||||
}
|
||||
m[identityID] = entity
|
||||
}
|
||||
|
||||
// ClearSession discards every unlocked identity for a session (call on logout).
|
||||
func (c *Cache) ClearSession(sessionToken string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.byTok, sessionToken)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestEncryptMIMERoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("frank@example.com", "hunter2hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte(
|
||||
"Message-ID: <1.frank.example.com@example.com>\r\n" +
|
||||
"From: Frank <frank@example.com>\r\n" +
|
||||
"To: grace@example.com\r\n" +
|
||||
"Subject: Secret\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: quoted-printable\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Grace! This is secret.\r\n")
|
||||
|
||||
encryptedMsg, err := EncryptMIME(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptMIME: %v", err)
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(encryptedMsg))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Secret" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive encryption)", got, "Secret")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/encrypted" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/encrypted", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
if _, err := mr.NextPart(); err != nil { // control part: application/pgp-encrypted, Version: 1
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
armored, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if err := UnlockPrivateKey(privEntity, "hunter2hunter2"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(armored, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
want := "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello, Grace! This is secret.\r\n"
|
||||
if string(decrypted) != want {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
|
||||
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
|
||||
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
|
||||
// "unsafe by design". This package is encryption-only: no PGP signature generation
|
||||
// or verification (S/MIME, internal/smime, handles signing).
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
func defaultConfig() *packet.Config {
|
||||
return &packet.Config{
|
||||
DefaultCipher: packet.CipherAES256, // library default is AES-128
|
||||
RSABits: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
|
||||
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
|
||||
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
|
||||
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
config := defaultConfig()
|
||||
entity, err := openpgp.NewEntity(email, "", email, config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate entity: %w", err)
|
||||
}
|
||||
if err := lockEntity(entity, passphrase); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor, err = serializePrivate(entity, config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
func lockEntity(entity *openpgp.Entity, passphrase string) error {
|
||||
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock primary key: %w", err)
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey == nil {
|
||||
continue
|
||||
}
|
||||
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("lock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := entity.Serialize(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
|
||||
// which requires the (now-encrypted) private key and fails once it's locked.
|
||||
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
|
||||
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
|
||||
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
||||
entity, err := ParsePrivateKey(armoredData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Verify the passphrase actually unlocks it before accepting the import.
|
||||
if err := UnlockPrivateKey(entity, passphrase); err != nil {
|
||||
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
|
||||
}
|
||||
publicArmor, err = serializePublic(entity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateArmor = armoredData
|
||||
return publicArmor, privateArmor, nil
|
||||
}
|
||||
|
||||
// ParsePublicKey reads a single armored public key.
|
||||
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
// ParsePrivateKey reads a single armored private key. The key remains locked
|
||||
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
|
||||
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
|
||||
return parseEntity(armoredData)
|
||||
}
|
||||
|
||||
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
|
||||
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return nil, fmt.Errorf("no key found in armored data")
|
||||
}
|
||||
return entities[0], nil
|
||||
}
|
||||
|
||||
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
|
||||
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
|
||||
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock primary key: %w", err)
|
||||
}
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
||||
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return fmt.Errorf("unlock subkey: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
|
||||
func Fingerprint(entity *openpgp.Entity) string {
|
||||
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
|
||||
}
|
||||
|
||||
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
|
||||
// recipients' public keys.
|
||||
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
if _, err := pt.Write(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pt.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := aw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptEntity opens an armored PGP message using an already-unlocked identity
|
||||
// (see UnlockPrivateKey).
|
||||
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
|
||||
block, err := armor.Decode(bytes.NewReader(armored))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode armor: %w", err)
|
||||
}
|
||||
keyring := openpgp.EntityList{unlockedIdentity}
|
||||
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
return io.ReadAll(md.UnverifiedBody)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
|
||||
|
||||
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
|
||||
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
|
||||
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
|
||||
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
|
||||
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
|
||||
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
|
||||
// what was encrypted regardless of a trailing CRLF.
|
||||
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted, err := EncryptEntity(entity, recipients)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
|
||||
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
|
||||
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
|
||||
out.Write(encrypted)
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
|
||||
// stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
|
||||
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
|
||||
// Encoding/Content-Disposition headers plus blank line plus body.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package pgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
)
|
||||
|
||||
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
|
||||
pubArmor, privArmor, err := GenerateKeyPair("carol@example.com", "correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
|
||||
pubEntity, err := ParsePublicKey(pubArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("the secret message body")
|
||||
encrypted, err := EncryptEntity(raw, []*openpgp.Entity{pubEntity})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptEntity: %v", err)
|
||||
}
|
||||
|
||||
privEntity, err := ParsePrivateKey(privArmor)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if !privEntity.PrivateKey.Encrypted {
|
||||
t.Fatal("private key should be Encrypted (passphrase-protected) before unlocking")
|
||||
}
|
||||
|
||||
// Wrong passphrase must fail.
|
||||
if err := UnlockPrivateKey(privEntity, "wrong-passphrase"); err == nil {
|
||||
t.Error("UnlockPrivateKey succeeded with wrong passphrase, want error")
|
||||
}
|
||||
|
||||
if err := UnlockPrivateKey(privEntity, "correct-horse-battery-staple"); err != nil {
|
||||
t.Fatalf("UnlockPrivateKey: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptEntity(encrypted, privEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptEntity: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("DecryptEntity() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
c := NewCache()
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected empty cache miss")
|
||||
}
|
||||
e := &openpgp.Entity{}
|
||||
c.Put("tok1", 1, e)
|
||||
got, ok := c.Get("tok1", 1)
|
||||
if !ok || got != e {
|
||||
t.Fatal("expected cache hit for tok1/1")
|
||||
}
|
||||
c.ClearSession("tok1")
|
||||
if _, ok := c.Get("tok1", 1); ok {
|
||||
t.Fatal("expected cache miss after ClearSession")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package rules implements mail-filter matching: given a message and an account's
|
||||
// active rules (already ordered by priority), find the first rule that matches.
|
||||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
// Condition is one field/op/value test. Mirrors models.RuleCondition but this package
|
||||
// stays free of the models/db dependency so Match is trivially unit-testable.
|
||||
type Condition struct {
|
||||
Field string
|
||||
Op string
|
||||
Value string
|
||||
}
|
||||
|
||||
// MessageFields is the subset of a message's data rules can match against.
|
||||
type MessageFields struct {
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
HasAttachment bool
|
||||
RecipientType string // "to" | "cc" | "bcc"
|
||||
}
|
||||
|
||||
// Rule is one filter: conditions (AND'd or OR'd per MatchType) plus an action.
|
||||
type Rule struct {
|
||||
ID int64
|
||||
Priority int
|
||||
Conditions []Condition
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
Action string
|
||||
ActionValue string
|
||||
ActionOptions map[string]any
|
||||
}
|
||||
|
||||
// Match returns the first rule (by priority, ascending) whose conditions match msg,
|
||||
// or nil if none match. Callers must pass rules pre-filtered to is_active and pre-sorted
|
||||
// by priority ascending (ListActiveRules already does this).
|
||||
func Match(msg MessageFields, activeRules []Rule) *Rule {
|
||||
for i := range activeRules {
|
||||
if ruleMatches(&activeRules[i], msg) {
|
||||
return &activeRules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ruleMatches(r *Rule, msg MessageFields) bool {
|
||||
if len(r.Conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
if r.MatchType == "any" {
|
||||
for _, c := range r.Conditions {
|
||||
if conditionMatches(c, msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// default "all" (AND)
|
||||
for _, c := range r.Conditions {
|
||||
if !conditionMatches(c, msg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionMatches(c Condition, msg MessageFields) bool {
|
||||
var target string
|
||||
switch c.Field {
|
||||
case "from":
|
||||
target = msg.From
|
||||
case "to":
|
||||
target = msg.To
|
||||
case "subject":
|
||||
target = msg.Subject
|
||||
case "body":
|
||||
target = msg.Body
|
||||
case "has_attachment":
|
||||
if msg.HasAttachment {
|
||||
target = "yes"
|
||||
} else {
|
||||
target = "no"
|
||||
}
|
||||
case "recipient_type":
|
||||
target = msg.RecipientType
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return matchOp(c.Op, c.Value, target)
|
||||
}
|
||||
|
||||
func matchOp(op, value, target string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
target = strings.ToLower(target)
|
||||
switch op {
|
||||
case "contains":
|
||||
return value != "" && strings.Contains(target, value)
|
||||
case "equals":
|
||||
return target == value
|
||||
case "starts_with":
|
||||
return value != "" && strings.HasPrefix(target, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
msg := MessageFields{
|
||||
From: "boss@work.com",
|
||||
To: "me@example.com",
|
||||
Subject: "Weekly Report Due",
|
||||
Body: "please see attached",
|
||||
HasAttachment: true,
|
||||
RecipientType: "to",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rules []Rule
|
||||
want string // expected matched rule action value marker, "" for no match
|
||||
}{
|
||||
{
|
||||
name: "single contains condition matches",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "work.com"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "equals is case-insensitive and exact",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "to", Op: "equals", Value: "ME@EXAMPLE.COM"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "starts_with no match",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "subject", Op: "starts_with", Value: "URGENT"}}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type all requires every condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "work.com"},
|
||||
{Field: "subject", Op: "contains", Value: "NOPE"},
|
||||
}},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "match_type any needs only one condition",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "any", ActionValue: "hit", Conditions: []Condition{
|
||||
{Field: "from", Op: "contains", Value: "NOPE"},
|
||||
{Field: "subject", Op: "contains", Value: "Report"},
|
||||
}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "has_attachment field",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 0, MatchType: "all", ActionValue: "hit",
|
||||
Conditions: []Condition{{Field: "has_attachment", Op: "equals", Value: "yes"}}},
|
||||
},
|
||||
want: "hit",
|
||||
},
|
||||
{
|
||||
name: "first matching rule in list order wins, later matching rules ignored",
|
||||
rules: []Rule{
|
||||
{ID: 1, Priority: 5, MatchType: "all", ActionValue: "nope",
|
||||
Conditions: []Condition{{Field: "from", Op: "contains", Value: "does-not-appear"}}},
|
||||
{ID: 2, Priority: 0, MatchType: "all", ActionValue: "first",
|
||||
Conditions: []Condition{{Field: "to", Op: "contains", Value: "example"}}},
|
||||
{ID: 3, Priority: 10, MatchType: "all", ActionValue: "second",
|
||||
Conditions: []Condition{{Field: "subject", Op: "contains", Value: "Report"}}},
|
||||
},
|
||||
want: "first", // Match trusts caller ordering (ListActiveRules sorts by priority ASC before calling)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Match(msg, tc.rules)
|
||||
gotVal := ""
|
||||
if got != nil {
|
||||
gotVal = got.ActionValue
|
||||
}
|
||||
if gotVal != tc.want {
|
||||
t.Errorf("Match() = %q, want %q", gotVal, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignMIMERoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("dave@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
// CTE deliberately "7bit", not "quoted-printable": Go's mime/multipart.Part.Read
|
||||
// auto-decodes quoted-printable/base64 parts, which would make this test compare
|
||||
// decoded bytes against the raw wire bytes that were actually signed — a test-harness
|
||||
// footgun, not a production concern (a spec-compliant S/MIME verifier signs/checks the
|
||||
// encoded wire octets, never the decoded form).
|
||||
raw := []byte(
|
||||
"Message-ID: <1.dave.example.com@example.com>\r\n" +
|
||||
"From: Dave <dave@example.com>\r\n" +
|
||||
"To: eve@example.com\r\n" +
|
||||
"Subject: Hello\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: 7bit\r\n" +
|
||||
"\r\n" +
|
||||
"Hello, Eve!\r\n")
|
||||
|
||||
signed, err := SignMIME(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("SignMIME: %v", err)
|
||||
}
|
||||
|
||||
// Parse it back like a real mail client would: read top-level headers, find the
|
||||
// multipart/signed boundary, split into the two parts, and verify.
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(signed))
|
||||
if err != nil {
|
||||
t.Fatalf("mail.ReadMessage: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Subject"); got != "Hello" {
|
||||
t.Errorf("Subject header = %q, want %q (top-level headers must survive signing)", got, "Hello")
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/signed" {
|
||||
t.Fatalf("Content-Type = %q, want multipart/signed", mediaType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
part1, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("first part: %v", err)
|
||||
}
|
||||
part1Headers := "Content-Type: " + part1.Header.Get("Content-Type") + "\r\n"
|
||||
if cte := part1.Header.Get("Content-Transfer-Encoding"); cte != "" {
|
||||
part1Headers += "Content-Transfer-Encoding: " + cte + "\r\n"
|
||||
}
|
||||
part1Body, err := io.ReadAll(part1)
|
||||
if err != nil {
|
||||
t.Fatalf("read first part: %v", err)
|
||||
}
|
||||
entity := append([]byte(part1Headers+"\r\n"), part1Body...)
|
||||
|
||||
part2, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Fatalf("second part: %v", err)
|
||||
}
|
||||
sigB64, err := io.ReadAll(part2)
|
||||
if err != nil {
|
||||
t.Fatalf("read second part: %v", err)
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigB64)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature base64: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(entity, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.EmailAddresses[0] != "dave@example.com" {
|
||||
t.Errorf("signer = %v, want dave@example.com", signer.EmailAddresses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package smime provides S/MIME certificate generation, signing, and encryption
|
||||
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
|
||||
//
|
||||
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
|
||||
// signature matches the given certificate, not that the certificate is trusted by any
|
||||
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
|
||||
// that want a "known sender" UI hint should compare against the user's own S/MIME
|
||||
// contact address book, not treat a successful Verify as proof of identity.
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
|
||||
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
|
||||
}
|
||||
|
||||
// DefaultValidity is the lifetime used for a freshly self-signed identity.
|
||||
const DefaultValidity = 365 * 24 * time.Hour
|
||||
|
||||
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
|
||||
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: email},
|
||||
EmailAddresses: []string{email},
|
||||
NotBefore: time.Now().Add(-5 * time.Minute),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
|
||||
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
|
||||
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
|
||||
key, cert, err := pkcs12.Decode(data, password)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decode p12: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
|
||||
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid certificate PEM")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
|
||||
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid key PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
|
||||
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sd, err := pkcs7.NewSignedData(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new signed data: %w", err)
|
||||
}
|
||||
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
|
||||
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
|
||||
return nil, fmt.Errorf("add signer: %w", err)
|
||||
}
|
||||
sd.Detach()
|
||||
return sd.Finish()
|
||||
}
|
||||
|
||||
// VerifySigned checks a detached signature against the original content and returns the
|
||||
// signer's certificate. It does NOT validate the certificate against any trust store —
|
||||
// see the package doc comment.
|
||||
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
|
||||
p7, err := pkcs7.Parse(signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse signature: %w", err)
|
||||
}
|
||||
p7.Content = raw
|
||||
if err := p7.Verify(); err != nil {
|
||||
return nil, fmt.Errorf("verify: %w", err)
|
||||
}
|
||||
signer := p7.GetOnlySigner()
|
||||
if signer == nil {
|
||||
return nil, errors.New("no signer certificate found in signature")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
|
||||
// smime-type=enveloped-data) for the given recipient certificates.
|
||||
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
|
||||
return pkcs7.Encrypt(raw, recipients)
|
||||
}
|
||||
|
||||
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
|
||||
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p7, err := pkcs7.Parse(enveloped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse enveloped data: %w", err)
|
||||
}
|
||||
return p7.Decrypt(cert, key)
|
||||
}
|
||||
|
||||
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
|
||||
//
|
||||
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
|
||||
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
|
||||
// above only handle the CMS blob itself.
|
||||
|
||||
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
|
||||
// original message's Content-Type + body become the first part, and a detached CMS
|
||||
// signature over that part becomes the second. All other top-level headers (From, To,
|
||||
// Subject, Date, Message-ID, ...) are preserved unchanged.
|
||||
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
|
||||
topLines, entity, err := splitMIMEEntity(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
|
||||
// the delimiter, not the signed content — a compliant multipart parser hands back the
|
||||
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
|
||||
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
|
||||
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
|
||||
sig, err := Sign(certPEM, keyPEM, signedContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
|
||||
|
||||
var out bytes.Buffer
|
||||
for _, l := range topLines {
|
||||
out.WriteString(l + "\r\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
out.WriteString("--" + boundary + "\r\n")
|
||||
out.Write(signedContent)
|
||||
out.WriteString("\r\n--" + boundary + "\r\n")
|
||||
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
|
||||
out.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
|
||||
out.WriteString(base64Wrap(sig))
|
||||
out.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
||||
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
|
||||
// not stay behind as a stray top-level header of the wrapper message.
|
||||
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
||||
|
||||
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
|
||||
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
|
||||
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
|
||||
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
|
||||
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("no header/body separator found in message")
|
||||
}
|
||||
headerBlock := string(raw[:idx])
|
||||
body := raw[idx+4:]
|
||||
rest := strings.Split(headerBlock, "\r\n")
|
||||
|
||||
var entityLines []string
|
||||
for _, name := range entityHeaderNames {
|
||||
var val string
|
||||
val, rest = extractHeader(rest, name)
|
||||
if val != "" {
|
||||
entityLines = append(entityLines, val)
|
||||
}
|
||||
}
|
||||
if len(entityLines) == 0 {
|
||||
return nil, nil, errors.New("no Content-Type header found in message")
|
||||
}
|
||||
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
||||
return rest, entity, nil
|
||||
}
|
||||
|
||||
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
||||
// returning its full value and the remaining lines with it removed.
|
||||
func extractHeader(lines []string, name string) (value string, rest []string) {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
||||
value = l
|
||||
j := i + 1
|
||||
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
||||
value += "\r\n" + lines[j]
|
||||
j++
|
||||
}
|
||||
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
||||
return value, rest
|
||||
}
|
||||
}
|
||||
return "", lines
|
||||
}
|
||||
|
||||
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
|
||||
func base64Wrap(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
out.WriteString(encoded[i:end])
|
||||
if end < len(encoded) {
|
||||
out.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("alice@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("this is the raw MIME message body")
|
||||
sig, err := Sign(certPEM, keyPEM, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
signer, err := VerifySigned(raw, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if len(signer.EmailAddresses) == 0 || signer.EmailAddresses[0] != "alice@example.com" {
|
||||
t.Errorf("signer email = %v, want [alice@example.com]", signer.EmailAddresses)
|
||||
}
|
||||
|
||||
// Tamper one byte of the content — verification must fail.
|
||||
tampered := bytes.Clone(raw)
|
||||
tampered[0] ^= 0xFF
|
||||
if _, err := VerifySigned(tampered, sig); err == nil {
|
||||
t.Error("VerifySigned succeeded against tampered content, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
certPEM, keyPEM, err := GenerateSelfSigned("bob@example.com", DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertPEM: %v", err)
|
||||
}
|
||||
|
||||
raw := []byte("secret message body")
|
||||
enveloped, err := Encrypt(raw, []*x509.Certificate{cert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(enveloped, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, raw) {
|
||||
t.Errorf("Decrypt() = %q, want %q", decrypted, raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/rules"
|
||||
)
|
||||
|
||||
// matchRule evaluates a message against an account's active rules (as loaded from the DB)
|
||||
// and returns the matching models.Rule (with full action data), or nil if none match.
|
||||
func matchRule(msg *models.Message, accountEmail string, activeRules []models.Rule) *models.Rule {
|
||||
if len(activeRules) == 0 {
|
||||
return nil
|
||||
}
|
||||
engineRules := make([]rules.Rule, 0, len(activeRules))
|
||||
for _, r := range activeRules {
|
||||
conds := make([]rules.Condition, 0, len(r.Conditions))
|
||||
for _, c := range r.Conditions {
|
||||
conds = append(conds, rules.Condition{Field: c.Field, Op: c.Op, Value: c.Value})
|
||||
}
|
||||
engineRules = append(engineRules, rules.Rule{
|
||||
ID: r.ID, Priority: r.Priority, Conditions: conds, MatchType: r.MatchType,
|
||||
Action: r.Action, ActionValue: r.ActionValue,
|
||||
})
|
||||
}
|
||||
mf := rules.MessageFields{
|
||||
From: msg.FromEmail, To: msg.ToList, Subject: msg.Subject, Body: msg.BodyText,
|
||||
HasAttachment: msg.HasAttachment, RecipientType: recipientType(msg, accountEmail),
|
||||
}
|
||||
matched := rules.Match(mf, engineRules)
|
||||
if matched == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range activeRules {
|
||||
if activeRules[i].ID == matched.ID {
|
||||
return &activeRules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recipientType(msg *models.Message, accountEmail string) string {
|
||||
if msg.CCList != "" && containsAddress(msg.CCList, accountEmail) {
|
||||
return "cc"
|
||||
}
|
||||
if msg.BCCList != "" && containsAddress(msg.BCCList, accountEmail) {
|
||||
return "bcc"
|
||||
}
|
||||
return "to"
|
||||
}
|
||||
|
||||
func containsAddress(list, addr string) bool {
|
||||
// list is comma-separated; a substring check is enough since we only use this
|
||||
// to pick a synthetic recipient_type label, not for anything security-relevant.
|
||||
for _, part := range splitAndTrim(list) {
|
||||
if part == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == ',' {
|
||||
out = append(out, trimLower(cur))
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(r)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, trimLower(cur))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimLower(s string) string {
|
||||
// minimal trim, avoids pulling in strings just for this
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func parseUID(s string) uint32 {
|
||||
var uid uint32
|
||||
fmt.Sscanf(s, "%d", &uid)
|
||||
return uid
|
||||
}
|
||||
|
||||
// ---- IMAP path ----
|
||||
|
||||
func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) {
|
||||
uid := parseUID(msg.RemoteUID)
|
||||
switch rule.Action {
|
||||
case "move_to_folder":
|
||||
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
|
||||
if err != nil || dest == nil {
|
||||
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
|
||||
return
|
||||
}
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: dest.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "mark_as_spam":
|
||||
junk, err := s.db.GetFolderByType(account.ID, "spam")
|
||||
if err != nil || junk == nil {
|
||||
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
|
||||
return
|
||||
}
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "delete":
|
||||
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "delete", RemoteUID: uid, FolderPath: dbFolder.FullPath})
|
||||
s.TriggerAccountSync(account.ID)
|
||||
case "mark_read":
|
||||
if err := c.SetFlagByUID(dbFolder.FullPath, uid, `\Seen`, true); err != nil {
|
||||
log.Printf("[rules] mark_read: %v", err)
|
||||
}
|
||||
case "forward":
|
||||
s.ruleForwardIMAP(account, msg, rule.ActionValue)
|
||||
case "auto_reply":
|
||||
s.ruleAutoReplyIMAP(account, msg, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleForwardIMAP(account *models.EmailAccount, msg *models.Message, to string) {
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{to},
|
||||
Subject: "Fwd: " + msg.Subject,
|
||||
BodyHTML: msg.BodyHTML,
|
||||
BodyText: msg.BodyText,
|
||||
}
|
||||
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
|
||||
log.Printf("[rules] forward to %s: %v", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleAutoReplyIMAP(account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
recipient := msg.FromEmail
|
||||
if recipient == "" {
|
||||
return // never reply to a bounce/empty sender — avoids loops
|
||||
}
|
||||
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
|
||||
return
|
||||
}
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{recipient},
|
||||
Subject: rule.ActionValue,
|
||||
BodyText: rule.ActionOptions.Body,
|
||||
BodyHTML: rule.ActionOptions.Body,
|
||||
}
|
||||
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
|
||||
log.Printf("[rules] auto_reply to %s: %v", recipient, err)
|
||||
return
|
||||
}
|
||||
s.db.LogAutoReply(account.ID, rule.ID, recipient)
|
||||
}
|
||||
|
||||
// ---- Graph (personal Outlook.com) path ----
|
||||
// msg.RemoteUID already holds the opaque Graph message ID (set at construction in graphDeltaSync).
|
||||
|
||||
func (s *Scheduler) applyRuleGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
ctx := context.Background()
|
||||
switch rule.Action {
|
||||
case "move_to_folder":
|
||||
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
|
||||
if err != nil || dest == nil {
|
||||
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
|
||||
return
|
||||
}
|
||||
if err := gc.MoveMessage(ctx, msg.RemoteUID, dest.FullPath); err != nil {
|
||||
log.Printf("[rules] graph move: %v", err)
|
||||
}
|
||||
case "mark_as_spam":
|
||||
junk, err := s.db.GetFolderByType(account.ID, "spam")
|
||||
if err != nil || junk == nil {
|
||||
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
|
||||
return
|
||||
}
|
||||
if err := gc.MoveMessage(ctx, msg.RemoteUID, junk.FullPath); err != nil {
|
||||
log.Printf("[rules] graph move: %v", err)
|
||||
}
|
||||
case "delete":
|
||||
if err := gc.DeleteMessage(ctx, msg.RemoteUID); err != nil {
|
||||
log.Printf("[rules] graph delete: %v", err)
|
||||
}
|
||||
case "mark_read":
|
||||
if err := gc.MarkRead(ctx, msg.RemoteUID, true); err != nil {
|
||||
log.Printf("[rules] graph mark_read: %v", err)
|
||||
}
|
||||
case "forward":
|
||||
s.ruleForwardGraph(gc, account, msg, rule.ActionValue)
|
||||
case "auto_reply":
|
||||
s.ruleAutoReplyGraph(gc, account, msg, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleForwardGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, to string) {
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{to},
|
||||
Subject: "Fwd: " + msg.Subject,
|
||||
BodyHTML: msg.BodyHTML,
|
||||
BodyText: msg.BodyText,
|
||||
}
|
||||
if err := gc.SendMail(context.Background(), req); err != nil {
|
||||
log.Printf("[rules] graph forward to %s: %v", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ruleAutoReplyGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
|
||||
recipient := msg.FromEmail
|
||||
if recipient == "" {
|
||||
return
|
||||
}
|
||||
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
|
||||
return
|
||||
}
|
||||
req := &models.ComposeRequest{
|
||||
AccountID: account.ID,
|
||||
To: []string{recipient},
|
||||
Subject: rule.ActionValue,
|
||||
BodyText: rule.ActionOptions.Body,
|
||||
BodyHTML: rule.ActionOptions.Body,
|
||||
}
|
||||
if err := gc.SendMail(context.Background(), req); err != nil {
|
||||
log.Printf("[rules] graph auto_reply to %s: %v", recipient, err)
|
||||
return
|
||||
}
|
||||
s.db.LogAutoReply(account.ID, rule.ID, recipient)
|
||||
}
|
||||
+271
-4
@@ -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
@@ -47,13 +47,16 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
|
||||
z-index:100;display:flex;align-items:center;justify-content:center;
|
||||
opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
.modal-overlay.open{opacity:1;pointer-events:all}
|
||||
/* Account add/edit modals open from inside the Settings modal and must stack above it,
|
||||
regardless of DOM order, so Settings stays visible (and reachable) underneath. */
|
||||
#add-account-modal,#edit-account-modal{z-index:110}
|
||||
.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2);
|
||||
border:1px solid var(--border2);border-radius:14px;padding:26px;
|
||||
border:1px solid var(--border2);border-radius:10px;padding:22px;
|
||||
transform:scale(.95);transition:transform .2s}
|
||||
.modal-overlay.open .modal{transform:scale(1)}
|
||||
.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px}
|
||||
.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px}
|
||||
.modal-field{margin-bottom:12px}
|
||||
.modal h2{font-family:'DM Serif Display',serif;font-size:19px;font-weight:400;margin-bottom:6px}
|
||||
.modal > p{font-size:13px;color:var(--muted);margin-bottom:16px}
|
||||
.modal-field{margin-bottom:10px}
|
||||
.modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase;
|
||||
letter-spacing:.8px;color:var(--muted);margin-bottom:5px}
|
||||
.modal-field input,.modal-field select,.modal-field textarea{
|
||||
@@ -140,11 +143,57 @@ body.auth-page{display:flex;align-items:center;justify-content:center;min-height
|
||||
body.app-page{overflow:hidden}
|
||||
.app{display:flex;height:100vh}
|
||||
|
||||
/* Mail view wrapper (list + detail) — lets the reading-pane position be
|
||||
flipped from the right (default) to the bottom without touching the
|
||||
sidebar column. */
|
||||
.mail-view{display:flex;flex:1;min-width:0;overflow:hidden}
|
||||
@media (min-width:701px){
|
||||
#app-root[data-reading-pane="bottom"] .mail-view{flex-direction:column}
|
||||
#app-root[data-reading-pane="bottom"] .mail-view .message-list-panel{
|
||||
width:100%;height:38%;min-height:160px;border-right:none;border-bottom:1px solid var(--border)}
|
||||
#app-root[data-reading-pane="bottom"] .mail-view .message-detail{flex:1;min-height:0}
|
||||
}
|
||||
|
||||
/* Drag handle between the message list and reading pane — desktop only (mobile
|
||||
switches full-screen between the two, there's nothing to split). Direction
|
||||
flips with reading-pane position; size is persisted via uiPrefs (server-side,
|
||||
not a cookie, so it follows the user across browsers/devices). */
|
||||
.panel-resize-handle{display:none}
|
||||
@media (min-width:701px){
|
||||
.panel-resize-handle{display:block;flex-shrink:0;width:5px;cursor:col-resize;
|
||||
background:transparent;position:relative;z-index:5}
|
||||
.panel-resize-handle::after{content:'';position:absolute;top:0;bottom:0;left:1px;right:1px;
|
||||
background:var(--border2);transition:background .15s}
|
||||
.panel-resize-handle:hover::after,.panel-resize-handle.dragging::after{background:var(--accent)}
|
||||
#app-root[data-reading-pane="bottom"] .panel-resize-handle{width:100%;height:5px;cursor:row-resize}
|
||||
#app-root[data-reading-pane="bottom"] .panel-resize-handle::after{top:1px;bottom:1px;left:0;right:0}
|
||||
}
|
||||
|
||||
/* Sidebar collapse / auto-hide (desktop only — mobile keeps its own drawer below).
|
||||
#sidebar-expand-btn lives inline in .panel-header (before the folder name) so it
|
||||
never overlaps content — it's only shown while the sidebar itself is hidden. */
|
||||
#sidebar-expand-btn{display:none}
|
||||
@media (min-width:701px){
|
||||
#app-root[data-sidebar="collapsed"] .sidebar,
|
||||
#app-root[data-sidebar="auto"] .sidebar{width:0;min-width:0;border-right:none;padding:0}
|
||||
#app-root[data-sidebar="auto"] .sidebar{
|
||||
position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);
|
||||
transform:translateX(-100%);transition:transform .15s ease;
|
||||
z-index:60;box-shadow:4px 0 24px rgba(0,0,0,.4);border-right:1px solid var(--border)}
|
||||
#app-root[data-sidebar="collapsed"] #sidebar-expand-btn,
|
||||
#app-root[data-sidebar="auto"] #sidebar-expand-btn{display:flex}
|
||||
/* Auto-hide: peek the sidebar in as an overlay while hovering the expand button
|
||||
or the sidebar itself (once revealed), pure CSS via :has() — no JS timers. */
|
||||
#app-root[data-sidebar="auto"]:has(#sidebar-expand-btn:hover) .sidebar,
|
||||
#app-root[data-sidebar="auto"] .sidebar:hover{transform:translateX(0)}
|
||||
}
|
||||
.sidebar-collapse-btn{flex-shrink:0}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface);
|
||||
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.sidebar-header{padding:12px 14px 10px;border-bottom:1px solid var(--border);
|
||||
display:flex;flex-direction:column}
|
||||
.sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)}
|
||||
.logo{display:flex;align-items:center;gap:8px}
|
||||
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
|
||||
@@ -156,17 +205,20 @@ body.app-page{overflow:hidden}
|
||||
.compose-btn:hover{opacity:.85}
|
||||
/* ── Account dot (still used in popup) */
|
||||
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
|
||||
.nav-section{padding:3px 6px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;
|
||||
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
|
||||
.nav-item:hover{background:var(--surface3);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.nav-item svg{width:15px;height:15px;flex-shrink:0}
|
||||
.nav-item svg{width:14px;height:14px;flex-shrink:0}
|
||||
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
||||
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
||||
.folder-count-group{margin-left:auto;display:flex;align-items:center;gap:2px;flex-shrink:0}
|
||||
.folder-count-group .unread-badge{margin-left:0}
|
||||
.folder-total-count{font-size:9px;color:var(--muted);font-weight:400}
|
||||
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
|
||||
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
|
||||
color:var(--muted);padding:8px 8px 2px;display:flex;align-items:center;gap:6px;
|
||||
cursor:pointer;user-select:none;border-radius:5px;transition:background .15s}
|
||||
.nav-folder-header:hover{background:var(--surface3)}
|
||||
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
|
||||
.acc-drag-handle:hover{opacity:1}
|
||||
@@ -174,7 +226,7 @@ body.app-page{overflow:hidden}
|
||||
.nav-account-group{border-radius:6px;transition:background .15s}
|
||||
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
|
||||
.nav-account-group.acc-dragging{opacity:.4}
|
||||
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
||||
.sidebar-footer{padding:8px 12px;border-top:1px solid var(--border);display:flex;
|
||||
align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.user-name{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -183,11 +235,11 @@ body.app-page{overflow:hidden}
|
||||
/* Message list panel */
|
||||
.message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;background:var(--surface)}
|
||||
.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border);
|
||||
.panel-header{padding:10px 12px 8px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.panel-title{font-family:'DM Serif Display',serif;font-size:17px}
|
||||
.panel-title{font-family:'DM Serif Display',serif;font-size:16px}
|
||||
.panel-count{font-size:12px;color:var(--muted)}
|
||||
.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.search-bar{padding:6px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.search-wrap{position:relative}
|
||||
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);
|
||||
width:13px;height:13px;fill:var(--muted);pointer-events:none}
|
||||
@@ -197,28 +249,43 @@ body.app-page{overflow:hidden}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--muted)}
|
||||
.message-list{flex:1;overflow-y:auto}
|
||||
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item{padding:6px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item:hover{background:var(--surface2)}
|
||||
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
|
||||
/* Unread: lighter background + bold sender so it pops clearly */
|
||||
.message-item.unread{background:rgba(255,255,255,.035)}
|
||||
.message-item.unread:hover{background:rgba(255,255,255,.055)}
|
||||
.message-item.unread .msg-from{color:var(--text);font-weight:600}
|
||||
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
|
||||
/* Read messages: everything dimmed down so unread has something to stand out against */
|
||||
.msg-from{font-size:13px;font-weight:500;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
/* Unread: accent-tinted background + a solid dot + bold bright sender/subject + left bar,
|
||||
so it reads as unread at a glance instead of only on close inspection. */
|
||||
.message-item.unread{background:rgba(91,141,239,.07)}
|
||||
.message-item.unread:hover{background:rgba(91,141,239,.12)}
|
||||
.message-item.unread .msg-from{color:var(--text);font-weight:700}
|
||||
.message-item.unread .msg-subject{font-weight:700;color:var(--text)}
|
||||
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
|
||||
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.message-item.unread.active{background:var(--accent-dim)}
|
||||
.message-item.unread.active::before{display:none}
|
||||
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
|
||||
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.msg-unread-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:transparent}
|
||||
.message-item.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
|
||||
/* Compact 2-line row (default): sender+date, then subject–preview with trailing icons */
|
||||
.msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px}
|
||||
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
|
||||
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
|
||||
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
|
||||
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.msg-acct{font-size:10px;color:var(--muted)}
|
||||
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
|
||||
.msg-line2{display:flex;align-items:center;gap:6px}
|
||||
.msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4}
|
||||
.msg-subject{color:var(--text2)}
|
||||
.msg-preview{color:var(--muted)}
|
||||
.msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||
.msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0}
|
||||
.msg-size{font-size:10px;color:var(--muted)}
|
||||
.msg-star{color:var(--muted);font-size:11px;cursor:pointer}
|
||||
.msg-star.on{color:var(--star)}
|
||||
|
||||
/* Comfortable density (opt-in via #app-root[data-density="comfortable"]): restores the
|
||||
roomier 4-line row with account email and larger padding. */
|
||||
#app-root[data-density="comfortable"] .message-item{padding:10px 12px}
|
||||
#app-root[data-density="comfortable"] .msg-top{margin-bottom:2px}
|
||||
#app-root[data-density="comfortable"] .msg-line2{flex-wrap:wrap}
|
||||
#app-root[data-density="comfortable"] .msg-text{white-space:normal;font-size:12px;flex-basis:100%}
|
||||
#app-root[data-density="comfortable"] .msg-icons{margin-left:auto;margin-top:2px}
|
||||
.load-more{padding:10px;text-align:center}
|
||||
.load-more-btn{background:none;border:1px solid var(--border2);color:var(--accent);
|
||||
padding:6px 18px;border-radius:6px;cursor:pointer;font-size:12px;transition:background .15s}
|
||||
@@ -235,19 +302,19 @@ body.app-page{overflow:hidden}
|
||||
.no-message svg{width:48px;height:48px;fill:var(--border2)}
|
||||
.no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)}
|
||||
.no-message p{font-size:13px}
|
||||
.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px}
|
||||
.detail-header{padding:12px 20px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.detail-subject{font-family:'DM Serif Display',serif;font-size:18px;margin-bottom:8px}
|
||||
.detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
||||
.detail-from{font-size:13px}
|
||||
.detail-from strong{color:var(--text)}
|
||||
.detail-from span{color:var(--muted);font-size:12px}
|
||||
.detail-date{font-size:12px;color:var(--muted);flex-shrink:0}
|
||||
.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
|
||||
.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;
|
||||
.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
|
||||
.action-btn{padding:4px 10px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;
|
||||
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s}
|
||||
.action-btn:hover{background:var(--surface3);color:var(--text)}
|
||||
.action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)}
|
||||
.detail-body{flex:1;overflow-y:auto;padding:20px}
|
||||
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
|
||||
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
|
||||
.detail-body iframe{width:100%;border:none;min-height:400px}
|
||||
|
||||
@@ -256,13 +323,13 @@ body.app-page{overflow:hidden}
|
||||
position:fixed;bottom:20px;right:24px;
|
||||
width:540px;height:480px;
|
||||
background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
border-radius:8px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
display:none;flex-direction:column;z-index:200;
|
||||
min-width:360px;min-height:280px;overflow:hidden;
|
||||
user-select:none;
|
||||
}
|
||||
.compose-dialog-header{
|
||||
padding:10px 12px 10px 16px;border-bottom:1px solid var(--border);
|
||||
padding:8px 10px 8px 14px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
cursor:grab;flex-shrink:0;background:var(--surface2);
|
||||
}
|
||||
@@ -272,12 +339,12 @@ body.app-page{overflow:hidden}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
|
||||
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
|
||||
.compose-close:hover{background:var(--surface3);color:var(--text)}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:6px 14px;gap:10px;flex-shrink:0}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:5px 12px;gap:10px;flex-shrink:0}
|
||||
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
|
||||
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
|
||||
.compose-field select option{background:var(--surface2)}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.compose-footer{padding:6px 12px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.send-btn:hover{opacity:.85}
|
||||
@@ -337,7 +404,16 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
padding:22px 24px;margin-bottom:20px}
|
||||
.admin-card h3{font-size:14px;font-weight:500;margin-bottom:4px}
|
||||
.admin-card .card-desc{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||
.settings-group{margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.settings-nav{width:160px;flex-shrink:0;padding:12px 8px;border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;gap:1px}
|
||||
.settings-nav button{display:block;width:100%;text-align:left;padding:7px 10px;border:none;
|
||||
background:transparent;color:var(--text2);border-radius:5px;cursor:pointer;font-family:'DM Sans',sans-serif;
|
||||
font-size:13px;transition:background .1s}
|
||||
.settings-nav button:hover{background:var(--surface3);color:var(--text)}
|
||||
.settings-nav button.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.settings-panel{display:none}
|
||||
.settings-panel.active{display:block}
|
||||
.settings-group{margin-bottom:18px;padding-bottom:18px;border-bottom:1px solid var(--border)}
|
||||
.settings-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.settings-group-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;
|
||||
color:var(--accent);margin-bottom:14px}
|
||||
@@ -354,11 +430,11 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.setting-control input[type=password]{font-family:monospace;letter-spacing:.1em}
|
||||
|
||||
/* ---- Rich text compose editor ---- */
|
||||
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
|
||||
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
|
||||
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
|
||||
.fmt-btn:hover{background:var(--border2);color:var(--text)}
|
||||
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
|
||||
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
|
||||
.compose-editor{flex:1;overflow-y:auto;padding:10px 12px;
|
||||
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
|
||||
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
|
||||
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
|
||||
@@ -400,25 +476,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
|
||||
font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
|
||||
|
||||
/* ── Accounts popup ──────────────────────────────────────────── */
|
||||
.accounts-popup{
|
||||
position:fixed;bottom:52px;left:8px;
|
||||
width:300px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);
|
||||
z-index:300;display:none;flex-direction:column;overflow:hidden;
|
||||
}
|
||||
.accounts-popup.open{display:flex}
|
||||
.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299}
|
||||
.accounts-popup-backdrop.open{display:block}
|
||||
.accounts-popup-inner{padding:12px}
|
||||
.accounts-popup-header{display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;
|
||||
color:var(--muted);margin-bottom:8px}
|
||||
.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px;
|
||||
transition:background .1s}
|
||||
.acct-popup-item:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px;
|
||||
margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
/* ── Settings: connected-accounts list (Accounts tab) ──────────── */
|
||||
.acct-row{display:flex;align-items:center;gap:8px;padding:9px 8px;border-radius:6px;
|
||||
transition:background .1s;border-bottom:1px solid var(--border)}
|
||||
.acct-row:last-child{border-bottom:none}
|
||||
.acct-row:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;padding:9px 6px;
|
||||
margin-top:10px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
|
||||
transition:background .1s}
|
||||
.accounts-add-btn:hover{background:var(--accent-dim)}
|
||||
@@ -548,6 +612,8 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
|
||||
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
|
||||
.sidebar-header .compose-btn{display:none}
|
||||
/* Desktop-only sidebar collapse control — mobile already has the drawer/hamburger */
|
||||
.sidebar-collapse-btn{display:none}
|
||||
|
||||
/* Message list panel: full width, shown/hidden by data-mob-view */
|
||||
.message-list-panel{width:100%;border-right:none;flex-shrink:0}
|
||||
|
||||
+765
-94
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,12 @@ function _setView(view) {
|
||||
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
|
||||
document.getElementById(id)?.classList.remove('active');
|
||||
});
|
||||
// Show/hide panels
|
||||
const mail1 = document.getElementById('message-list-panel');
|
||||
const mail2 = document.getElementById('message-detail');
|
||||
// Show/hide panels — mail-view wraps the message list + reading pane together
|
||||
// so they hide/show as one unit rather than two separately-toggled panels.
|
||||
const mailView = document.getElementById('mail-view');
|
||||
const contacts = document.getElementById('contacts-panel');
|
||||
const calendar = document.getElementById('calendar-panel');
|
||||
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
|
||||
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
|
||||
if (mailView) mailView.style.display = view === 'mail' ? '' : 'none';
|
||||
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
|
||||
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & 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 ·
|
||||
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
<div class="modal-field"><label id="imap-host-label">IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field" id="imap-port-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-row" id="smtp-fields">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
|
||||
</div>
|
||||
<div class="modal-divider"><span>optional — sync calendar & contacts</span></div>
|
||||
<div class="modal-field"><label>CalDAV URL</label><input type="text" id="imap-caldav-url" placeholder="https://mail.example.com/dav/calendars/user@example.com/default"></div>
|
||||
<div class="modal-field"><label>CardDAV URL</label><input type="text" id="imap-carddav-url" placeholder="https://mail.example.com/dav/addressbooks/user@example.com/default"></div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
|
||||
@@ -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 & 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, 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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user