update
This commit is contained in:
@@ -5,7 +5,12 @@
|
|||||||
"Bash(command -v staticcheck)",
|
"Bash(command -v staticcheck)",
|
||||||
"Bash(go build *)",
|
"Bash(go build *)",
|
||||||
"Bash(go vet *)",
|
"Bash(go vet *)",
|
||||||
"Bash(go test *)"
|
"Bash(go test *)",
|
||||||
|
"Bash(timeout 5 bash -c \"echo > /dev/udp/1.1.1.1/53\")",
|
||||||
|
"Bash(go run *)",
|
||||||
|
"Bash(graphify update *)",
|
||||||
|
"Bash(graphify query *)",
|
||||||
|
"Bash(python3 -)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -172,7 +172,7 @@ func main() {
|
|||||||
// The key lookup runs fresh on every send (not cached at startup) so key
|
// The key lookup runs fresh on every send (not cached at startup) so key
|
||||||
// rotation via the admin portal (Phase 8+) takes effect without a restart.
|
// rotation via the admin portal (Phase 8+) takes effect without a restart.
|
||||||
queueWorker := queue.NewWorker(database, store).
|
queueWorker := queue.NewWorker(database, store).
|
||||||
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname}).
|
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname, Database: database}).
|
||||||
WithKeyLookup(
|
WithKeyLookup(
|
||||||
func(fromDomain string) ([]byte, string, bool) {
|
func(fromDomain string) ([]byte, string, bool) {
|
||||||
dom, err := database.LookupDomainByName(fromDomain)
|
dom, err := database.LookupDomainByName(fromDomain)
|
||||||
@@ -259,7 +259,7 @@ func main() {
|
|||||||
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
|
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
|
||||||
|
|
||||||
oauthConfigs := buildOAuthConfigs(cfg)
|
oauthConfigs := buildOAuthConfigs(cfg)
|
||||||
webmailHandler := webmail.NewHandler(database, store, mk, cfg.Security.JWTSecret, oauthConfigs)
|
webmailHandler := webmail.NewHandler(database, store, mk, cfg.Security.JWTSecret, cfg.Server.Hostname, oauthConfigs)
|
||||||
webmailMux := http.NewServeMux()
|
webmailMux := http.NewServeMux()
|
||||||
webmailHandler.RegisterRoutes(webmailMux)
|
webmailHandler.RegisterRoutes(webmailMux)
|
||||||
|
|
||||||
@@ -362,7 +362,20 @@ func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
|
|||||||
configs["google"] = &oauth2.Config{
|
configs["google"] = &oauth2.Config{
|
||||||
ClientID: cfg.OAuth.Google.ClientID, ClientSecret: cfg.OAuth.Google.ClientSecret,
|
ClientID: cfg.OAuth.Google.ClientID, ClientSecret: cfg.OAuth.Google.ClientSecret,
|
||||||
RedirectURI: cfg.OAuth.Google.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
RedirectURI: cfg.OAuth.Google.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
||||||
Scopes: []string{"https://mail.google.com/", "email"},
|
// gmail.modify covers read/send/label/trash in one scope
|
||||||
|
// (deliberately excludes permanent delete — see
|
||||||
|
// GmailAPIProvider.Delete); openid+email for the real
|
||||||
|
// userinfo lookup at link time (see oauthCallback);
|
||||||
|
// calendar.readonly/contacts.readonly for GmailAPIProvider's
|
||||||
|
// ListEvents/ListContacts (read-only — see that package's
|
||||||
|
// doc comment on why write access isn't in scope).
|
||||||
|
// Note: accounts linked before this scope list changed need
|
||||||
|
// to be unlinked and re-linked for calendar/contacts access
|
||||||
|
// to work — their existing OAuth consent doesn't cover it.
|
||||||
|
Scopes: []string{
|
||||||
|
"https://www.googleapis.com/auth/gmail.modify", "openid", "email",
|
||||||
|
"https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/contacts.readonly",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +385,13 @@ func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
|
|||||||
configs["microsoft"] = &oauth2.Config{
|
configs["microsoft"] = &oauth2.Config{
|
||||||
ClientID: cfg.OAuth.Microsoft.ClientID, ClientSecret: cfg.OAuth.Microsoft.ClientSecret,
|
ClientID: cfg.OAuth.Microsoft.ClientID, ClientSecret: cfg.OAuth.Microsoft.ClientSecret,
|
||||||
RedirectURI: cfg.OAuth.Microsoft.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
RedirectURI: cfg.OAuth.Microsoft.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
||||||
Scopes: []string{"https://outlook.office.com/IMAP.AccessAsUser.All", "offline_access", "email"},
|
// Mail.ReadWrite+Mail.Send cover Graph mail access;
|
||||||
|
// offline_access for a refresh token; User.Read for the
|
||||||
|
// real /me userinfo lookup at link time; Calendars.Read/
|
||||||
|
// Contacts.Read for GraphAPIProvider's ListEvents/
|
||||||
|
// ListContacts (read-only, same rationale as Google's scopes
|
||||||
|
// above — accounts linked before this change need re-linking).
|
||||||
|
Scopes: []string{"Mail.ReadWrite", "Mail.Send", "offline_access", "User.Read", "email", "Calendars.Read", "Contacts.Read"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ go 1.26.4
|
|||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.49
|
github.com/mattn/go-sqlite3 v1.14.49
|
||||||
|
github.com/miekg/dns v1.1.72
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -1 +1,72 @@
|
|||||||
{"0": "TOTP & Web Token Auth", "1": "ACME/JWS Client", "2": "CalDAV/CardDAV Handlers", "3": "IMAP Command Parser", "4": "Mail Provider Abstraction", "5": "Server Config & Bootstrap", "6": "Sieve Filter Interpreter", "7": "DB Mutation Queries", "8": "IMAP Server Loop", "9": "POP3 Server Loop", "10": "JMAP Auth & Sessions", "11": "SMTP Session Header Parsing", "12": "Admin API Handlers", "13": "IMAP Client", "14": "DKIM Key Signing", "15": "Outbound Delivery Queue", "16": "SMTP Server Networking", "17": "Linked Account & Alias Queries", "18": "TCP Server Lifecycle", "19": "Calendar/Contact/TLS Queries", "20": "OAuth2 Config", "21": "Go-No-Deps Web App Pattern", "22": "GoMail Build Phases Overview", "23": "Quarantine & Message Queries", "24": "iCal Parsing & Fuzzing", "25": "LLM Spam Stage", "26": "vCard Parsing & Fuzzing", "27": "Admin Portal Bugs & CRUD", "28": "Spam Pipeline Orchestrator", "29": "SPF Stage", "30": "TCP Boundary & Rate-Limit Findings", "31": "Domain/Tenant Queries", "32": "Rspamd Stage", "33": "Per-IP Rate Limiter", "34": "ClamAV Stage", "35": "DMARC Stage", "36": "Spam Header Injection Stage", "37": "Iterative Build Discipline Skill", "38": "Deployment & DNS Setup", "39": "HTTP Middleware", "40": "URL Extraction Stage", "41": "SQLite Deadlock Findings", "42": "List Rule Queries", "43": "Sieve Script Queries", "44": "Mail Context Domain Helpers", "45": "Calendar Object Queries", "46": "Contact Queries", "47": "Shared api() Fetch Convention", "48": "Session Continuity Practices", "49": "Scope Prioritization Practices", "50": "DB Migrations", "51": "Shared esc() Helper", "52": "SPA Login/Logout", "53": "SPA App Boot/Routing", "54": "Graphify Project Rules", "55": "Embedded Assets (admin)", "56": "Admin Handlers Entry", "57": "Admin Dashboard Loader", "58": "Admin SPA Router", "59": "Webmail Bootstrap", "60": "Embedded Assets (webmail)", "61": "Raw MIME Body Extractor", "62": "Webmail Message Actions", "63": "Go Module Root"}
|
{
|
||||||
|
"0": "User",
|
||||||
|
"1": "Client",
|
||||||
|
"2": "webauthn.go",
|
||||||
|
"3": "session",
|
||||||
|
"4": "GmailAPIProvider",
|
||||||
|
"5": "config.go",
|
||||||
|
"6": "parser",
|
||||||
|
"7": "DB",
|
||||||
|
"8": "session",
|
||||||
|
"9": "session",
|
||||||
|
"10": "Handler",
|
||||||
|
"11": "session",
|
||||||
|
"12": "writeErr",
|
||||||
|
"13": "Client",
|
||||||
|
"14": "Sign",
|
||||||
|
"15": "Worker",
|
||||||
|
"16": "session",
|
||||||
|
"17": "models.go",
|
||||||
|
"18": "Store",
|
||||||
|
"19": "uuidNew",
|
||||||
|
"20": "oauth2.go",
|
||||||
|
"21": "Go Web App No-Deps Pattern (skill)",
|
||||||
|
"22": "GoMail Action Plan v4 Overview",
|
||||||
|
"23": "QuarantineEntry",
|
||||||
|
"24": "ical.go",
|
||||||
|
"25": ".Run",
|
||||||
|
"26": "vcard.go",
|
||||||
|
"27": "Phase 11: Admin Portal",
|
||||||
|
"28": "app.js",
|
||||||
|
"29": "Server",
|
||||||
|
"30": "Suggested Next Session Scope",
|
||||||
|
"31": "Domain",
|
||||||
|
"32": "checkSPF",
|
||||||
|
"33": "Limiter",
|
||||||
|
"34": ".Run",
|
||||||
|
"35": ".Run",
|
||||||
|
"36": ".Run",
|
||||||
|
"37": "Core Principle: Compiles is Not Correct",
|
||||||
|
"38": "Phase 13: TLS + ACME + DANE/MTA-STS",
|
||||||
|
"39": "GraphAPIProvider",
|
||||||
|
"40": ".Run",
|
||||||
|
"41": "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)",
|
||||||
|
"42": "gomail",
|
||||||
|
"43": "SieveScript",
|
||||||
|
"44": "MailContext",
|
||||||
|
"45": "CalendarObject",
|
||||||
|
"46": "OutboundQueueEntry",
|
||||||
|
"47": "Shared api() Fetch Helper Convention",
|
||||||
|
"48": "Checkpoint Working State as Durable Artifact",
|
||||||
|
"49": "Prioritize by Risk Reduction Over Task Order",
|
||||||
|
"50": "migration",
|
||||||
|
"51": "esc() HTML-escape helper (admin)",
|
||||||
|
"52": "login() (admin)",
|
||||||
|
"53": "showApp()/showLogin() (admin)",
|
||||||
|
"54": "graphify Project Rules",
|
||||||
|
"55": "admin/embed.go",
|
||||||
|
"56": "handlers.go",
|
||||||
|
"57": "loadDashboard",
|
||||||
|
"58": "showPage() router (admin)",
|
||||||
|
"59": "bootstrap.go",
|
||||||
|
"60": "webmail/embed.go",
|
||||||
|
"61": "bodyOf() raw MIME body extractor",
|
||||||
|
"62": "loadMessages()/viewMessage()/deleteMessage",
|
||||||
|
"63": "ListRule",
|
||||||
|
"64": ".GetContact",
|
||||||
|
"65": "dnssec.go",
|
||||||
|
"66": "totp.go",
|
||||||
|
"67": "StageResult",
|
||||||
|
"68": "pipeline.go",
|
||||||
|
"70": ".GetTLSCert"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"0": "0601ba4c5ef570af", "1": "5563ceee816586bc", "2": "0065d9d7243491e5", "3": "5a4df2f471849b12", "4": "6b4a79ebdda6140e", "5": "398e7091e6710da5", "6": "43a892bf7f0137c1", "7": "9283dc7e174690b2", "8": "d19e0c173fbbd4cf", "9": "3aa8996b24118c33", "10": "c8f99b1d2797f728", "11": "743c756b0b4bdd9f", "12": "97a2f0692d97dc15", "13": "96bddd22ac12459b", "14": "ed1a75d8c2b19f50", "15": "5262c99718288789", "16": "fa3d5f16594f61cb", "17": "9f781490485c0586", "18": "8f35d32c03fe029b", "19": "c6941ea4d56538e3", "20": "1b0277c6dd12c83d", "21": "235c5b1d72f73e69", "22": "8299b1df0460d7a7", "23": "3468ea389b4941d0", "24": "ef3ef42ae2afbb2a", "25": "7489b031b1317de1", "26": "beb24c4d9ea79e3a", "27": "77bf2d2bfc22381b", "28": "b44f08f7bce825f4", "29": "b646f402108351cc", "30": "75e7934ab0b830de", "31": "b262509efd80d18b", "32": "5c44e1875cf3126a", "33": "67463d38582b6682", "34": "c6f4ee03c5881a2a", "35": "e04cacd1ed78efd5", "36": "6e9034dba552d1f5", "37": "57923a62fb5c83d6", "38": "2df20c00f5d849c4", "39": "9dd21636e0545a77", "40": "de7344ceee589455", "41": "fac8947f14f3076b", "42": "c6fbcadd0820177d", "43": "62afd540692951ef", "44": "4641c95c0f510d93", "45": "2a0d6510b94d201c", "46": "3b4aae9299fc2f7b", "47": "d0591e2bdecf97f9", "48": "279552eb69e8368b", "49": "75b9dab7ee74ca0c", "50": "3bad9ca60eedcd79", "51": "f9086d8302936834", "52": "63e3d2ab37f32156", "53": "302d78947e3d3db7", "54": "9b40c2d1b4a4feb2", "55": "ba1fd9dfea0b4665", "56": "0aea46458306bbdc", "57": "6148e60f6e7a4cef", "58": "91aebaec8cfa4ba0", "59": "89e311c1f6a17b74", "60": "a6635b6fb2421b76", "61": "237ba03f8b2be5a5", "62": "7594a8e28f35b921", "63": "c2f1ef3116de9154", "64": "1fdb13184fd71fdf", "65": "c5492aaa900554f3", "66": "9766cf03802f7b1b", "67": "2d28c20804038a71", "68": "2bd2f5cacb74baa4", "70": "e351c7db6c083440"}
|
||||||
@@ -1 +1 @@
|
|||||||
/home/haku/projects/webmail
|
.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"0": "User",
|
||||||
|
"1": "Client",
|
||||||
|
"2": "webauthn.go",
|
||||||
|
"3": "session",
|
||||||
|
"4": "GoMailProvider",
|
||||||
|
"5": "config.go",
|
||||||
|
"6": "parser",
|
||||||
|
"7": "DB",
|
||||||
|
"8": "session",
|
||||||
|
"9": "session",
|
||||||
|
"10": "Handler",
|
||||||
|
"11": "session",
|
||||||
|
"12": "writeErr",
|
||||||
|
"13": "Client",
|
||||||
|
"14": "Sign",
|
||||||
|
"15": "Worker",
|
||||||
|
"16": "Server",
|
||||||
|
"17": "models.go",
|
||||||
|
"18": "Store",
|
||||||
|
"19": "uuidNew",
|
||||||
|
"20": "oauth2.go",
|
||||||
|
"21": "Go Web App No-Deps Pattern (skill)",
|
||||||
|
"22": "GoMail Action Plan v4 Overview",
|
||||||
|
"23": "QuarantineEntry",
|
||||||
|
"24": "ical.go",
|
||||||
|
"25": ".Run",
|
||||||
|
"26": "vcard.go",
|
||||||
|
"27": "Phase 11: Admin Portal",
|
||||||
|
"28": "app.js",
|
||||||
|
"29": "dnsutil.go",
|
||||||
|
"30": "Suggested Next Session Scope",
|
||||||
|
"31": "Domain",
|
||||||
|
"32": "checkSPF",
|
||||||
|
"33": "Limiter",
|
||||||
|
"34": ".Run",
|
||||||
|
"35": ".Run",
|
||||||
|
"36": "StageResult",
|
||||||
|
"37": "Core Principle: Compiles is Not Correct",
|
||||||
|
"38": "Phase 13: TLS + ACME + DANE/MTA-STS",
|
||||||
|
"39": "Server",
|
||||||
|
"40": ".Run",
|
||||||
|
"41": "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)",
|
||||||
|
"42": "gomail",
|
||||||
|
"43": "SieveScript",
|
||||||
|
"44": "MailContext",
|
||||||
|
"45": "CalendarObject",
|
||||||
|
"46": "OutboundQueueEntry",
|
||||||
|
"47": "Shared api() Fetch Helper Convention",
|
||||||
|
"48": "Checkpoint Working State as Durable Artifact",
|
||||||
|
"49": "Prioritize by Risk Reduction Over Task Order",
|
||||||
|
"50": "migration",
|
||||||
|
"51": "esc() HTML-escape helper (admin)",
|
||||||
|
"52": "login() (admin)",
|
||||||
|
"53": "showApp()/showLogin() (admin)",
|
||||||
|
"54": "graphify Project Rules",
|
||||||
|
"55": "admin/embed.go",
|
||||||
|
"56": "handlers.go",
|
||||||
|
"57": "loadDashboard",
|
||||||
|
"58": "showPage() router (admin)",
|
||||||
|
"59": "bootstrap.go",
|
||||||
|
"60": "webmail/embed.go",
|
||||||
|
"61": "bodyOf() raw MIME body extractor",
|
||||||
|
"62": "loadMessages()/viewMessage()/deleteMessage",
|
||||||
|
"63": "ListRule",
|
||||||
|
"64": "Contact",
|
||||||
|
"65": "session",
|
||||||
|
"66": "MailboxEntry",
|
||||||
|
"67": "Server",
|
||||||
|
"68": "pipeline.go",
|
||||||
|
"69": "expandFetchItems",
|
||||||
|
"70": ".GetTLSCert"
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# Graph Report - webmail (2026-08-09)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 85 files · ~80,564 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1144 nodes · 2550 edges · 71 communities (53 shown, 18 thin omitted)
|
||||||
|
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 205 edges (avg confidence: 0.81)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `d7ca591b`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- User
|
||||||
|
- Client
|
||||||
|
- webauthn.go
|
||||||
|
- session
|
||||||
|
- GoMailProvider
|
||||||
|
- config.go
|
||||||
|
- parser
|
||||||
|
- DB
|
||||||
|
- session
|
||||||
|
- session
|
||||||
|
- Handler
|
||||||
|
- session
|
||||||
|
- writeErr
|
||||||
|
- Client
|
||||||
|
- Sign
|
||||||
|
- Worker
|
||||||
|
- Server
|
||||||
|
- models.go
|
||||||
|
- Store
|
||||||
|
- uuidNew
|
||||||
|
- oauth2.go
|
||||||
|
- Go Web App No-Deps Pattern (skill)
|
||||||
|
- GoMail Action Plan v4 Overview
|
||||||
|
- QuarantineEntry
|
||||||
|
- ical.go
|
||||||
|
- .Run
|
||||||
|
- vcard.go
|
||||||
|
- Phase 11: Admin Portal
|
||||||
|
- app.js
|
||||||
|
- dnsutil.go
|
||||||
|
- Suggested Next Session Scope
|
||||||
|
- Domain
|
||||||
|
- checkSPF
|
||||||
|
- Limiter
|
||||||
|
- .Run
|
||||||
|
- .Run
|
||||||
|
- StageResult
|
||||||
|
- Core Principle: Compiles is Not Correct
|
||||||
|
- Phase 13: TLS + ACME + DANE/MTA-STS
|
||||||
|
- Server
|
||||||
|
- .Run
|
||||||
|
- Phase 12: Auth Hardening (TOTP/App Passwords/Reset)
|
||||||
|
- gomail
|
||||||
|
- SieveScript
|
||||||
|
- MailContext
|
||||||
|
- CalendarObject
|
||||||
|
- OutboundQueueEntry
|
||||||
|
- Shared api() Fetch Helper Convention
|
||||||
|
- Checkpoint Working State as Durable Artifact
|
||||||
|
- Prioritize by Risk Reduction Over Task Order
|
||||||
|
- migration
|
||||||
|
- esc() HTML-escape helper (admin)
|
||||||
|
- login() (admin)
|
||||||
|
- showApp()/showLogin() (admin)
|
||||||
|
- graphify Project Rules
|
||||||
|
- showPage() router (admin)
|
||||||
|
- bodyOf() raw MIME body extractor
|
||||||
|
- ListRule
|
||||||
|
- Contact
|
||||||
|
- session
|
||||||
|
- MailboxEntry
|
||||||
|
- Server
|
||||||
|
- pipeline.go
|
||||||
|
- expandFetchItems
|
||||||
|
- .GetTLSCert
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `DB` - 81 edges
|
||||||
|
2. `User` - 64 edges
|
||||||
|
3. `Handler` - 47 edges
|
||||||
|
4. `writeErr()` - 34 edges
|
||||||
|
5. `writeJSON()` - 33 edges
|
||||||
|
6. `Store` - 28 edges
|
||||||
|
7. `api()` - 28 edges
|
||||||
|
8. `MasterKey` - 27 edges
|
||||||
|
9. `session` - 25 edges
|
||||||
|
10. `session` - 25 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `base.html Flask-style Block Layout` --conceptually_related_to--> `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
- `Dark Tailwind CSS Custom-Property Palette` --semantically_similar_to--> `GoMail Admin SPA (index.html)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/admin/static/index.html
|
||||||
|
- `Dark Tailwind CSS Custom-Property Palette` --semantically_similar_to--> `GoMail Webmail SPA (index.html)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
- `Shared api() Fetch Helper Convention` --semantically_similar_to--> `api() fetch helper (admin)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/admin/static/index.html
|
||||||
|
- `Shared api() Fetch Helper Convention` --semantically_similar_to--> `api() fetch helper (webmail)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- None detected.
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **GoMail Project Documentation Set** — gomail_handover_overview, readme_gomail, gomail_action_plan_v4_overview, _claude_iterative_build_discipline_skill_gomail_project [INFERRED 0.85]
|
||||||
|
- **Admin Portal CRUD Feature Set** — internal_admin_static_index_domains_crud, internal_admin_static_index_users_crud, internal_admin_static_index_rules_crud, internal_admin_static_index_queue_crud, internal_admin_static_index_quarantine [EXTRACTED 1.00]
|
||||||
|
- **Recurring Bug-Pattern Documentation Across GoMail Docs** — _claude_iterative_build_discipline_skill_sqlite_single_conn, gomail_handover_sqlite_deadlock_bug, gomail_action_plan_v4_sqlite_deadlock_bug, _claude_iterative_build_discipline_skill_tcp_read_pattern, gomail_handover_tcp_bug, gomail_action_plan_v4_tcp_bug [INFERRED 0.85]
|
||||||
|
|
||||||
|
## Communities (71 total, 18 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "User"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (42): User, NewGoMailProvider(), decodeSecret(), Generate(), GenerateSecret(), Time, hotp(), ProvisioningURI() (+34 more)
|
||||||
|
|
||||||
|
### Community 1 - "Client"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (30): AccountKey, Authorization, Challenge, ChallengeResponder, Client, directory, jwk, Order (+22 more)
|
||||||
|
|
||||||
|
### Community 2 - "webauthn.go"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (16): cborDecode(), cborDecodeWithLength(), DecodePublicKey(), EncodePublicKey(), Time, ParseAttestationObject(), ParseAuthData(), parseCOSEKey() (+8 more)
|
||||||
|
|
||||||
|
### Community 3 - "session"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (8): state, Config, Conn, Context, session, ReadWriter, Server, newSession()
|
||||||
|
|
||||||
|
### Community 4 - "GoMailProvider"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (38): Folder, FullMessage, GmailAPIProvider, gmailLabel, GoMailProvider, GraphAPIProvider, graphFolder, graphMessage (+30 more)
|
||||||
|
|
||||||
|
### Community 5 - "config.go"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (36): buildOAuthConfigs(), Config, Handler, ipAllowlistMiddleware(), main(), Config, DatabaseConfig, JMAPConfig (+28 more)
|
||||||
|
|
||||||
|
### Community 6 - "parser"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (19): evalTest(), execStatements(), Execute(), lookupHeader(), newLexer(), Parse(), FuzzParse(), F (+11 more)
|
||||||
|
|
||||||
|
### Community 8 - "session"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (15): Scope, Authenticate(), checkAppPassword(), DB, escapeQuoted(), Conn, Context, ReadWriter (+7 more)
|
||||||
|
|
||||||
|
### Community 9 - "session"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (16): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+8 more)
|
||||||
|
|
||||||
|
### Community 10 - "Handler"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (14): Context, DB, Request, ResponseWriter, ServeMux, jmapRole(), NewHandler(), splitCompositeID() (+6 more)
|
||||||
|
|
||||||
|
### Community 11 - "session"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (16): applySieve(), extractHeader(), extractHeaderMap(), extractMessageID(), extractSubject(), Conn, Context, IP (+8 more)
|
||||||
|
|
||||||
|
### Community 12 - "writeErr"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
|
||||||
|
|
||||||
|
### Community 13 - "Client"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
|
||||||
|
|
||||||
|
### Community 14 - "Sign"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (20): KeyPair, ExtractSignatureInfo(), GenerateKeyPair(), PrivateKey, ParseDNSPublicKey(), ParsePrivateKey(), buildDKIMHeader(), canonicalizeBodyRelaxed() (+12 more)
|
||||||
|
|
||||||
|
### Community 15 - "Worker"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (20): Discover(), Context, Duration, Matches(), parsePolicy(), backoffDuration(), domainOf(), Config (+12 more)
|
||||||
|
|
||||||
|
### Community 16 - "Server"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (14): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+6 more)
|
||||||
|
|
||||||
|
### Community 17 - "models.go"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (12): Addressbook, Alias, AppPassword, Calendar, CheckResult, MessageCheck, MFABackupCode, OwnerType (+4 more)
|
||||||
|
|
||||||
|
### Community 18 - "Store"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (46): IMAPCredential, MailProvider, OAuth2Credential, keyCacheKey, MasterKey, Handler, multistatusResponse, propSet (+38 more)
|
||||||
|
|
||||||
|
### Community 19 - "uuidNew"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (3): MTASTSPolicy, Stats, uuidNew()
|
||||||
|
|
||||||
|
### Community 20 - "oauth2.go"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): Context, Reader, Request, Time, NewAuthedRequest(), truncate(), WellKnownEndpoints(), Config (+3 more)
|
||||||
|
|
||||||
|
### Community 21 - "Go Web App No-Deps Pattern (skill)"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (13): go-web-app-no-deps Packaged Skill (.skill zip), base.html Flask-style Block Layout, Template Block Bleeding Bug (ParseGlob shared namespace), Static Asset Cache-Busting via Version Query Param, Dark Tailwind CSS Custom-Property Palette, JS Function-in-Conditional Scoping Bug, No-Third-Party-Dependencies Principle, Go Web App No-Deps Pattern (skill) (+5 more)
|
||||||
|
|
||||||
|
### Community 22 - "GoMail Action Plan v4 Overview"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (12): GoMail (referenced project), Wire-Format Structs Need Explicit Serialization Tags, cmd/e2etestN Disposable Test Pattern, GoMail Action Plan v4 Overview, Phase 5: IMAP/POP3/Auth, Phase 7: CalDAV/CardDAV, Phase 8: Webmail (JWT + REST API + SPA), Phase 9: JMAP Core/Mail Subset (+4 more)
|
||||||
|
|
||||||
|
### Community 23 - "QuarantineEntry"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (3): QuarantineEntry, QuarantineStatus, Time
|
||||||
|
|
||||||
|
### Community 24 - "ical.go"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
|
||||||
|
|
||||||
|
### Community 25 - ".Run"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
|
||||||
|
|
||||||
|
### Community 26 - "vcard.go"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (8): escape(), FuzzParse(), F, Parse(), splitProperty(), unescape(), unfold(), Card
|
||||||
|
|
||||||
|
### Community 27 - "Phase 11: Admin Portal"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (10): Admin Domain Creation Missing Tenant Fallback Bug, Admin User Creation Missing domain_id Bug, Phase 11: Admin Portal, Quarantine: Admin Global Discard vs Webmail Per-User Release, Domains CRUD (loadDomains/createDomain/rotateDkim/deleteDomain), Quarantine Discard (admin), Outbound Queue Actions (retry/cancel), List Rules CRUD (+2 more)
|
||||||
|
|
||||||
|
### Community 28 - "app.js"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (56): accounts, acctQuery(), addPasskey(), api(), b64urlToBuf(), bodyOf(), boot(), bufToB64url() (+48 more)
|
||||||
|
|
||||||
|
### Community 29 - "dnsutil.go"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (18): TLSARecord, Response, RR, Certificate, Context, Lookup(), VerifyPeerCertificate(), buildQuery() (+10 more)
|
||||||
|
|
||||||
|
### Community 30 - "Suggested Next Session Scope"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (9): bufio.Reader + io.ReadFull for TCP Protocol Boundaries, Genuine EICAR Byte-Level ClamAV Verification, Phase 10: OAuth2 + Gmail/M365 Multi-Account, Phase 14: Optional External Services (ClamAV/Rspamd/LLM), Phase 15: Hardening + Deploy, Token-Bucket Per-IP Rate Limiter, Fake clamd Server TCP Over-Read Bug, Suggested Next Session Scope (+1 more)
|
||||||
|
|
||||||
|
### Community 32 - "checkSPF"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
|
||||||
|
|
||||||
|
### Community 33 - "Limiter"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (5): Mutex, Limiter, Time, New(), bucket
|
||||||
|
|
||||||
|
### Community 34 - ".Run"
|
||||||
|
Cohesion: 0.36
|
||||||
|
Nodes (5): Context, Duration, rspamdResponse, RspamdStage, rspamdSymbol
|
||||||
|
|
||||||
|
### Community 35 - ".Run"
|
||||||
|
Cohesion: 0.39
|
||||||
|
Nodes (4): Context, Duration, parseClamAddr(), ClamAVStage
|
||||||
|
|
||||||
|
### Community 36 - "StageResult"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (9): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), Context, injectSpamHeaders(), DMARCStage, HeaderStage (+1 more)
|
||||||
|
|
||||||
|
### Community 37 - "Core Principle: Compiles is Not Correct"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): iterative-build-discipline Packaged Skill (.skill zip), Core Principle: Compiles is Not Correct, Disposable Real End-to-End Test Pattern, Genuinely-Enforcing Fake Protocol Server Pattern, Negative-Path Tests as Non-Optional, Verify Against Published Test Vectors (RFC 6238)
|
||||||
|
|
||||||
|
### Community 38 - "Phase 13: TLS + ACME + DANE/MTA-STS"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (6): Phase 13: TLS + ACME + DANE/MTA-STS, Sandbox Network/Toolchain Constraints, Immediate First Steps Setup, DNS Setup (MX/SPF/DKIM/DMARC), GoMail (Self-Hosted Email Server), TLS / ACME Quick Setup
|
||||||
|
|
||||||
|
### Community 39 - "Server"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): Server, connHost(), Addr, Config, Context, DB, Duration, Limiter (+3 more)
|
||||||
|
|
||||||
|
### Community 40 - ".Run"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (3): dedupe(), Context, URLStage
|
||||||
|
|
||||||
|
### Community 41 - "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (5): SQLite Single-Connection-Pool Query/Exec Deadlock Pattern, Phase 12: Auth Hardening (TOTP/App Passwords/Reset), Rationale: Phase 13 Pulled Forward, ConsumeBackupCode SQLite Deadlock Bug, SQLite Query+Exec Deadlock Bug (documented)
|
||||||
|
|
||||||
|
### Community 44 - "MailContext"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (5): Message, MessageVerdict, domainOf(), IP, MailContext
|
||||||
|
|
||||||
|
### Community 47 - "Shared api() Fetch Helper Convention"
|
||||||
|
Cohesion: 1.00
|
||||||
|
Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api() fetch helper (webmail)
|
||||||
|
|
||||||
|
### Community 65 - "session"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (3): session, matchesSearch(), quoteIfNeeded()
|
||||||
|
|
||||||
|
### Community 66 - "MailboxEntry"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (7): MailboxEntry, addFlag(), extractHeaders(), flagsToIMAP(), indexOf(), parseSeqNum(), removeFlag()
|
||||||
|
|
||||||
|
### Community 67 - "Server"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (8): Config, Context, DB, Duration, Listener, WaitGroup, NewServer(), Server
|
||||||
|
|
||||||
|
### Community 68 - "pipeline.go"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (8): DefaultStages(), Config, Context, NewOrchestrator(), StagesFromConfig(), verdictFor(), Orchestrator, Stage
|
||||||
|
|
||||||
|
### Community 69 - "expandFetchItems"
|
||||||
|
Cohesion: 0.32
|
||||||
|
Nodes (6): expandFetchItems(), isList(), splitList(), tokenize(), FuzzTokenize(), F
|
||||||
|
|
||||||
|
## Ambiguous Edges - Review These
|
||||||
|
- `Template Renderer (fresh-instance-per-page)` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `base.html Flask-style Block Layout` → `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` → `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **29 isolated node(s):** `gomail`, `gmailLabel`, `graphFolder`, `IMAPCredential`, `keyCacheKey` (+24 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **18 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **What is the exact relationship between `Template Renderer (fresh-instance-per-page)` and `GoMail Admin SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `base.html Flask-style Block Layout` and `GoMail Webmail SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Admin SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Webmail SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **Why does `User` connect `User` to `session`, `GoMailProvider`, `DB`, `session`, `session`, `Handler`, `session`, `writeErr`, `models.go`, `Store`?**
|
||||||
|
_High betweenness centrality (0.297) - this node is a cross-community bridge._
|
||||||
|
- **Why does `Store` connect `Store` to `User`, `GoMailProvider`, `Server`, `session`, `Handler`, `Worker`, `Server`?**
|
||||||
|
_High betweenness centrality (0.150) - this node is a cross-community bridge._
|
||||||
|
- **Why does `DB` connect `DB` to `Contact`, `MailboxEntry`, `.GetTLSCert`, `SieveScript`, `MailContext`, `CalendarObject`, `OutboundQueueEntry`, `models.go`, `Store`, `uuidNew`, `QuarantineEntry`, `Domain`, `ListRule`?**
|
||||||
|
_High betweenness centrality (0.095) - this node is a cross-community bridge._
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-09T13:36:33.949106+00:00",
|
||||||
|
"input_tokens": 134823,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 78
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 134823,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
".claude/settings.json": {
|
||||||
|
"mtime": 1786281487.088728,
|
||||||
|
"ast_hash": "",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"cmd/gomail/main.go": {
|
||||||
|
"mtime": 1786301328.7600007,
|
||||||
|
"ast_hash": "9bc7ad461922c990788c456f84817a9f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"go.mod": {
|
||||||
|
"mtime": 1786283185.2691686,
|
||||||
|
"ast_hash": "93100fd0185e78288141d5ed0b2c8b19",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/link.go": {
|
||||||
|
"mtime": 1786299069.1242821,
|
||||||
|
"ast_hash": "5eed8ecb5be6a1c35c6591dedad976d1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider.go": {
|
||||||
|
"mtime": 1786168320.0,
|
||||||
|
"ast_hash": "4a180151e798601f43bda25bd1a80410",
|
||||||
|
"semantic_hash": "4a180151e798601f43bda25bd1a80410"
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_gomail.go": {
|
||||||
|
"mtime": 1786297740.5829873,
|
||||||
|
"ast_hash": "ac0d507cba9953d85a44ce0506217587",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_imap.go": {
|
||||||
|
"mtime": 1786298998.877657,
|
||||||
|
"ast_hash": "4825cd303e132e3179500afe0e9098bd",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_smtp_helper.go": {
|
||||||
|
"mtime": 1786289886.1606128,
|
||||||
|
"ast_hash": "4fec96778cdaa94b1ab2264236af8b48",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/acme/challenge.go": {
|
||||||
|
"mtime": 1786189310.0,
|
||||||
|
"ast_hash": "ec3bcb998b34bc2c8a43cb8f3880e112",
|
||||||
|
"semantic_hash": "ec3bcb998b34bc2c8a43cb8f3880e112"
|
||||||
|
},
|
||||||
|
"internal/acme/client.go": {
|
||||||
|
"mtime": 1786189290.0,
|
||||||
|
"ast_hash": "830b78389d159a75e48f1a0076b6275b",
|
||||||
|
"semantic_hash": "830b78389d159a75e48f1a0076b6275b"
|
||||||
|
},
|
||||||
|
"internal/acme/jws.go": {
|
||||||
|
"mtime": 1786189220.0,
|
||||||
|
"ast_hash": "e5aa7cbbea33d9971f1577e4c976cb8c",
|
||||||
|
"semantic_hash": "e5aa7cbbea33d9971f1577e4c976cb8c"
|
||||||
|
},
|
||||||
|
"internal/acme/obtain.go": {
|
||||||
|
"mtime": 1786189332.0,
|
||||||
|
"ast_hash": "d2f78794831ad67ba64fb79cb7eb1c00",
|
||||||
|
"semantic_hash": "d2f78794831ad67ba64fb79cb7eb1c00"
|
||||||
|
},
|
||||||
|
"internal/admin/api.go": {
|
||||||
|
"mtime": 1786281901.2009063,
|
||||||
|
"ast_hash": "14fbb199cccd839edb6cf98392226b03",
|
||||||
|
"semantic_hash": "14fbb199cccd839edb6cf98392226b03"
|
||||||
|
},
|
||||||
|
"internal/admin/embed.go": {
|
||||||
|
"mtime": 1786188384.0,
|
||||||
|
"ast_hash": "55b64461208f9449c417bddf49e686cc",
|
||||||
|
"semantic_hash": "55b64461208f9449c417bddf49e686cc"
|
||||||
|
},
|
||||||
|
"internal/admin/handlers.go": {
|
||||||
|
"mtime": 1786281901.8545587,
|
||||||
|
"ast_hash": "06aad8456678b41905ac6787e50fb15a",
|
||||||
|
"semantic_hash": "06aad8456678b41905ac6787e50fb15a"
|
||||||
|
},
|
||||||
|
"internal/auth/auth.go": {
|
||||||
|
"mtime": 1786281903.3781548,
|
||||||
|
"ast_hash": "c74ed7b257b2272dcfb860b8a60746a0",
|
||||||
|
"semantic_hash": "c74ed7b257b2272dcfb860b8a60746a0"
|
||||||
|
},
|
||||||
|
"internal/config/config.go": {
|
||||||
|
"mtime": 1786289818.8781466,
|
||||||
|
"ast_hash": "68fd6f093c4ba00106bdbbe59786a269",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/crypto/crypto.go": {
|
||||||
|
"mtime": 1786297602.7221303,
|
||||||
|
"ast_hash": "8de4f9714d1c3e73a4322080d6b1a2ca",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dav/dav.go": {
|
||||||
|
"mtime": 1786281900.5555487,
|
||||||
|
"ast_hash": "9dc713615ff99b713ad2879fca2b28dd",
|
||||||
|
"semantic_hash": "9dc713615ff99b713ad2879fca2b28dd"
|
||||||
|
},
|
||||||
|
"internal/db/bootstrap.go": {
|
||||||
|
"mtime": 1786281902.3275623,
|
||||||
|
"ast_hash": "922a08de72be0ef63409ed402d01e098",
|
||||||
|
"semantic_hash": "922a08de72be0ef63409ed402d01e098"
|
||||||
|
},
|
||||||
|
"internal/db/db.go": {
|
||||||
|
"mtime": 1786297548.1684437,
|
||||||
|
"ast_hash": "aed0290af4025c8c84089356f8746f08",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/migrations.go": {
|
||||||
|
"mtime": 1786300065.1340663,
|
||||||
|
"ast_hash": "2eb17b13bdc6bd5a954c82b4503aa8f9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/models.go": {
|
||||||
|
"mtime": 1786300091.6301413,
|
||||||
|
"ast_hash": "12f132efac1ac74ab13ce8bfce0bfc69",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/queries.go": {
|
||||||
|
"mtime": 1786301261.1620972,
|
||||||
|
"ast_hash": "684ba8ac79df6845b0f0571ee6ff67a3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dkim/keys.go": {
|
||||||
|
"mtime": 1785566243.0,
|
||||||
|
"ast_hash": "e8d336df9cba3a8f69c70962f8a75579",
|
||||||
|
"semantic_hash": "e8d336df9cba3a8f69c70962f8a75579"
|
||||||
|
},
|
||||||
|
"internal/dkim/sign.go": {
|
||||||
|
"mtime": 1785472923.0,
|
||||||
|
"ast_hash": "ba6dd425f253aac61b386a8aea0d2061",
|
||||||
|
"semantic_hash": "ba6dd425f253aac61b386a8aea0d2061"
|
||||||
|
},
|
||||||
|
"internal/dkim/verify.go": {
|
||||||
|
"mtime": 1785472944.0,
|
||||||
|
"ast_hash": "4e58aca20a4874dac789bc0a844bfba6",
|
||||||
|
"semantic_hash": "4e58aca20a4874dac789bc0a844bfba6"
|
||||||
|
},
|
||||||
|
"internal/ical/ical.go": {
|
||||||
|
"mtime": 1785905665.0,
|
||||||
|
"ast_hash": "2de79f76c377508c20caa2e8683e920a",
|
||||||
|
"semantic_hash": "2de79f76c377508c20caa2e8683e920a"
|
||||||
|
},
|
||||||
|
"internal/ical/ical_fuzz_test.go": {
|
||||||
|
"mtime": 1786207598.0,
|
||||||
|
"ast_hash": "5518fd86059d729048685e2bc39c24b2",
|
||||||
|
"semantic_hash": "5518fd86059d729048685e2bc39c24b2"
|
||||||
|
},
|
||||||
|
"internal/imap/commands.go": {
|
||||||
|
"mtime": 1786294683.3800013,
|
||||||
|
"ast_hash": "584a4c4fa7ad664aaec36bc404825512",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/imap/parser.go": {
|
||||||
|
"mtime": 1785638411.0,
|
||||||
|
"ast_hash": "687b7788b077688b9081f958b669a0e6",
|
||||||
|
"semantic_hash": "687b7788b077688b9081f958b669a0e6"
|
||||||
|
},
|
||||||
|
"internal/imap/server.go": {
|
||||||
|
"mtime": 1786289824.8942587,
|
||||||
|
"ast_hash": "50a8a0f58edd9d9bb6cafa385f3c97b2",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/imap/session.go": {
|
||||||
|
"mtime": 1786281910.5246246,
|
||||||
|
"ast_hash": "f06bd7a253f498fbe197951537bc589e",
|
||||||
|
"semantic_hash": "f06bd7a253f498fbe197951537bc589e"
|
||||||
|
},
|
||||||
|
"internal/imap/tokenize_fuzz_test.go": {
|
||||||
|
"mtime": 1786207631.0,
|
||||||
|
"ast_hash": "4920db9f0c2beaa06a7393ab027d3c2b",
|
||||||
|
"semantic_hash": "4920db9f0c2beaa06a7393ab027d3c2b"
|
||||||
|
},
|
||||||
|
"internal/imapclient/client.go": {
|
||||||
|
"mtime": 1786169633.0,
|
||||||
|
"ast_hash": "f9671c971ff9f39a2ed82af7ed3e87df",
|
||||||
|
"semantic_hash": "f9671c971ff9f39a2ed82af7ed3e87df"
|
||||||
|
},
|
||||||
|
"internal/jmap/jmap.go": {
|
||||||
|
"mtime": 1786281904.3499777,
|
||||||
|
"ast_hash": "2484a0bccd56908627cee4c769b689e5",
|
||||||
|
"semantic_hash": "2484a0bccd56908627cee4c769b689e5"
|
||||||
|
},
|
||||||
|
"internal/mailstore/maildir.go": {
|
||||||
|
"mtime": 1786297713.2183998,
|
||||||
|
"ast_hash": "ca8749979e4a1ca9ada23c072945189c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/managesieve/server.go": {
|
||||||
|
"mtime": 1786281899.3706038,
|
||||||
|
"ast_hash": "4ebc166b83227affe5171026b8012876",
|
||||||
|
"semantic_hash": "4ebc166b83227affe5171026b8012876"
|
||||||
|
},
|
||||||
|
"internal/managesieve/session.go": {
|
||||||
|
"mtime": 1786281899.9771776,
|
||||||
|
"ast_hash": "ab69e22b6add70fdb6c18f056c474e8d",
|
||||||
|
"semantic_hash": "ab69e22b6add70fdb6c18f056c474e8d"
|
||||||
|
},
|
||||||
|
"internal/oauth2/oauth2.go": {
|
||||||
|
"mtime": 1786298531.0961437,
|
||||||
|
"ast_hash": "4e1c7abbe92feb151acecfec03229c50",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/pipeline/pipeline.go": {
|
||||||
|
"mtime": 1786281906.8561504,
|
||||||
|
"ast_hash": "2b0b026fc13ce700c083960df7bc7857",
|
||||||
|
"semantic_hash": "2b0b026fc13ce700c083960df7bc7857"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_clamav.go": {
|
||||||
|
"mtime": 1786281905.5831316,
|
||||||
|
"ast_hash": "6a5c073d349a49e2cbe75863945a663b",
|
||||||
|
"semantic_hash": "6a5c073d349a49e2cbe75863945a663b"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_dkim.go": {
|
||||||
|
"mtime": 1786281908.573951,
|
||||||
|
"ast_hash": "3f92da867d5b128398a8ffc70b6fa475",
|
||||||
|
"semantic_hash": "3f92da867d5b128398a8ffc70b6fa475"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_dmarc.go": {
|
||||||
|
"mtime": 1786294688.9970334,
|
||||||
|
"ast_hash": "fc9ece401684fb950771b70db7a54d9d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_headers.go": {
|
||||||
|
"mtime": 1786281909.176789,
|
||||||
|
"ast_hash": "e7777ff0afde0baa97bd458060e7c002",
|
||||||
|
"semantic_hash": "e7777ff0afde0baa97bd458060e7c002"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_llm.go": {
|
||||||
|
"mtime": 1786281906.2809672,
|
||||||
|
"ast_hash": "c0a1d11888d0f32083b0e90d32018ec1",
|
||||||
|
"semantic_hash": "c0a1d11888d0f32083b0e90d32018ec1"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_rspamd.go": {
|
||||||
|
"mtime": 1786281907.8549173,
|
||||||
|
"ast_hash": "6b7c65df77472ef32c96782db5d98d07",
|
||||||
|
"semantic_hash": "6b7c65df77472ef32c96782db5d98d07"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_spf.go": {
|
||||||
|
"mtime": 1786281916.382805,
|
||||||
|
"ast_hash": "7e1eda77e5ba190bc50dda977b0c3723",
|
||||||
|
"semantic_hash": "7e1eda77e5ba190bc50dda977b0c3723"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_url.go": {
|
||||||
|
"mtime": 1786281905.1305835,
|
||||||
|
"ast_hash": "f954d508ac085f560928c06171779db5",
|
||||||
|
"semantic_hash": "f954d508ac085f560928c06171779db5"
|
||||||
|
},
|
||||||
|
"internal/pop3/pop3.go": {
|
||||||
|
"mtime": 1786289853.1900737,
|
||||||
|
"ast_hash": "e4756d72c5bc2bd89fd41a2205ca3d14",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/queue/queue.go": {
|
||||||
|
"mtime": 1786300256.943359,
|
||||||
|
"ast_hash": "5e9631b0601474ed28f3626a293e8e08",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/ratelimit/http.go": {
|
||||||
|
"mtime": 1786289690.9789355,
|
||||||
|
"ast_hash": "039681ec4aef1340ab4ba8bf83baa679",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/ratelimit/ratelimit.go": {
|
||||||
|
"mtime": 1786207185.0,
|
||||||
|
"ast_hash": "4d5bf0b77d390cf3e71abb4d022cf0aa",
|
||||||
|
"semantic_hash": "4d5bf0b77d390cf3e71abb4d022cf0aa"
|
||||||
|
},
|
||||||
|
"internal/sieve/interp.go": {
|
||||||
|
"mtime": 1786169049.0,
|
||||||
|
"ast_hash": "6f13e1891ef551ffebba37e759742aca",
|
||||||
|
"semantic_hash": "6f13e1891ef551ffebba37e759742aca"
|
||||||
|
},
|
||||||
|
"internal/sieve/lexer.go": {
|
||||||
|
"mtime": 1786168959.0,
|
||||||
|
"ast_hash": "3d8d9f9f203855eaf8d638935f65f7bb",
|
||||||
|
"semantic_hash": "3d8d9f9f203855eaf8d638935f65f7bb"
|
||||||
|
},
|
||||||
|
"internal/sieve/parser.go": {
|
||||||
|
"mtime": 1786168977.0,
|
||||||
|
"ast_hash": "cbcd550894b5c473a160481098151605",
|
||||||
|
"semantic_hash": "cbcd550894b5c473a160481098151605"
|
||||||
|
},
|
||||||
|
"internal/sieve/sieve_fuzz_test.go": {
|
||||||
|
"mtime": 1786207613.0,
|
||||||
|
"ast_hash": "fa017deb29e67fa5264e3ecc59ede127",
|
||||||
|
"semantic_hash": "fa017deb29e67fa5264e3ecc59ede127"
|
||||||
|
},
|
||||||
|
"internal/smtp/auth.go": {
|
||||||
|
"mtime": 1786281931.1489654,
|
||||||
|
"ast_hash": "9220929f9afd4b5b8c39ee7401ea3ecd",
|
||||||
|
"semantic_hash": "9220929f9afd4b5b8c39ee7401ea3ecd"
|
||||||
|
},
|
||||||
|
"internal/smtp/server.go": {
|
||||||
|
"mtime": 1786281931.153782,
|
||||||
|
"ast_hash": "30d88aa535f6ee39072f35fa6c1fd108",
|
||||||
|
"semantic_hash": "30d88aa535f6ee39072f35fa6c1fd108"
|
||||||
|
},
|
||||||
|
"internal/smtp/session.go": {
|
||||||
|
"mtime": 1786281931.1489654,
|
||||||
|
"ast_hash": "41aae3368dc4be4eba559f0d732e9a37",
|
||||||
|
"semantic_hash": "41aae3368dc4be4eba559f0d732e9a37"
|
||||||
|
},
|
||||||
|
"internal/tlsutil/acme_manager.go": {
|
||||||
|
"mtime": 1786281902.8497763,
|
||||||
|
"ast_hash": "5e7aa10c39162f5d95f927ba4efc30fb",
|
||||||
|
"semantic_hash": "5e7aa10c39162f5d95f927ba4efc30fb"
|
||||||
|
},
|
||||||
|
"internal/tlsutil/selfsigned.go": {
|
||||||
|
"mtime": 1786193053.0,
|
||||||
|
"ast_hash": "9c58f78ed11e4c7bf208306285f0b32e",
|
||||||
|
"semantic_hash": "9c58f78ed11e4c7bf208306285f0b32e"
|
||||||
|
},
|
||||||
|
"internal/totp/totp.go": {
|
||||||
|
"mtime": 1786193280.0,
|
||||||
|
"ast_hash": "48f2c9db1c1307f13d78726aa3b24f6a",
|
||||||
|
"semantic_hash": "48f2c9db1c1307f13d78726aa3b24f6a"
|
||||||
|
},
|
||||||
|
"internal/vcard/vcard.go": {
|
||||||
|
"mtime": 1785905650.0,
|
||||||
|
"ast_hash": "8b0043626bac3cacc11b6ce76259445b",
|
||||||
|
"semantic_hash": "8b0043626bac3cacc11b6ce76259445b"
|
||||||
|
},
|
||||||
|
"internal/vcard/vcard_fuzz_test.go": {
|
||||||
|
"mtime": 1786207583.0,
|
||||||
|
"ast_hash": "5aeab637a3c3357bb2c13cacc21508c6",
|
||||||
|
"semantic_hash": "5aeab637a3c3357bb2c13cacc21508c6"
|
||||||
|
},
|
||||||
|
"internal/webmail/api.go": {
|
||||||
|
"mtime": 1786302351.1001678,
|
||||||
|
"ast_hash": "08cd2e43bf56f9b24d49fb5c75dfe90f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webmail/embed.go": {
|
||||||
|
"mtime": 1786295803.857812,
|
||||||
|
"ast_hash": "78663d0210a610d1777c4b4151390cf1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webtoken/webtoken.go": {
|
||||||
|
"mtime": 1786289648.699828,
|
||||||
|
"ast_hash": "b9be7cc5ea74ce6b82de79ef3644cb52",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".claude/go-web-app-no-deps-SKILL.md": {
|
||||||
|
"mtime": 1786206554.0,
|
||||||
|
"ast_hash": "eb54ace954e819a069a9f1284485d213",
|
||||||
|
"semantic_hash": "eb54ace954e819a069a9f1284485d213"
|
||||||
|
},
|
||||||
|
".claude/go-web-app-no-deps.skill": {
|
||||||
|
"mtime": 1786206554.0,
|
||||||
|
"ast_hash": "de820100f8ad2da76d57884262c9f5f0",
|
||||||
|
"semantic_hash": "de820100f8ad2da76d57884262c9f5f0"
|
||||||
|
},
|
||||||
|
".claude/iterative-build-discipline-SKILL.md": {
|
||||||
|
"mtime": 1786206504.0,
|
||||||
|
"ast_hash": "d0e94862b77af5f6ac99c3f2668db3c9",
|
||||||
|
"semantic_hash": "d0e94862b77af5f6ac99c3f2668db3c9"
|
||||||
|
},
|
||||||
|
".claude/iterative-build-discipline.skill": {
|
||||||
|
"mtime": 1786206504.0,
|
||||||
|
"ast_hash": "87a25b626d37a9dfe5c037ae409fecad",
|
||||||
|
"semantic_hash": "87a25b626d37a9dfe5c037ae409fecad"
|
||||||
|
},
|
||||||
|
"CLAUDE.md": {
|
||||||
|
"mtime": 1786281487.0884602,
|
||||||
|
"ast_hash": "82efb97a359f5c7290bf2513d14686be",
|
||||||
|
"semantic_hash": "82efb97a359f5c7290bf2513d14686be"
|
||||||
|
},
|
||||||
|
"GOMAIL_HANDOVER.md": {
|
||||||
|
"mtime": 1786206496.0,
|
||||||
|
"ast_hash": "81fbcb0478fce27c6b6e8c6646eed44a",
|
||||||
|
"semantic_hash": "81fbcb0478fce27c6b6e8c6646eed44a"
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"mtime": 1786208021.0,
|
||||||
|
"ast_hash": "257dbe428b6a41b51d4d8efcd2431615",
|
||||||
|
"semantic_hash": "257dbe428b6a41b51d4d8efcd2431615"
|
||||||
|
},
|
||||||
|
"gomail-action-plan-v4.md": {
|
||||||
|
"mtime": 1786206496.0,
|
||||||
|
"ast_hash": "203046a9566fb3c392ed2e51aa178805",
|
||||||
|
"semantic_hash": "203046a9566fb3c392ed2e51aa178805"
|
||||||
|
},
|
||||||
|
"internal/admin/static/index.html": {
|
||||||
|
"mtime": 1786188708.0,
|
||||||
|
"ast_hash": "77b0b0e42cf45a961571ee5e3a3a219a",
|
||||||
|
"semantic_hash": "77b0b0e42cf45a961571ee5e3a3a219a"
|
||||||
|
},
|
||||||
|
"internal/webmail/static/index.html": {
|
||||||
|
"mtime": 1786302422.3206468,
|
||||||
|
"ast_hash": "240f0802126ee6142b639b0c3d1fd749",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webmail/static/app.js": {
|
||||||
|
"mtime": 1786302477.0202506,
|
||||||
|
"ast_hash": "8b8643e290174bcaab0192971680383d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_gmailapi.go": {
|
||||||
|
"mtime": 1786298739.5325413,
|
||||||
|
"ast_hash": "a80e81ac8b519d6512decda32e7a7708",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_graphapi.go": {
|
||||||
|
"mtime": 1786298907.5633397,
|
||||||
|
"ast_hash": "2780f65f9cfd9dda29fdfcdb6286c6b8",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dane/dane.go": {
|
||||||
|
"mtime": 1786299961.3714652,
|
||||||
|
"ast_hash": "3ef099496960a4e2be04aae071be357c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dnsutil/dnsutil.go": {
|
||||||
|
"mtime": 1786300340.0186586,
|
||||||
|
"ast_hash": "6433e7754901f5eeb8e12d88342265b1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/mtasts/mtasts.go": {
|
||||||
|
"mtime": 1786300014.9348822,
|
||||||
|
"ast_hash": "832952ea8843b31f4f8a01ffa3cfeb7f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/cbor.go": {
|
||||||
|
"mtime": 1786301093.0761018,
|
||||||
|
"ast_hash": "79612ad8167cf6a9e6e18977f1d90f0f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/webauthn.go": {
|
||||||
|
"mtime": 1786301194.5917418,
|
||||||
|
"ast_hash": "f9abf441c2637cdb4bdfd28b358417a3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"0": "User",
|
||||||
|
"1": "Client",
|
||||||
|
"2": "webauthn.go",
|
||||||
|
"3": "session",
|
||||||
|
"4": "GmailAPIProvider",
|
||||||
|
"5": "config.go",
|
||||||
|
"6": "parser",
|
||||||
|
"7": "DB",
|
||||||
|
"8": "session",
|
||||||
|
"9": "session",
|
||||||
|
"10": "Handler",
|
||||||
|
"11": "session",
|
||||||
|
"12": "writeErr",
|
||||||
|
"13": "Client",
|
||||||
|
"14": "Sign",
|
||||||
|
"15": "Worker",
|
||||||
|
"16": "session",
|
||||||
|
"17": "models.go",
|
||||||
|
"18": "Store",
|
||||||
|
"19": "uuidNew",
|
||||||
|
"20": "oauth2.go",
|
||||||
|
"21": "Go Web App No-Deps Pattern (skill)",
|
||||||
|
"22": "GoMail Action Plan v4 Overview",
|
||||||
|
"23": "QuarantineEntry",
|
||||||
|
"24": "ical.go",
|
||||||
|
"25": ".Run",
|
||||||
|
"26": "vcard.go",
|
||||||
|
"27": "Phase 11: Admin Portal",
|
||||||
|
"28": "app.js",
|
||||||
|
"29": "Server",
|
||||||
|
"30": "Suggested Next Session Scope",
|
||||||
|
"31": "Domain",
|
||||||
|
"32": "checkSPF",
|
||||||
|
"33": "Limiter",
|
||||||
|
"34": ".Run",
|
||||||
|
"35": ".Run",
|
||||||
|
"36": ".Run",
|
||||||
|
"37": "Core Principle: Compiles is Not Correct",
|
||||||
|
"38": "Phase 13: TLS + ACME + DANE/MTA-STS",
|
||||||
|
"39": "GraphAPIProvider",
|
||||||
|
"40": ".Run",
|
||||||
|
"41": "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)",
|
||||||
|
"42": "gomail",
|
||||||
|
"43": "SieveScript",
|
||||||
|
"44": "MailContext",
|
||||||
|
"45": "CalendarObject",
|
||||||
|
"46": "OutboundQueueEntry",
|
||||||
|
"47": "Shared api() Fetch Helper Convention",
|
||||||
|
"48": "Checkpoint Working State as Durable Artifact",
|
||||||
|
"49": "Prioritize by Risk Reduction Over Task Order",
|
||||||
|
"50": "migration",
|
||||||
|
"51": "esc() HTML-escape helper (admin)",
|
||||||
|
"52": "login() (admin)",
|
||||||
|
"53": "showApp()/showLogin() (admin)",
|
||||||
|
"54": "graphify Project Rules",
|
||||||
|
"55": "admin/embed.go",
|
||||||
|
"56": "handlers.go",
|
||||||
|
"57": "loadDashboard",
|
||||||
|
"58": "showPage() router (admin)",
|
||||||
|
"59": "bootstrap.go",
|
||||||
|
"60": "webmail/embed.go",
|
||||||
|
"61": "bodyOf() raw MIME body extractor",
|
||||||
|
"62": "loadMessages()/viewMessage()/deleteMessage",
|
||||||
|
"63": "ListRule",
|
||||||
|
"64": ".GetContact",
|
||||||
|
"65": "dnssec.go",
|
||||||
|
"66": "totp.go",
|
||||||
|
"67": "StageResult",
|
||||||
|
"68": "pipeline.go",
|
||||||
|
"70": ".GetTLSCert"
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Graph Report - webmail (2026-08-10)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 85 files · ~82,056 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1169 nodes · 2612 edges · 70 communities (52 shown, 18 thin omitted)
|
||||||
|
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 201 edges (avg confidence: 0.81)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `d7ca591b`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- User
|
||||||
|
- Client
|
||||||
|
- webauthn.go
|
||||||
|
- session
|
||||||
|
- GmailAPIProvider
|
||||||
|
- config.go
|
||||||
|
- parser
|
||||||
|
- DB
|
||||||
|
- session
|
||||||
|
- session
|
||||||
|
- Handler
|
||||||
|
- session
|
||||||
|
- writeErr
|
||||||
|
- Client
|
||||||
|
- Sign
|
||||||
|
- Worker
|
||||||
|
- session
|
||||||
|
- models.go
|
||||||
|
- Store
|
||||||
|
- uuidNew
|
||||||
|
- oauth2.go
|
||||||
|
- Go Web App No-Deps Pattern (skill)
|
||||||
|
- GoMail Action Plan v4 Overview
|
||||||
|
- QuarantineEntry
|
||||||
|
- ical.go
|
||||||
|
- .Run
|
||||||
|
- vcard.go
|
||||||
|
- Phase 11: Admin Portal
|
||||||
|
- app.js
|
||||||
|
- Server
|
||||||
|
- Suggested Next Session Scope
|
||||||
|
- Domain
|
||||||
|
- checkSPF
|
||||||
|
- Limiter
|
||||||
|
- .Run
|
||||||
|
- .Run
|
||||||
|
- .Run
|
||||||
|
- Core Principle: Compiles is Not Correct
|
||||||
|
- Phase 13: TLS + ACME + DANE/MTA-STS
|
||||||
|
- GraphAPIProvider
|
||||||
|
- .Run
|
||||||
|
- Phase 12: Auth Hardening (TOTP/App Passwords/Reset)
|
||||||
|
- gomail
|
||||||
|
- SieveScript
|
||||||
|
- MailContext
|
||||||
|
- CalendarObject
|
||||||
|
- OutboundQueueEntry
|
||||||
|
- Shared api() Fetch Helper Convention
|
||||||
|
- Checkpoint Working State as Durable Artifact
|
||||||
|
- Prioritize by Risk Reduction Over Task Order
|
||||||
|
- migration
|
||||||
|
- esc() HTML-escape helper (admin)
|
||||||
|
- login() (admin)
|
||||||
|
- showApp()/showLogin() (admin)
|
||||||
|
- graphify Project Rules
|
||||||
|
- showPage() router (admin)
|
||||||
|
- bodyOf() raw MIME body extractor
|
||||||
|
- ListRule
|
||||||
|
- .GetContact
|
||||||
|
- dnssec.go
|
||||||
|
- totp.go
|
||||||
|
- StageResult
|
||||||
|
- pipeline.go
|
||||||
|
- .GetTLSCert
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `DB` - 81 edges
|
||||||
|
2. `User` - 67 edges
|
||||||
|
3. `Handler` - 50 edges
|
||||||
|
4. `writeErr()` - 36 edges
|
||||||
|
5. `writeJSON()` - 35 edges
|
||||||
|
6. `Store` - 28 edges
|
||||||
|
7. `api()` - 28 edges
|
||||||
|
8. `MasterKey` - 27 edges
|
||||||
|
9. `session` - 25 edges
|
||||||
|
10. `session` - 25 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `base.html Flask-style Block Layout` --conceptually_related_to--> `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
- `Dark Tailwind CSS Custom-Property Palette` --semantically_similar_to--> `GoMail Admin SPA (index.html)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/admin/static/index.html
|
||||||
|
- `Dark Tailwind CSS Custom-Property Palette` --semantically_similar_to--> `GoMail Webmail SPA (index.html)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
- `Shared api() Fetch Helper Convention` --semantically_similar_to--> `api() fetch helper (admin)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/admin/static/index.html
|
||||||
|
- `Shared api() Fetch Helper Convention` --semantically_similar_to--> `api() fetch helper (webmail)` [INFERRED] [semantically similar]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md → internal/webmail/static/index.html
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- None detected.
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **GoMail Project Documentation Set** — gomail_handover_overview, readme_gomail, gomail_action_plan_v4_overview, _claude_iterative_build_discipline_skill_gomail_project [INFERRED 0.85]
|
||||||
|
- **Admin Portal CRUD Feature Set** — internal_admin_static_index_domains_crud, internal_admin_static_index_users_crud, internal_admin_static_index_rules_crud, internal_admin_static_index_queue_crud, internal_admin_static_index_quarantine [EXTRACTED 1.00]
|
||||||
|
- **Recurring Bug-Pattern Documentation Across GoMail Docs** — _claude_iterative_build_discipline_skill_sqlite_single_conn, gomail_handover_sqlite_deadlock_bug, gomail_action_plan_v4_sqlite_deadlock_bug, _claude_iterative_build_discipline_skill_tcp_read_pattern, gomail_handover_tcp_bug, gomail_action_plan_v4_tcp_bug [INFERRED 0.85]
|
||||||
|
|
||||||
|
## Communities (70 total, 18 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "User"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (36): MailProvider, User, GenerateSecret(), NewChallenge(), Config, DB, Duration, HandlerFunc (+28 more)
|
||||||
|
|
||||||
|
### Community 1 - "Client"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (31): AccountKey, Authorization, Challenge, ChallengeResponder, Client, directory, jwk, Order (+23 more)
|
||||||
|
|
||||||
|
### Community 2 - "webauthn.go"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (16): cborDecode(), cborDecodeWithLength(), DecodePublicKey(), EncodePublicKey(), Time, ParseAttestationObject(), ParseAuthData(), parseCOSEKey() (+8 more)
|
||||||
|
|
||||||
|
### Community 3 - "session"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (15): addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf(), matchesSearch(), parseSeqNum() (+7 more)
|
||||||
|
|
||||||
|
### Community 4 - "GmailAPIProvider"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (36): CalendarEvent, CalendarProvider, Contact, ContactProvider, Folder, FullMessage, GmailAPIProvider, gmailDateTime (+28 more)
|
||||||
|
|
||||||
|
### Community 5 - "config.go"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (36): buildOAuthConfigs(), Config, Handler, ipAllowlistMiddleware(), main(), Config, DatabaseConfig, JMAPConfig (+28 more)
|
||||||
|
|
||||||
|
### Community 6 - "parser"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (21): evalTest(), execStatements(), Execute(), lookupHeader(), newLexer(), Parse(), FuzzParse(), F (+13 more)
|
||||||
|
|
||||||
|
### Community 8 - "session"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (17): Config, Context, DB, Duration, Listener, WaitGroup, NewServer(), escapeQuoted() (+9 more)
|
||||||
|
|
||||||
|
### Community 9 - "session"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (16): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+8 more)
|
||||||
|
|
||||||
|
### Community 10 - "Handler"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (14): Context, DB, Request, ResponseWriter, ServeMux, jmapRole(), NewHandler(), splitCompositeID() (+6 more)
|
||||||
|
|
||||||
|
### Community 11 - "session"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (28): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+20 more)
|
||||||
|
|
||||||
|
### Community 12 - "writeErr"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
|
||||||
|
|
||||||
|
### Community 13 - "Client"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
|
||||||
|
|
||||||
|
### Community 14 - "Sign"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (20): KeyPair, ExtractSignatureInfo(), GenerateKeyPair(), PrivateKey, ParseDNSPublicKey(), ParsePrivateKey(), buildDKIMHeader(), canonicalizeBodyRelaxed() (+12 more)
|
||||||
|
|
||||||
|
### Community 15 - "Worker"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (25): TLSARecord, Certificate, Context, Lookup(), VerifyPeerCertificate(), Discover(), Context, Duration (+17 more)
|
||||||
|
|
||||||
|
### Community 16 - "session"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (14): Scope, state, Authenticate(), checkAppPassword(), DB, Config, Conn, Context (+6 more)
|
||||||
|
|
||||||
|
### Community 17 - "models.go"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (13): Addressbook, Alias, AppPassword, Calendar, Contact, LinkedAccountAuthType, MailboxEntry, MFABackupCode (+5 more)
|
||||||
|
|
||||||
|
### Community 18 - "Store"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (46): IMAPCredential, OAuth2Credential, keyCacheKey, MasterKey, Handler, multistatusResponse, propSet, LinkedAccount (+38 more)
|
||||||
|
|
||||||
|
### Community 19 - "uuidNew"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (3): MTASTSPolicy, Stats, uuidNew()
|
||||||
|
|
||||||
|
### Community 20 - "oauth2.go"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): Context, Reader, Request, Time, NewAuthedRequest(), truncate(), Config, Token (+2 more)
|
||||||
|
|
||||||
|
### Community 21 - "Go Web App No-Deps Pattern (skill)"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (13): go-web-app-no-deps Packaged Skill (.skill zip), base.html Flask-style Block Layout, Template Block Bleeding Bug (ParseGlob shared namespace), Static Asset Cache-Busting via Version Query Param, Dark Tailwind CSS Custom-Property Palette, JS Function-in-Conditional Scoping Bug, No-Third-Party-Dependencies Principle, Go Web App No-Deps Pattern (skill) (+5 more)
|
||||||
|
|
||||||
|
### Community 22 - "GoMail Action Plan v4 Overview"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (12): GoMail (referenced project), Wire-Format Structs Need Explicit Serialization Tags, cmd/e2etestN Disposable Test Pattern, GoMail Action Plan v4 Overview, Phase 5: IMAP/POP3/Auth, Phase 7: CalDAV/CardDAV, Phase 8: Webmail (JWT + REST API + SPA), Phase 9: JMAP Core/Mail Subset (+4 more)
|
||||||
|
|
||||||
|
### Community 23 - "QuarantineEntry"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (3): QuarantineEntry, QuarantineStatus, Time
|
||||||
|
|
||||||
|
### Community 24 - "ical.go"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
|
||||||
|
|
||||||
|
### Community 25 - ".Run"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
|
||||||
|
|
||||||
|
### Community 26 - "vcard.go"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (8): escape(), FuzzParse(), F, Parse(), splitProperty(), unescape(), unfold(), Card
|
||||||
|
|
||||||
|
### Community 27 - "Phase 11: Admin Portal"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (10): Admin Domain Creation Missing Tenant Fallback Bug, Admin User Creation Missing domain_id Bug, Phase 11: Admin Portal, Quarantine: Admin Global Discard vs Webmail Per-User Release, Domains CRUD (loadDomains/createDomain/rotateDkim/deleteDomain), Quarantine Discard (admin), Outbound Queue Actions (retry/cancel), List Rules CRUD (+2 more)
|
||||||
|
|
||||||
|
### Community 28 - "app.js"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (56): accounts, acctQuery(), addPasskey(), api(), b64urlToBuf(), bodyOf(), boot(), bufToB64url() (+48 more)
|
||||||
|
|
||||||
|
### Community 29 - "Server"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): Server, connHost(), Addr, Config, Context, DB, Duration, Limiter (+3 more)
|
||||||
|
|
||||||
|
### Community 30 - "Suggested Next Session Scope"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (9): bufio.Reader + io.ReadFull for TCP Protocol Boundaries, Genuine EICAR Byte-Level ClamAV Verification, Phase 10: OAuth2 + Gmail/M365 Multi-Account, Phase 14: Optional External Services (ClamAV/Rspamd/LLM), Phase 15: Hardening + Deploy, Token-Bucket Per-IP Rate Limiter, Fake clamd Server TCP Over-Read Bug, Suggested Next Session Scope (+1 more)
|
||||||
|
|
||||||
|
### Community 32 - "checkSPF"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
|
||||||
|
|
||||||
|
### Community 33 - "Limiter"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (5): Mutex, Limiter, Time, New(), bucket
|
||||||
|
|
||||||
|
### Community 34 - ".Run"
|
||||||
|
Cohesion: 0.36
|
||||||
|
Nodes (5): Context, Duration, rspamdResponse, RspamdStage, rspamdSymbol
|
||||||
|
|
||||||
|
### Community 35 - ".Run"
|
||||||
|
Cohesion: 0.39
|
||||||
|
Nodes (4): Context, Duration, parseClamAddr(), ClamAVStage
|
||||||
|
|
||||||
|
### Community 36 - ".Run"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (7): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), Context, DMARCStage, HeaderStage
|
||||||
|
|
||||||
|
### Community 37 - "Core Principle: Compiles is Not Correct"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): iterative-build-discipline Packaged Skill (.skill zip), Core Principle: Compiles is Not Correct, Disposable Real End-to-End Test Pattern, Genuinely-Enforcing Fake Protocol Server Pattern, Negative-Path Tests as Non-Optional, Verify Against Published Test Vectors (RFC 6238)
|
||||||
|
|
||||||
|
### Community 38 - "Phase 13: TLS + ACME + DANE/MTA-STS"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (6): Phase 13: TLS + ACME + DANE/MTA-STS, Sandbox Network/Toolchain Constraints, Immediate First Steps Setup, DNS Setup (MX/SPF/DKIM/DMARC), GoMail (Self-Hosted Email Server), TLS / ACME Quick Setup
|
||||||
|
|
||||||
|
### Community 39 - "GraphAPIProvider"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (14): GraphAPIProvider, graphDateTimeTZ, graphFolder, graphMessage, graphRecipient, Config, Contact, Context (+6 more)
|
||||||
|
|
||||||
|
### Community 40 - ".Run"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (3): dedupe(), Context, URLStage
|
||||||
|
|
||||||
|
### Community 41 - "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (5): SQLite Single-Connection-Pool Query/Exec Deadlock Pattern, Phase 12: Auth Hardening (TOTP/App Passwords/Reset), Rationale: Phase 13 Pulled Forward, ConsumeBackupCode SQLite Deadlock Bug, SQLite Query+Exec Deadlock Bug (documented)
|
||||||
|
|
||||||
|
### Community 44 - "MailContext"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (5): Message, MessageVerdict, domainOf(), IP, MailContext
|
||||||
|
|
||||||
|
### Community 47 - "Shared api() Fetch Helper Convention"
|
||||||
|
Cohesion: 1.00
|
||||||
|
Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api() fetch helper (webmail)
|
||||||
|
|
||||||
|
### Community 65 - "dnssec.go"
|
||||||
|
Cohesion: 0.38
|
||||||
|
Nodes (15): DNSKEY, DS, Context, query(), resolvers(), rrsetOf(), rrsigsOf(), Validate() (+7 more)
|
||||||
|
|
||||||
|
### Community 66 - "totp.go"
|
||||||
|
Cohesion: 0.48
|
||||||
|
Nodes (6): decodeSecret(), Generate(), Time, hotp(), ProvisioningURI(), Validate()
|
||||||
|
|
||||||
|
### Community 67 - "StageResult"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (4): CheckResult, MessageCheck, injectSpamHeaders(), StageResult
|
||||||
|
|
||||||
|
### Community 68 - "pipeline.go"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (8): DefaultStages(), Config, Context, NewOrchestrator(), StagesFromConfig(), verdictFor(), Orchestrator, Stage
|
||||||
|
|
||||||
|
## Ambiguous Edges - Review These
|
||||||
|
- `Template Renderer (fresh-instance-per-page)` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `base.html Flask-style Block Layout` → `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
- `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` → `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **33 isolated node(s):** `gomail`, `Contact`, `CalendarProvider`, `ContactProvider`, `gmailLabel` (+28 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **18 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **What is the exact relationship between `Template Renderer (fresh-instance-per-page)` and `GoMail Admin SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `base.html Flask-style Block Layout` and `GoMail Webmail SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Admin SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Webmail SPA (index.html)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **Why does `User` connect `User` to `GmailAPIProvider`, `DB`, `session`, `session`, `Handler`, `session`, `writeErr`, `session`, `models.go`, `Store`?**
|
||||||
|
_High betweenness centrality (0.297) - this node is a cross-community bridge._
|
||||||
|
- **Why does `Store` connect `Store` to `User`, `GmailAPIProvider`, `session`, `Handler`, `session`, `Worker`, `Server`?**
|
||||||
|
_High betweenness centrality (0.105) - this node is a cross-community bridge._
|
||||||
|
- **Why does `DB` connect `DB` to `.GetContact`, `StageResult`, `.GetTLSCert`, `SieveScript`, `MailContext`, `CalendarObject`, `OutboundQueueEntry`, `models.go`, `uuidNew`, `QuarantineEntry`, `Domain`, `ListRule`?**
|
||||||
|
_High betweenness centrality (0.085) - this node is a cross-community bridge._
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-09T13:36:33.949106+00:00",
|
||||||
|
"input_tokens": 134823,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 78
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 134823,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
".claude/settings.json": {
|
||||||
|
"mtime": 1786281487.088728,
|
||||||
|
"ast_hash": "",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"cmd/gomail/main.go": {
|
||||||
|
"mtime": 1786303371.9406705,
|
||||||
|
"ast_hash": "32c3d08429cc99b216f59edf3e0e4caa",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"go.mod": {
|
||||||
|
"mtime": 1786304427.3009958,
|
||||||
|
"ast_hash": "4bbf418e44c141deba9e99f9d780ed9b",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/link.go": {
|
||||||
|
"mtime": 1786299069.1242821,
|
||||||
|
"ast_hash": "5eed8ecb5be6a1c35c6591dedad976d1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider.go": {
|
||||||
|
"mtime": 1786303040.5896587,
|
||||||
|
"ast_hash": "7b4ccfea1a6fbad3e8b519a295cb520b",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_gomail.go": {
|
||||||
|
"mtime": 1786297740.5829873,
|
||||||
|
"ast_hash": "ac0d507cba9953d85a44ce0506217587",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_imap.go": {
|
||||||
|
"mtime": 1786298998.877657,
|
||||||
|
"ast_hash": "4825cd303e132e3179500afe0e9098bd",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_smtp_helper.go": {
|
||||||
|
"mtime": 1786289886.1606128,
|
||||||
|
"ast_hash": "4fec96778cdaa94b1ab2264236af8b48",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/acme/challenge.go": {
|
||||||
|
"mtime": 1786189310.0,
|
||||||
|
"ast_hash": "ec3bcb998b34bc2c8a43cb8f3880e112",
|
||||||
|
"semantic_hash": "ec3bcb998b34bc2c8a43cb8f3880e112"
|
||||||
|
},
|
||||||
|
"internal/acme/client.go": {
|
||||||
|
"mtime": 1786189290.0,
|
||||||
|
"ast_hash": "830b78389d159a75e48f1a0076b6275b",
|
||||||
|
"semantic_hash": "830b78389d159a75e48f1a0076b6275b"
|
||||||
|
},
|
||||||
|
"internal/acme/jws.go": {
|
||||||
|
"mtime": 1786189220.0,
|
||||||
|
"ast_hash": "e5aa7cbbea33d9971f1577e4c976cb8c",
|
||||||
|
"semantic_hash": "e5aa7cbbea33d9971f1577e4c976cb8c"
|
||||||
|
},
|
||||||
|
"internal/acme/obtain.go": {
|
||||||
|
"mtime": 1786189332.0,
|
||||||
|
"ast_hash": "d2f78794831ad67ba64fb79cb7eb1c00",
|
||||||
|
"semantic_hash": "d2f78794831ad67ba64fb79cb7eb1c00"
|
||||||
|
},
|
||||||
|
"internal/admin/api.go": {
|
||||||
|
"mtime": 1786281901.2009063,
|
||||||
|
"ast_hash": "14fbb199cccd839edb6cf98392226b03",
|
||||||
|
"semantic_hash": "14fbb199cccd839edb6cf98392226b03"
|
||||||
|
},
|
||||||
|
"internal/admin/embed.go": {
|
||||||
|
"mtime": 1786188384.0,
|
||||||
|
"ast_hash": "55b64461208f9449c417bddf49e686cc",
|
||||||
|
"semantic_hash": "55b64461208f9449c417bddf49e686cc"
|
||||||
|
},
|
||||||
|
"internal/admin/handlers.go": {
|
||||||
|
"mtime": 1786281901.8545587,
|
||||||
|
"ast_hash": "06aad8456678b41905ac6787e50fb15a",
|
||||||
|
"semantic_hash": "06aad8456678b41905ac6787e50fb15a"
|
||||||
|
},
|
||||||
|
"internal/auth/auth.go": {
|
||||||
|
"mtime": 1786281903.3781548,
|
||||||
|
"ast_hash": "c74ed7b257b2272dcfb860b8a60746a0",
|
||||||
|
"semantic_hash": "c74ed7b257b2272dcfb860b8a60746a0"
|
||||||
|
},
|
||||||
|
"internal/config/config.go": {
|
||||||
|
"mtime": 1786289818.8781466,
|
||||||
|
"ast_hash": "68fd6f093c4ba00106bdbbe59786a269",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/crypto/crypto.go": {
|
||||||
|
"mtime": 1786297602.7221303,
|
||||||
|
"ast_hash": "8de4f9714d1c3e73a4322080d6b1a2ca",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dav/dav.go": {
|
||||||
|
"mtime": 1786281900.5555487,
|
||||||
|
"ast_hash": "9dc713615ff99b713ad2879fca2b28dd",
|
||||||
|
"semantic_hash": "9dc713615ff99b713ad2879fca2b28dd"
|
||||||
|
},
|
||||||
|
"internal/db/bootstrap.go": {
|
||||||
|
"mtime": 1786281902.3275623,
|
||||||
|
"ast_hash": "922a08de72be0ef63409ed402d01e098",
|
||||||
|
"semantic_hash": "922a08de72be0ef63409ed402d01e098"
|
||||||
|
},
|
||||||
|
"internal/db/db.go": {
|
||||||
|
"mtime": 1786297548.1684437,
|
||||||
|
"ast_hash": "aed0290af4025c8c84089356f8746f08",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/migrations.go": {
|
||||||
|
"mtime": 1786300065.1340663,
|
||||||
|
"ast_hash": "2eb17b13bdc6bd5a954c82b4503aa8f9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/models.go": {
|
||||||
|
"mtime": 1786300091.6301413,
|
||||||
|
"ast_hash": "12f132efac1ac74ab13ce8bfce0bfc69",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/db/queries.go": {
|
||||||
|
"mtime": 1786301261.1620972,
|
||||||
|
"ast_hash": "684ba8ac79df6845b0f0571ee6ff67a3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dkim/keys.go": {
|
||||||
|
"mtime": 1785566243.0,
|
||||||
|
"ast_hash": "e8d336df9cba3a8f69c70962f8a75579",
|
||||||
|
"semantic_hash": "e8d336df9cba3a8f69c70962f8a75579"
|
||||||
|
},
|
||||||
|
"internal/dkim/sign.go": {
|
||||||
|
"mtime": 1785472923.0,
|
||||||
|
"ast_hash": "ba6dd425f253aac61b386a8aea0d2061",
|
||||||
|
"semantic_hash": "ba6dd425f253aac61b386a8aea0d2061"
|
||||||
|
},
|
||||||
|
"internal/dkim/verify.go": {
|
||||||
|
"mtime": 1785472944.0,
|
||||||
|
"ast_hash": "4e58aca20a4874dac789bc0a844bfba6",
|
||||||
|
"semantic_hash": "4e58aca20a4874dac789bc0a844bfba6"
|
||||||
|
},
|
||||||
|
"internal/ical/ical.go": {
|
||||||
|
"mtime": 1785905665.0,
|
||||||
|
"ast_hash": "2de79f76c377508c20caa2e8683e920a",
|
||||||
|
"semantic_hash": "2de79f76c377508c20caa2e8683e920a"
|
||||||
|
},
|
||||||
|
"internal/ical/ical_fuzz_test.go": {
|
||||||
|
"mtime": 1786207598.0,
|
||||||
|
"ast_hash": "5518fd86059d729048685e2bc39c24b2",
|
||||||
|
"semantic_hash": "5518fd86059d729048685e2bc39c24b2"
|
||||||
|
},
|
||||||
|
"internal/imap/commands.go": {
|
||||||
|
"mtime": 1786294683.3800013,
|
||||||
|
"ast_hash": "584a4c4fa7ad664aaec36bc404825512",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/imap/parser.go": {
|
||||||
|
"mtime": 1785638411.0,
|
||||||
|
"ast_hash": "687b7788b077688b9081f958b669a0e6",
|
||||||
|
"semantic_hash": "687b7788b077688b9081f958b669a0e6"
|
||||||
|
},
|
||||||
|
"internal/imap/server.go": {
|
||||||
|
"mtime": 1786289824.8942587,
|
||||||
|
"ast_hash": "50a8a0f58edd9d9bb6cafa385f3c97b2",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/imap/session.go": {
|
||||||
|
"mtime": 1786281910.5246246,
|
||||||
|
"ast_hash": "f06bd7a253f498fbe197951537bc589e",
|
||||||
|
"semantic_hash": "f06bd7a253f498fbe197951537bc589e"
|
||||||
|
},
|
||||||
|
"internal/imap/tokenize_fuzz_test.go": {
|
||||||
|
"mtime": 1786207631.0,
|
||||||
|
"ast_hash": "4920db9f0c2beaa06a7393ab027d3c2b",
|
||||||
|
"semantic_hash": "4920db9f0c2beaa06a7393ab027d3c2b"
|
||||||
|
},
|
||||||
|
"internal/imapclient/client.go": {
|
||||||
|
"mtime": 1786169633.0,
|
||||||
|
"ast_hash": "f9671c971ff9f39a2ed82af7ed3e87df",
|
||||||
|
"semantic_hash": "f9671c971ff9f39a2ed82af7ed3e87df"
|
||||||
|
},
|
||||||
|
"internal/jmap/jmap.go": {
|
||||||
|
"mtime": 1786281904.3499777,
|
||||||
|
"ast_hash": "2484a0bccd56908627cee4c769b689e5",
|
||||||
|
"semantic_hash": "2484a0bccd56908627cee4c769b689e5"
|
||||||
|
},
|
||||||
|
"internal/mailstore/maildir.go": {
|
||||||
|
"mtime": 1786297713.2183998,
|
||||||
|
"ast_hash": "ca8749979e4a1ca9ada23c072945189c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/managesieve/server.go": {
|
||||||
|
"mtime": 1786281899.3706038,
|
||||||
|
"ast_hash": "4ebc166b83227affe5171026b8012876",
|
||||||
|
"semantic_hash": "4ebc166b83227affe5171026b8012876"
|
||||||
|
},
|
||||||
|
"internal/managesieve/session.go": {
|
||||||
|
"mtime": 1786281899.9771776,
|
||||||
|
"ast_hash": "ab69e22b6add70fdb6c18f056c474e8d",
|
||||||
|
"semantic_hash": "ab69e22b6add70fdb6c18f056c474e8d"
|
||||||
|
},
|
||||||
|
"internal/oauth2/oauth2.go": {
|
||||||
|
"mtime": 1786298531.0961437,
|
||||||
|
"ast_hash": "4e1c7abbe92feb151acecfec03229c50",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/pipeline/pipeline.go": {
|
||||||
|
"mtime": 1786281906.8561504,
|
||||||
|
"ast_hash": "2b0b026fc13ce700c083960df7bc7857",
|
||||||
|
"semantic_hash": "2b0b026fc13ce700c083960df7bc7857"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_clamav.go": {
|
||||||
|
"mtime": 1786281905.5831316,
|
||||||
|
"ast_hash": "6a5c073d349a49e2cbe75863945a663b",
|
||||||
|
"semantic_hash": "6a5c073d349a49e2cbe75863945a663b"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_dkim.go": {
|
||||||
|
"mtime": 1786281908.573951,
|
||||||
|
"ast_hash": "3f92da867d5b128398a8ffc70b6fa475",
|
||||||
|
"semantic_hash": "3f92da867d5b128398a8ffc70b6fa475"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_dmarc.go": {
|
||||||
|
"mtime": 1786294688.9970334,
|
||||||
|
"ast_hash": "fc9ece401684fb950771b70db7a54d9d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_headers.go": {
|
||||||
|
"mtime": 1786281909.176789,
|
||||||
|
"ast_hash": "e7777ff0afde0baa97bd458060e7c002",
|
||||||
|
"semantic_hash": "e7777ff0afde0baa97bd458060e7c002"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_llm.go": {
|
||||||
|
"mtime": 1786281906.2809672,
|
||||||
|
"ast_hash": "c0a1d11888d0f32083b0e90d32018ec1",
|
||||||
|
"semantic_hash": "c0a1d11888d0f32083b0e90d32018ec1"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_rspamd.go": {
|
||||||
|
"mtime": 1786281907.8549173,
|
||||||
|
"ast_hash": "6b7c65df77472ef32c96782db5d98d07",
|
||||||
|
"semantic_hash": "6b7c65df77472ef32c96782db5d98d07"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_spf.go": {
|
||||||
|
"mtime": 1786281916.382805,
|
||||||
|
"ast_hash": "7e1eda77e5ba190bc50dda977b0c3723",
|
||||||
|
"semantic_hash": "7e1eda77e5ba190bc50dda977b0c3723"
|
||||||
|
},
|
||||||
|
"internal/pipeline/stage_url.go": {
|
||||||
|
"mtime": 1786281905.1305835,
|
||||||
|
"ast_hash": "f954d508ac085f560928c06171779db5",
|
||||||
|
"semantic_hash": "f954d508ac085f560928c06171779db5"
|
||||||
|
},
|
||||||
|
"internal/pop3/pop3.go": {
|
||||||
|
"mtime": 1786289853.1900737,
|
||||||
|
"ast_hash": "e4756d72c5bc2bd89fd41a2205ca3d14",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/queue/queue.go": {
|
||||||
|
"mtime": 1786300256.943359,
|
||||||
|
"ast_hash": "5e9631b0601474ed28f3626a293e8e08",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/ratelimit/http.go": {
|
||||||
|
"mtime": 1786289690.9789355,
|
||||||
|
"ast_hash": "039681ec4aef1340ab4ba8bf83baa679",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/ratelimit/ratelimit.go": {
|
||||||
|
"mtime": 1786207185.0,
|
||||||
|
"ast_hash": "4d5bf0b77d390cf3e71abb4d022cf0aa",
|
||||||
|
"semantic_hash": "4d5bf0b77d390cf3e71abb4d022cf0aa"
|
||||||
|
},
|
||||||
|
"internal/sieve/interp.go": {
|
||||||
|
"mtime": 1786169049.0,
|
||||||
|
"ast_hash": "6f13e1891ef551ffebba37e759742aca",
|
||||||
|
"semantic_hash": "6f13e1891ef551ffebba37e759742aca"
|
||||||
|
},
|
||||||
|
"internal/sieve/lexer.go": {
|
||||||
|
"mtime": 1786168959.0,
|
||||||
|
"ast_hash": "3d8d9f9f203855eaf8d638935f65f7bb",
|
||||||
|
"semantic_hash": "3d8d9f9f203855eaf8d638935f65f7bb"
|
||||||
|
},
|
||||||
|
"internal/sieve/parser.go": {
|
||||||
|
"mtime": 1786168977.0,
|
||||||
|
"ast_hash": "cbcd550894b5c473a160481098151605",
|
||||||
|
"semantic_hash": "cbcd550894b5c473a160481098151605"
|
||||||
|
},
|
||||||
|
"internal/sieve/sieve_fuzz_test.go": {
|
||||||
|
"mtime": 1786207613.0,
|
||||||
|
"ast_hash": "fa017deb29e67fa5264e3ecc59ede127",
|
||||||
|
"semantic_hash": "fa017deb29e67fa5264e3ecc59ede127"
|
||||||
|
},
|
||||||
|
"internal/smtp/auth.go": {
|
||||||
|
"mtime": 1786281931.1489654,
|
||||||
|
"ast_hash": "9220929f9afd4b5b8c39ee7401ea3ecd",
|
||||||
|
"semantic_hash": "9220929f9afd4b5b8c39ee7401ea3ecd"
|
||||||
|
},
|
||||||
|
"internal/smtp/server.go": {
|
||||||
|
"mtime": 1786281931.153782,
|
||||||
|
"ast_hash": "30d88aa535f6ee39072f35fa6c1fd108",
|
||||||
|
"semantic_hash": "30d88aa535f6ee39072f35fa6c1fd108"
|
||||||
|
},
|
||||||
|
"internal/smtp/session.go": {
|
||||||
|
"mtime": 1786281931.1489654,
|
||||||
|
"ast_hash": "41aae3368dc4be4eba559f0d732e9a37",
|
||||||
|
"semantic_hash": "41aae3368dc4be4eba559f0d732e9a37"
|
||||||
|
},
|
||||||
|
"internal/tlsutil/acme_manager.go": {
|
||||||
|
"mtime": 1786281902.8497763,
|
||||||
|
"ast_hash": "5e7aa10c39162f5d95f927ba4efc30fb",
|
||||||
|
"semantic_hash": "5e7aa10c39162f5d95f927ba4efc30fb"
|
||||||
|
},
|
||||||
|
"internal/tlsutil/selfsigned.go": {
|
||||||
|
"mtime": 1786193053.0,
|
||||||
|
"ast_hash": "9c58f78ed11e4c7bf208306285f0b32e",
|
||||||
|
"semantic_hash": "9c58f78ed11e4c7bf208306285f0b32e"
|
||||||
|
},
|
||||||
|
"internal/totp/totp.go": {
|
||||||
|
"mtime": 1786193280.0,
|
||||||
|
"ast_hash": "48f2c9db1c1307f13d78726aa3b24f6a",
|
||||||
|
"semantic_hash": "48f2c9db1c1307f13d78726aa3b24f6a"
|
||||||
|
},
|
||||||
|
"internal/vcard/vcard.go": {
|
||||||
|
"mtime": 1785905650.0,
|
||||||
|
"ast_hash": "8b0043626bac3cacc11b6ce76259445b",
|
||||||
|
"semantic_hash": "8b0043626bac3cacc11b6ce76259445b"
|
||||||
|
},
|
||||||
|
"internal/vcard/vcard_fuzz_test.go": {
|
||||||
|
"mtime": 1786207583.0,
|
||||||
|
"ast_hash": "5aeab637a3c3357bb2c13cacc21508c6",
|
||||||
|
"semantic_hash": "5aeab637a3c3357bb2c13cacc21508c6"
|
||||||
|
},
|
||||||
|
"internal/webmail/api.go": {
|
||||||
|
"mtime": 1786303416.0629382,
|
||||||
|
"ast_hash": "c3894a16e34ac088b8e9922fa14c91c5",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webmail/embed.go": {
|
||||||
|
"mtime": 1786295803.857812,
|
||||||
|
"ast_hash": "78663d0210a610d1777c4b4151390cf1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webtoken/webtoken.go": {
|
||||||
|
"mtime": 1786289648.699828,
|
||||||
|
"ast_hash": "b9be7cc5ea74ce6b82de79ef3644cb52",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".claude/go-web-app-no-deps-SKILL.md": {
|
||||||
|
"mtime": 1786206554.0,
|
||||||
|
"ast_hash": "eb54ace954e819a069a9f1284485d213",
|
||||||
|
"semantic_hash": "eb54ace954e819a069a9f1284485d213"
|
||||||
|
},
|
||||||
|
".claude/go-web-app-no-deps.skill": {
|
||||||
|
"mtime": 1786206554.0,
|
||||||
|
"ast_hash": "de820100f8ad2da76d57884262c9f5f0",
|
||||||
|
"semantic_hash": "de820100f8ad2da76d57884262c9f5f0"
|
||||||
|
},
|
||||||
|
".claude/iterative-build-discipline-SKILL.md": {
|
||||||
|
"mtime": 1786206504.0,
|
||||||
|
"ast_hash": "d0e94862b77af5f6ac99c3f2668db3c9",
|
||||||
|
"semantic_hash": "d0e94862b77af5f6ac99c3f2668db3c9"
|
||||||
|
},
|
||||||
|
".claude/iterative-build-discipline.skill": {
|
||||||
|
"mtime": 1786206504.0,
|
||||||
|
"ast_hash": "87a25b626d37a9dfe5c037ae409fecad",
|
||||||
|
"semantic_hash": "87a25b626d37a9dfe5c037ae409fecad"
|
||||||
|
},
|
||||||
|
"CLAUDE.md": {
|
||||||
|
"mtime": 1786281487.0884602,
|
||||||
|
"ast_hash": "82efb97a359f5c7290bf2513d14686be",
|
||||||
|
"semantic_hash": "82efb97a359f5c7290bf2513d14686be"
|
||||||
|
},
|
||||||
|
"GOMAIL_HANDOVER.md": {
|
||||||
|
"mtime": 1786206496.0,
|
||||||
|
"ast_hash": "81fbcb0478fce27c6b6e8c6646eed44a",
|
||||||
|
"semantic_hash": "81fbcb0478fce27c6b6e8c6646eed44a"
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"mtime": 1786208021.0,
|
||||||
|
"ast_hash": "257dbe428b6a41b51d4d8efcd2431615",
|
||||||
|
"semantic_hash": "257dbe428b6a41b51d4d8efcd2431615"
|
||||||
|
},
|
||||||
|
"gomail-action-plan-v4.md": {
|
||||||
|
"mtime": 1786206496.0,
|
||||||
|
"ast_hash": "203046a9566fb3c392ed2e51aa178805",
|
||||||
|
"semantic_hash": "203046a9566fb3c392ed2e51aa178805"
|
||||||
|
},
|
||||||
|
"internal/admin/static/index.html": {
|
||||||
|
"mtime": 1786188708.0,
|
||||||
|
"ast_hash": "77b0b0e42cf45a961571ee5e3a3a219a",
|
||||||
|
"semantic_hash": "77b0b0e42cf45a961571ee5e3a3a219a"
|
||||||
|
},
|
||||||
|
"internal/webmail/static/index.html": {
|
||||||
|
"mtime": 1786302422.3206468,
|
||||||
|
"ast_hash": "240f0802126ee6142b639b0c3d1fd749",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webmail/static/app.js": {
|
||||||
|
"mtime": 1786302477.0202506,
|
||||||
|
"ast_hash": "8b8643e290174bcaab0192971680383d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_gmailapi.go": {
|
||||||
|
"mtime": 1786303230.0544362,
|
||||||
|
"ast_hash": "991624e71e139cb871a1c549e7b2583a",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_graphapi.go": {
|
||||||
|
"mtime": 1786303329.356412,
|
||||||
|
"ast_hash": "4c7aca4b1ab67e5629fdd094e59caae4",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dane/dane.go": {
|
||||||
|
"mtime": 1786304412.353843,
|
||||||
|
"ast_hash": "077c045db0d9c08bcb1c91fda5f095ce",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/mtasts/mtasts.go": {
|
||||||
|
"mtime": 1786300014.9348822,
|
||||||
|
"ast_hash": "832952ea8843b31f4f8a01ffa3cfeb7f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/cbor.go": {
|
||||||
|
"mtime": 1786301093.0761018,
|
||||||
|
"ast_hash": "79612ad8167cf6a9e6e18977f1d90f0f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/webauthn.go": {
|
||||||
|
"mtime": 1786301194.5917418,
|
||||||
|
"ast_hash": "f9abf441c2637cdb4bdfd28b358417a3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dnssec/dnssec.go": {
|
||||||
|
"mtime": 1786338656.3355203,
|
||||||
|
"ast_hash": "0041911983b254c6ba9b78906f3055c3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
-170
@@ -1,85 +1,96 @@
|
|||||||
# Graph Report - . (2026-08-09)
|
# Graph Report - webmail (2026-08-10)
|
||||||
|
|
||||||
## Corpus Check
|
## Corpus Check
|
||||||
- 78 files · ~64,934 words
|
- 85 files · ~82,339 words
|
||||||
- Verdict: corpus is large enough that graph structure adds value.
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- 953 nodes · 2017 edges · 64 communities (48 shown, 16 thin omitted)
|
- 1169 nodes · 2614 edges · 70 communities (52 shown, 18 thin omitted)
|
||||||
- Extraction: 91% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 170 edges (avg confidence: 0.82)
|
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 203 edges (avg confidence: 0.81)
|
||||||
- Token cost: 134,823 input · 0 output
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `d7ca591b`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
## Community Hubs (Navigation)
|
## Community Hubs (Navigation)
|
||||||
- TOTP & Web Token Auth
|
- User
|
||||||
- ACME/JWS Client
|
- Client
|
||||||
- CalDAV/CardDAV Handlers
|
- webauthn.go
|
||||||
- IMAP Command Parser
|
- session
|
||||||
- Mail Provider Abstraction
|
- GmailAPIProvider
|
||||||
- Server Config & Bootstrap
|
- config.go
|
||||||
- Sieve Filter Interpreter
|
- parser
|
||||||
- DB Mutation Queries
|
- DB
|
||||||
- IMAP Server Loop
|
- session
|
||||||
- POP3 Server Loop
|
- session
|
||||||
- JMAP Auth & Sessions
|
- Handler
|
||||||
- SMTP Session Header Parsing
|
- session
|
||||||
- Admin API Handlers
|
- writeErr
|
||||||
- IMAP Client
|
- Client
|
||||||
- DKIM Key Signing
|
- Sign
|
||||||
- Outbound Delivery Queue
|
- Worker
|
||||||
- SMTP Server Networking
|
- session
|
||||||
- Linked Account & Alias Queries
|
- models.go
|
||||||
- TCP Server Lifecycle
|
- Store
|
||||||
- Calendar/Contact/TLS Queries
|
- uuidNew
|
||||||
- OAuth2 Config
|
- oauth2.go
|
||||||
- Go-No-Deps Web App Pattern
|
- Go Web App No-Deps Pattern (skill)
|
||||||
- GoMail Build Phases Overview
|
- GoMail Action Plan v4 Overview
|
||||||
- Quarantine & Message Queries
|
- QuarantineEntry
|
||||||
- iCal Parsing & Fuzzing
|
- ical.go
|
||||||
- LLM Spam Stage
|
- .Run
|
||||||
- vCard Parsing & Fuzzing
|
- vcard.go
|
||||||
- Admin Portal Bugs & CRUD
|
- Phase 11: Admin Portal
|
||||||
- Spam Pipeline Orchestrator
|
- app.js
|
||||||
- SPF Stage
|
- Server
|
||||||
- TCP Boundary & Rate-Limit Findings
|
- Suggested Next Session Scope
|
||||||
- Domain/Tenant Queries
|
- Domain
|
||||||
- Rspamd Stage
|
- checkSPF
|
||||||
- Per-IP Rate Limiter
|
- Limiter
|
||||||
- ClamAV Stage
|
- .Run
|
||||||
- DMARC Stage
|
- .Run
|
||||||
- Spam Header Injection Stage
|
- .Run
|
||||||
- Iterative Build Discipline Skill
|
- Core Principle: Compiles is Not Correct
|
||||||
- Deployment & DNS Setup
|
- Phase 13: TLS + ACME + DANE/MTA-STS
|
||||||
- HTTP Middleware
|
- GraphAPIProvider
|
||||||
- URL Extraction Stage
|
- .Run
|
||||||
- SQLite Deadlock Findings
|
- Phase 12: Auth Hardening (TOTP/App Passwords/Reset)
|
||||||
- List Rule Queries
|
- gomail
|
||||||
- Sieve Script Queries
|
- SieveScript
|
||||||
- Mail Context Domain Helpers
|
- MailContext
|
||||||
- Calendar Object Queries
|
- CalendarObject
|
||||||
- Contact Queries
|
- OutboundQueueEntry
|
||||||
- Shared api() Fetch Convention
|
- Shared api() Fetch Helper Convention
|
||||||
- Session Continuity Practices
|
- Checkpoint Working State as Durable Artifact
|
||||||
- Scope Prioritization Practices
|
- Prioritize by Risk Reduction Over Task Order
|
||||||
- DB Migrations
|
- migration
|
||||||
- Shared esc() Helper
|
- esc() HTML-escape helper (admin)
|
||||||
- SPA Login/Logout
|
- login() (admin)
|
||||||
- SPA App Boot/Routing
|
- showApp()/showLogin() (admin)
|
||||||
- Graphify Project Rules
|
- graphify Project Rules
|
||||||
- Admin SPA Router
|
- showPage() router (admin)
|
||||||
- Raw MIME Body Extractor
|
- bodyOf() raw MIME body extractor
|
||||||
- Go Module Root
|
- ListRule
|
||||||
|
- .GetContact
|
||||||
|
- dnssec.go
|
||||||
|
- totp.go
|
||||||
|
- StageResult
|
||||||
|
- pipeline.go
|
||||||
|
- .GetTLSCert
|
||||||
|
|
||||||
## God Nodes (most connected - your core abstractions)
|
## God Nodes (most connected - your core abstractions)
|
||||||
1. `DB` - 77 edges
|
1. `DB` - 81 edges
|
||||||
2. `User` - 56 edges
|
2. `User` - 67 edges
|
||||||
3. `Handler` - 35 edges
|
3. `Handler` - 50 edges
|
||||||
4. `Store` - 27 edges
|
4. `writeErr()` - 36 edges
|
||||||
5. `session` - 25 edges
|
5. `writeJSON()` - 35 edges
|
||||||
6. `session` - 25 edges
|
6. `Store` - 28 edges
|
||||||
7. `writeJSON()` - 24 edges
|
7. `api()` - 28 edges
|
||||||
8. `writeErr()` - 24 edges
|
8. `MasterKey` - 27 edges
|
||||||
9. `MasterKey` - 23 edges
|
9. `session` - 25 edges
|
||||||
10. `session` - 20 edges
|
10. `session` - 25 edges
|
||||||
|
|
||||||
## Surprising Connections (you probably didn't know these)
|
## Surprising Connections (you probably didn't know these)
|
||||||
- `base.html Flask-style Block Layout` --conceptually_related_to--> `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
- `base.html Flask-style Block Layout` --conceptually_related_to--> `GoMail Webmail SPA (index.html)` [AMBIGUOUS]
|
||||||
@@ -101,176 +112,192 @@
|
|||||||
- **Admin Portal CRUD Feature Set** — internal_admin_static_index_domains_crud, internal_admin_static_index_users_crud, internal_admin_static_index_rules_crud, internal_admin_static_index_queue_crud, internal_admin_static_index_quarantine [EXTRACTED 1.00]
|
- **Admin Portal CRUD Feature Set** — internal_admin_static_index_domains_crud, internal_admin_static_index_users_crud, internal_admin_static_index_rules_crud, internal_admin_static_index_queue_crud, internal_admin_static_index_quarantine [EXTRACTED 1.00]
|
||||||
- **Recurring Bug-Pattern Documentation Across GoMail Docs** — _claude_iterative_build_discipline_skill_sqlite_single_conn, gomail_handover_sqlite_deadlock_bug, gomail_action_plan_v4_sqlite_deadlock_bug, _claude_iterative_build_discipline_skill_tcp_read_pattern, gomail_handover_tcp_bug, gomail_action_plan_v4_tcp_bug [INFERRED 0.85]
|
- **Recurring Bug-Pattern Documentation Across GoMail Docs** — _claude_iterative_build_discipline_skill_sqlite_single_conn, gomail_handover_sqlite_deadlock_bug, gomail_action_plan_v4_sqlite_deadlock_bug, _claude_iterative_build_discipline_skill_tcp_read_pattern, gomail_handover_tcp_bug, gomail_action_plan_v4_tcp_bug [INFERRED 0.85]
|
||||||
|
|
||||||
## Communities (64 total, 16 thin omitted)
|
## Communities (70 total, 18 thin omitted)
|
||||||
|
|
||||||
### Community 0 - "TOTP & Web Token Auth"
|
### Community 0 - "User"
|
||||||
Cohesion: 0.10
|
Cohesion: 0.10
|
||||||
Nodes (34): User, decodeSecret(), Generate(), GenerateSecret(), Time, hotp(), ProvisioningURI(), Validate() (+26 more)
|
Nodes (37): MailProvider, User, NewGoMailProvider(), GenerateSecret(), NewChallenge(), Config, DB, Duration (+29 more)
|
||||||
|
|
||||||
### Community 1 - "ACME/JWS Client"
|
### Community 1 - "Client"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.06
|
||||||
Nodes (31): AccountKey, Authorization, Challenge, ChallengeResponder, Client, directory, jwk, Order (+23 more)
|
Nodes (31): AccountKey, Authorization, Challenge, ChallengeResponder, Client, directory, jwk, Order (+23 more)
|
||||||
|
|
||||||
### Community 2 - "CalDAV/CardDAV Handlers"
|
### Community 2 - "webauthn.go"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.17
|
||||||
Nodes (34): MailProvider, MasterKey, Handler, multistatusResponse, propSet, LinkedAccountProvider, Config, DB (+26 more)
|
Nodes (16): cborDecode(), cborDecodeWithLength(), DecodePublicKey(), EncodePublicKey(), Time, ParseAttestationObject(), ParseAuthData(), parseCOSEKey() (+8 more)
|
||||||
|
|
||||||
### Community 3 - "IMAP Command Parser"
|
### Community 3 - "session"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.11
|
||||||
Nodes (24): MailboxEntry, state, addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf() (+16 more)
|
Nodes (15): addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf(), matchesSearch(), parseSeqNum() (+7 more)
|
||||||
|
|
||||||
### Community 4 - "Mail Provider Abstraction"
|
### Community 4 - "GmailAPIProvider"
|
||||||
Cohesion: 0.09
|
Cohesion: 0.07
|
||||||
Nodes (25): Folder, FullMessage, GoMailProvider, IMAPCredential, IMAPProvider, ListOpts, MessageHeader, OAuth2Credential (+17 more)
|
Nodes (35): CalendarEvent, CalendarProvider, Contact, ContactProvider, Folder, FullMessage, GmailAPIProvider, gmailDateTime (+27 more)
|
||||||
|
|
||||||
### Community 5 - "Server Config & Bootstrap"
|
### Community 5 - "config.go"
|
||||||
Cohesion: 0.09
|
Cohesion: 0.07
|
||||||
Nodes (30): buildOAuthConfigs(), Config, main(), Config, DatabaseConfig, JMAPConfig, LinkedAccountsConfig, NotifyConfig (+22 more)
|
Nodes (36): buildOAuthConfigs(), Config, Handler, ipAllowlistMiddleware(), main(), Config, DatabaseConfig, JMAPConfig (+28 more)
|
||||||
|
|
||||||
### Community 6 - "Sieve Filter Interpreter"
|
### Community 6 - "parser"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (20): evalTest(), execStatements(), Execute(), lookupHeader(), newLexer(), Parse(), FuzzParse(), F (+12 more)
|
Nodes (21): evalTest(), execStatements(), Execute(), lookupHeader(), newLexer(), Parse(), FuzzParse(), F (+13 more)
|
||||||
|
|
||||||
### Community 8 - "IMAP Server Loop"
|
### Community 8 - "session"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (17): Config, Context, DB, Duration, Listener, WaitGroup, NewServer(), escapeQuoted() (+9 more)
|
Nodes (17): Config, Context, DB, Duration, Listener, WaitGroup, NewServer(), escapeQuoted() (+9 more)
|
||||||
|
|
||||||
### Community 9 - "POP3 Server Loop"
|
### Community 9 - "session"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.13
|
||||||
Nodes (13): Config, Conn, Context, DB, Duration, Listener, ReadWriter, WaitGroup (+5 more)
|
Nodes (16): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+8 more)
|
||||||
|
|
||||||
### Community 10 - "JMAP Auth & Sessions"
|
### Community 10 - "Handler"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.19
|
||||||
Nodes (20): Scope, Authenticate(), checkAppPassword(), DB, Context, DB, Request, ResponseWriter (+12 more)
|
Nodes (14): Context, DB, Request, ResponseWriter, ServeMux, jmapRole(), NewHandler(), splitCompositeID() (+6 more)
|
||||||
|
|
||||||
### Community 11 - "SMTP Session Header Parsing"
|
### Community 11 - "session"
|
||||||
Cohesion: 0.16
|
Cohesion: 0.09
|
||||||
Nodes (15): extractHeader(), extractHeaderMap(), extractMessageID(), extractSubject(), Conn, Context, IP, ReadWriter (+7 more)
|
Nodes (28): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+20 more)
|
||||||
|
|
||||||
### Community 12 - "Admin API Handlers"
|
### Community 12 - "writeErr"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.20
|
||||||
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
|
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
|
||||||
|
|
||||||
### Community 13 - "IMAP Client"
|
### Community 13 - "Client"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.15
|
||||||
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
|
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
|
||||||
|
|
||||||
### Community 14 - "DKIM Key Signing"
|
### Community 14 - "Sign"
|
||||||
Cohesion: 0.14
|
Cohesion: 0.14
|
||||||
Nodes (20): KeyPair, ExtractSignatureInfo(), GenerateKeyPair(), PrivateKey, ParseDNSPublicKey(), ParsePrivateKey(), buildDKIMHeader(), canonicalizeBodyRelaxed() (+12 more)
|
Nodes (20): KeyPair, ExtractSignatureInfo(), GenerateKeyPair(), PrivateKey, ParseDNSPublicKey(), ParsePrivateKey(), buildDKIMHeader(), canonicalizeBodyRelaxed() (+12 more)
|
||||||
|
|
||||||
### Community 15 - "Outbound Delivery Queue"
|
### Community 15 - "Worker"
|
||||||
Cohesion: 0.18
|
Cohesion: 0.10
|
||||||
Nodes (11): backoffDuration(), domainOf(), DB, Duration, isPermanentError(), lookupMXHosts(), NewWorker(), Deliverer (+3 more)
|
Nodes (25): TLSARecord, Certificate, Context, Lookup(), VerifyPeerCertificate(), Discover(), Context, Duration (+17 more)
|
||||||
|
|
||||||
### Community 16 - "SMTP Server Networking"
|
### Community 16 - "session"
|
||||||
Cohesion: 0.19
|
Cohesion: 0.12
|
||||||
Nodes (14): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+6 more)
|
Nodes (14): Scope, state, Authenticate(), checkAppPassword(), DB, Config, Conn, Context (+6 more)
|
||||||
|
|
||||||
### Community 17 - "Linked Account & Alias Queries"
|
### Community 17 - "models.go"
|
||||||
Cohesion: 0.14
|
Cohesion: 0.15
|
||||||
Nodes (11): Alias, AppPassword, CheckResult, LinkedAccount, LinkedAccountAuthType, MessageCheck, MFABackupCode, ReleaseToken (+3 more)
|
Nodes (13): Addressbook, Alias, AppPassword, Calendar, Contact, LinkedAccountAuthType, MailboxEntry, MFABackupCode (+5 more)
|
||||||
|
|
||||||
### Community 18 - "TCP Server Lifecycle"
|
### Community 18 - "Store"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (46): IMAPCredential, OAuth2Credential, keyCacheKey, MasterKey, Handler, multistatusResponse, propSet, LinkedAccount (+38 more)
|
||||||
|
|
||||||
|
### Community 19 - "uuidNew"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (3): MTASTSPolicy, Stats, uuidNew()
|
||||||
|
|
||||||
|
### Community 20 - "oauth2.go"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.20
|
||||||
Nodes (11): Server, connHost(), Addr, Config, Context, DB, Duration, Limiter (+3 more)
|
Nodes (10): Context, Reader, Request, Time, NewAuthedRequest(), truncate(), Config, Token (+2 more)
|
||||||
|
|
||||||
### Community 19 - "Calendar/Contact/TLS Queries"
|
### Community 21 - "Go Web App No-Deps Pattern (skill)"
|
||||||
Cohesion: 0.19
|
|
||||||
Nodes (6): Addressbook, Calendar, OwnerType, Stats, TLSCert, uuidNew()
|
|
||||||
|
|
||||||
### Community 20 - "OAuth2 Config"
|
|
||||||
Cohesion: 0.23
|
|
||||||
Nodes (9): Context, Time, truncate(), WellKnownEndpoints(), XOAUTH2SASLString(), Config, Token, tokenResponse (+1 more)
|
|
||||||
|
|
||||||
### Community 21 - "Go-No-Deps Web App Pattern"
|
|
||||||
Cohesion: 0.21
|
Cohesion: 0.21
|
||||||
Nodes (13): go-web-app-no-deps Packaged Skill (.skill zip), base.html Flask-style Block Layout, Template Block Bleeding Bug (ParseGlob shared namespace), Static Asset Cache-Busting via Version Query Param, Dark Tailwind CSS Custom-Property Palette, JS Function-in-Conditional Scoping Bug, No-Third-Party-Dependencies Principle, Go Web App No-Deps Pattern (skill) (+5 more)
|
Nodes (13): go-web-app-no-deps Packaged Skill (.skill zip), base.html Flask-style Block Layout, Template Block Bleeding Bug (ParseGlob shared namespace), Static Asset Cache-Busting via Version Query Param, Dark Tailwind CSS Custom-Property Palette, JS Function-in-Conditional Scoping Bug, No-Third-Party-Dependencies Principle, Go Web App No-Deps Pattern (skill) (+5 more)
|
||||||
|
|
||||||
### Community 22 - "GoMail Build Phases Overview"
|
### Community 22 - "GoMail Action Plan v4 Overview"
|
||||||
Cohesion: 0.21
|
Cohesion: 0.21
|
||||||
Nodes (12): GoMail (referenced project), Wire-Format Structs Need Explicit Serialization Tags, cmd/e2etestN Disposable Test Pattern, GoMail Action Plan v4 Overview, Phase 5: IMAP/POP3/Auth, Phase 7: CalDAV/CardDAV, Phase 8: Webmail (JWT + REST API + SPA), Phase 9: JMAP Core/Mail Subset (+4 more)
|
Nodes (12): GoMail (referenced project), Wire-Format Structs Need Explicit Serialization Tags, cmd/e2etestN Disposable Test Pattern, GoMail Action Plan v4 Overview, Phase 5: IMAP/POP3/Auth, Phase 7: CalDAV/CardDAV, Phase 8: Webmail (JWT + REST API + SPA), Phase 9: JMAP Core/Mail Subset (+4 more)
|
||||||
|
|
||||||
### Community 23 - "Quarantine & Message Queries"
|
### Community 23 - "QuarantineEntry"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.22
|
||||||
Nodes (5): Message, MessageVerdict, QuarantineEntry, QuarantineStatus, Time
|
Nodes (3): QuarantineEntry, QuarantineStatus, Time
|
||||||
|
|
||||||
### Community 24 - "iCal Parsing & Fuzzing"
|
### Community 24 - "ical.go"
|
||||||
Cohesion: 0.24
|
Cohesion: 0.24
|
||||||
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
|
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
|
||||||
|
|
||||||
### Community 25 - "LLM Spam Stage"
|
### Community 25 - ".Run"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
|
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
|
||||||
|
|
||||||
### Community 26 - "vCard Parsing & Fuzzing"
|
### Community 26 - "vcard.go"
|
||||||
Cohesion: 0.27
|
Cohesion: 0.27
|
||||||
Nodes (8): escape(), FuzzParse(), F, Parse(), splitProperty(), unescape(), unfold(), Card
|
Nodes (8): escape(), FuzzParse(), F, Parse(), splitProperty(), unescape(), unfold(), Card
|
||||||
|
|
||||||
### Community 27 - "Admin Portal Bugs & CRUD"
|
### Community 27 - "Phase 11: Admin Portal"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (10): Admin Domain Creation Missing Tenant Fallback Bug, Admin User Creation Missing domain_id Bug, Phase 11: Admin Portal, Quarantine: Admin Global Discard vs Webmail Per-User Release, Domains CRUD (loadDomains/createDomain/rotateDkim/deleteDomain), Quarantine Discard (admin), Outbound Queue Actions (retry/cancel), List Rules CRUD (+2 more)
|
Nodes (10): Admin Domain Creation Missing Tenant Fallback Bug, Admin User Creation Missing domain_id Bug, Phase 11: Admin Portal, Quarantine: Admin Global Discard vs Webmail Per-User Release, Domains CRUD (loadDomains/createDomain/rotateDkim/deleteDomain), Quarantine Discard (admin), Outbound Queue Actions (retry/cancel), List Rules CRUD (+2 more)
|
||||||
|
|
||||||
### Community 28 - "Spam Pipeline Orchestrator"
|
### Community 28 - "app.js"
|
||||||
Cohesion: 0.40
|
Cohesion: 0.10
|
||||||
Nodes (8): DefaultStages(), Config, Context, NewOrchestrator(), StagesFromConfig(), verdictFor(), Orchestrator, Stage
|
Nodes (56): accounts, acctQuery(), addPasskey(), api(), b64urlToBuf(), bodyOf(), boot(), bufToB64url() (+48 more)
|
||||||
|
|
||||||
### Community 29 - "SPF Stage"
|
### Community 29 - "Server"
|
||||||
Cohesion: 0.40
|
Cohesion: 0.18
|
||||||
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
|
Nodes (11): Server, connHost(), Addr, Config, Context, DB, Duration, Limiter (+3 more)
|
||||||
|
|
||||||
### Community 30 - "TCP Boundary & Rate-Limit Findings"
|
### Community 30 - "Suggested Next Session Scope"
|
||||||
Cohesion: 0.25
|
Cohesion: 0.25
|
||||||
Nodes (9): bufio.Reader + io.ReadFull for TCP Protocol Boundaries, Genuine EICAR Byte-Level ClamAV Verification, Phase 10: OAuth2 + Gmail/M365 Multi-Account, Phase 14: Optional External Services (ClamAV/Rspamd/LLM), Phase 15: Hardening + Deploy, Token-Bucket Per-IP Rate Limiter, Fake clamd Server TCP Over-Read Bug, Suggested Next Session Scope (+1 more)
|
Nodes (9): bufio.Reader + io.ReadFull for TCP Protocol Boundaries, Genuine EICAR Byte-Level ClamAV Verification, Phase 10: OAuth2 + Gmail/M365 Multi-Account, Phase 14: Optional External Services (ClamAV/Rspamd/LLM), Phase 15: Hardening + Deploy, Token-Bucket Per-IP Rate Limiter, Fake clamd Server TCP Over-Read Bug, Suggested Next Session Scope (+1 more)
|
||||||
|
|
||||||
### Community 32 - "Rspamd Stage"
|
### Community 32 - "checkSPF"
|
||||||
Cohesion: 0.36
|
Cohesion: 0.40
|
||||||
Nodes (5): Context, Duration, rspamdResponse, RspamdStage, rspamdSymbol
|
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
|
||||||
|
|
||||||
### Community 33 - "Per-IP Rate Limiter"
|
### Community 33 - "Limiter"
|
||||||
Cohesion: 0.33
|
Cohesion: 0.33
|
||||||
Nodes (5): Mutex, Limiter, Time, New(), bucket
|
Nodes (5): Mutex, Limiter, Time, New(), bucket
|
||||||
|
|
||||||
### Community 34 - "ClamAV Stage"
|
### Community 34 - ".Run"
|
||||||
|
Cohesion: 0.36
|
||||||
|
Nodes (5): Context, Duration, rspamdResponse, RspamdStage, rspamdSymbol
|
||||||
|
|
||||||
|
### Community 35 - ".Run"
|
||||||
Cohesion: 0.39
|
Cohesion: 0.39
|
||||||
Nodes (4): Context, Duration, parseClamAddr(), ClamAVStage
|
Nodes (4): Context, Duration, parseClamAddr(), ClamAVStage
|
||||||
|
|
||||||
### Community 35 - "DMARC Stage"
|
### Community 36 - ".Run"
|
||||||
Cohesion: 0.39
|
Cohesion: 0.22
|
||||||
Nodes (5): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), DMARCStage
|
Nodes (7): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), Context, DMARCStage, HeaderStage
|
||||||
|
|
||||||
### Community 36 - "Spam Header Injection Stage"
|
### Community 37 - "Core Principle: Compiles is Not Correct"
|
||||||
Cohesion: 0.33
|
|
||||||
Nodes (4): Context, injectSpamHeaders(), HeaderStage, StageResult
|
|
||||||
|
|
||||||
### Community 37 - "Iterative Build Discipline Skill"
|
|
||||||
Cohesion: 0.33
|
Cohesion: 0.33
|
||||||
Nodes (6): iterative-build-discipline Packaged Skill (.skill zip), Core Principle: Compiles is Not Correct, Disposable Real End-to-End Test Pattern, Genuinely-Enforcing Fake Protocol Server Pattern, Negative-Path Tests as Non-Optional, Verify Against Published Test Vectors (RFC 6238)
|
Nodes (6): iterative-build-discipline Packaged Skill (.skill zip), Core Principle: Compiles is Not Correct, Disposable Real End-to-End Test Pattern, Genuinely-Enforcing Fake Protocol Server Pattern, Negative-Path Tests as Non-Optional, Verify Against Published Test Vectors (RFC 6238)
|
||||||
|
|
||||||
### Community 38 - "Deployment & DNS Setup"
|
### Community 38 - "Phase 13: TLS + ACME + DANE/MTA-STS"
|
||||||
Cohesion: 0.47
|
Cohesion: 0.47
|
||||||
Nodes (6): Phase 13: TLS + ACME + DANE/MTA-STS, Sandbox Network/Toolchain Constraints, Immediate First Steps Setup, DNS Setup (MX/SPF/DKIM/DMARC), GoMail (Self-Hosted Email Server), TLS / ACME Quick Setup
|
Nodes (6): Phase 13: TLS + ACME + DANE/MTA-STS, Sandbox Network/Toolchain Constraints, Immediate First Steps Setup, DNS Setup (MX/SPF/DKIM/DMARC), GoMail (Self-Hosted Email Server), TLS / ACME Quick Setup
|
||||||
|
|
||||||
### Community 39 - "HTTP Middleware"
|
### Community 39 - "GraphAPIProvider"
|
||||||
Cohesion: 0.33
|
Cohesion: 0.17
|
||||||
Nodes (4): Handler, clientIP(), Limiter, Request
|
Nodes (14): GraphAPIProvider, graphDateTimeTZ, graphFolder, graphMessage, graphRecipient, Config, Contact, Context (+6 more)
|
||||||
|
|
||||||
### Community 40 - "URL Extraction Stage"
|
### Community 40 - ".Run"
|
||||||
Cohesion: 0.47
|
Cohesion: 0.47
|
||||||
Nodes (3): dedupe(), Context, URLStage
|
Nodes (3): dedupe(), Context, URLStage
|
||||||
|
|
||||||
### Community 41 - "SQLite Deadlock Findings"
|
### Community 41 - "Phase 12: Auth Hardening (TOTP/App Passwords/Reset)"
|
||||||
Cohesion: 0.50
|
Cohesion: 0.50
|
||||||
Nodes (5): SQLite Single-Connection-Pool Query/Exec Deadlock Pattern, Phase 12: Auth Hardening (TOTP/App Passwords/Reset), Rationale: Phase 13 Pulled Forward, ConsumeBackupCode SQLite Deadlock Bug, SQLite Query+Exec Deadlock Bug (documented)
|
Nodes (5): SQLite Single-Connection-Pool Query/Exec Deadlock Pattern, Phase 12: Auth Hardening (TOTP/App Passwords/Reset), Rationale: Phase 13 Pulled Forward, ConsumeBackupCode SQLite Deadlock Bug, SQLite Query+Exec Deadlock Bug (documented)
|
||||||
|
|
||||||
### Community 44 - "Mail Context Domain Helpers"
|
### Community 44 - "MailContext"
|
||||||
Cohesion: 0.50
|
Cohesion: 0.31
|
||||||
Nodes (3): domainOf(), IP, MailContext
|
Nodes (5): Message, MessageVerdict, domainOf(), IP, MailContext
|
||||||
|
|
||||||
### Community 47 - "Shared api() Fetch Convention"
|
### Community 47 - "Shared api() Fetch Helper Convention"
|
||||||
Cohesion: 1.00
|
Cohesion: 1.00
|
||||||
Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api() fetch helper (webmail)
|
Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api() fetch helper (webmail)
|
||||||
|
|
||||||
|
### Community 65 - "dnssec.go"
|
||||||
|
Cohesion: 0.38
|
||||||
|
Nodes (15): DNSKEY, DS, Context, query(), resolvers(), rrsetOf(), rrsigsOf(), Validate() (+7 more)
|
||||||
|
|
||||||
|
### Community 66 - "totp.go"
|
||||||
|
Cohesion: 0.48
|
||||||
|
Nodes (6): decodeSecret(), Generate(), Time, hotp(), ProvisioningURI(), Validate()
|
||||||
|
|
||||||
|
### Community 67 - "StageResult"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (4): CheckResult, MessageCheck, injectSpamHeaders(), StageResult
|
||||||
|
|
||||||
|
### Community 68 - "pipeline.go"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (8): DefaultStages(), Config, Context, NewOrchestrator(), StagesFromConfig(), verdictFor(), Orchestrator, Stage
|
||||||
|
|
||||||
## Ambiguous Edges - Review These
|
## Ambiguous Edges - Review These
|
||||||
- `Template Renderer (fresh-instance-per-page)` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
- `Template Renderer (fresh-instance-per-page)` → `GoMail Admin SPA (index.html)` [AMBIGUOUS]
|
||||||
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
@@ -282,9 +309,9 @@ Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api
|
|||||||
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
.claude/go-web-app-no-deps-SKILL.md · relation: conceptually_related_to
|
||||||
|
|
||||||
## Knowledge Gaps
|
## Knowledge Gaps
|
||||||
- **23 isolated node(s):** `webmail`, `IMAPCredential`, `DB`, `migration`, `response` (+18 more)
|
- **33 isolated node(s):** `gomail`, `Contact`, `CalendarProvider`, `ContactProvider`, `gmailLabel` (+28 more)
|
||||||
These have ≤1 connection - possible missing edges or undocumented components.
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
- **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
- **18 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
## Suggested Questions
|
## Suggested Questions
|
||||||
_Questions this graph is uniquely positioned to answer:_
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
@@ -297,9 +324,9 @@ _Questions this graph is uniquely positioned to answer:_
|
|||||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Webmail SPA (index.html)`?**
|
- **What is the exact relationship between `Security Basics: CSRF/Path-Traversal/Atomic-Write/CSP` and `GoMail Webmail SPA (index.html)`?**
|
||||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
- **Why does `User` connect `TOTP & Web Token Auth` to `CalDAV/CardDAV Handlers`, `IMAP Command Parser`, `Mail Provider Abstraction`, `DB Mutation Queries`, `IMAP Server Loop`, `POP3 Server Loop`, `JMAP Auth & Sessions`, `SMTP Session Header Parsing`, `Admin API Handlers`, `Linked Account & Alias Queries`?**
|
- **Why does `User` connect `User` to `GmailAPIProvider`, `DB`, `session`, `session`, `Handler`, `session`, `writeErr`, `session`, `models.go`, `Store`?**
|
||||||
_High betweenness centrality (0.323) - this node is a cross-community bridge._
|
_High betweenness centrality (0.297) - this node is a cross-community bridge._
|
||||||
- **Why does `Store` connect `CalDAV/CardDAV Handlers` to `TOTP & Web Token Auth`, `Mail Provider Abstraction`, `POP3 Server Loop`, `JMAP Auth & Sessions`, `Outbound Delivery Queue`, `SMTP Server Networking`, `TCP Server Lifecycle`?**
|
- **Why does `Store` connect `Store` to `User`, `GmailAPIProvider`, `session`, `Handler`, `session`, `Worker`, `Server`?**
|
||||||
_High betweenness centrality (0.109) - this node is a cross-community bridge._
|
_High betweenness centrality (0.104) - this node is a cross-community bridge._
|
||||||
- **Why does `DB` connect `DB Mutation Queries` to `IMAP Command Parser`, `List Rule Queries`, `Sieve Script Queries`, `Calendar Object Queries`, `Contact Queries`, `Linked Account & Alias Queries`, `Calendar/Contact/TLS Queries`, `Quarantine & Message Queries`, `Domain/Tenant Queries`?**
|
- **Why does `DB` connect `DB` to `.GetContact`, `StageResult`, `.GetTLSCert`, `SieveScript`, `MailContext`, `CalendarObject`, `OutboundQueueEntry`, `models.go`, `uuidNew`, `QuarantineEntry`, `Domain`, `ListRule`?**
|
||||||
_High betweenness centrality (0.098) - this node is a cross-community bridge._
|
_High betweenness centrality (0.085) - this node is a cross-community bridge._
|
||||||
graphify-out/cache/ast/v0.9.37/01cd9aa394dd401d0cb01c0ce6e81b7f32b409619d4933f7c0bef543a6925286.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/06757d9608c63ff4df0a478af7e05250a0b2b53621fbddddcf31a33e299cc634.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/09d74b7cb3808c9f909544b25a336fbe4f8c214e5623f222be6efac3034c4aa0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/0c0d7de992f7b288c3e0bdcb28db4fff790563958a89b9388a7f322e3fa61758.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/0ebbb552529679ab009c0e0535872914cabc8738e2e2be5091663205537bc736.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/176b0aec8d85d28e991e20dcc89034f75764ffb8ce7121860dabf840119e36b9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/18afbd3c5ddd66a9af98072d911048514d50a0dcdcdfe74f015567748f0240c0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/1ba8a5315f9ace3bf5f36c10b21f75b70fb0d9866edcb646210a2c3d43f0a883.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/1d14a5637bc153c73c2542c5a20a1d609d432ab1a02b1180b5de479e3e75dcb4.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/1ff700a996535cbccf2ca4f39302bb54baa00db7fa58f01c30439fd01f8fe237.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/20545edc9168ac23efcda26fea9b777c0f3ad3b454c5e7411ca71a5ecc974937.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2611091ae0acfb2e6dff5ba6edbb2b1b8611df757f787d6f77e584dac278c82e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2a4c4e13f473717cd36e3cfb88ecddfdf9f4ddc8d8b35f6f0de99b8f5362d9a9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/400f963d914038abfd96952505fc94b3ea818f31a0994dcedb8e3a5d545b9679.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/40b53f58daf0fe7dcb02182693089493dc22b1f4560eddb088b8d79279e6a3fc.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/431484d73b1c7c3a4356593246d04668d2cc6bb2e2b14aa5404e36ce519b2374.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/48855b71a58f4eb81b43cd1edc8c3f1fb3d03125b82bb3b79ac4185ca3eec009.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/48fe453328d90095f930d257a1ca31e4521724926f3f123017f5574bb94a1eb0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/4d51257324203a16b2c169ca735ec5657527ab763715095af09117bb52210dfd.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "$graphify-root$_internal_webmail_embed_go", "label": "embed.go", "file_type": "code", "source_file": "internal/webmail/embed.go", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_internal_webmail_embed_go", "target": "go_pkg_embed", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/webmail/embed.go", "source_location": "L3", "weight": 1.0, "context": "import"}], "raw_calls": []}
|
||||||
graphify-out/cache/ast/v0.9.37/5f8d6cbd5032e092d50cf781b1a6c3fe8070df5662fda352dcdd0d1ca43268bb.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/6362e67230fb5fcfe68ca8aa7495ef9e7cb1989eb66f52ac90aed41138c10b7a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/802a1586425dfbf4276a3bf5b35fa78fd9c8ff8dd0d519e4668c4c292dd81493.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/80fdd3d51caa00e9e565abd12c564e91dea4d90edf52e9cf5776fa281c5a8203.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/873991dec46b949403f9a33f9ae954a7069b7571e729bad0b7b2a36fc59df477.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/8a33cefa876cc28da6dedfbbb05a0fe82822d205b034b6e3835ea829bb4265e5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/8de58371d6001b766609750d1baffbd1fc03eb6703edb6b559431678d10886a1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/93356da52f141779fd2f06c04f6e58f59b558394d546f77e9ae4667692939e35.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "$graphify-root$_internal_db_migrations_go", "label": "migrations.go", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L1"}, {"id": "db_migration", "label": "migration", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L7"}], "edges": [{"source": "$graphify-root$_internal_db_migrations_go", "target": "db_migration", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/db/migrations.go", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||||
graphify-out/cache/ast/v0.9.37/9575211ec503f514a1383376148f198fb41eac07fcff9a02490a5ebed9b26ca0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/97f3db35e19c9bcf9601aa1c041835c2b3586f384f75a052cdcae1ff1f94ca49.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/9b7c6b4db73b526b27f3e51ff16f1fb9228974bfd0f0390b17e847c8f01d5ac3.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/9fc210601dbcf5bc577fd9660b06a2d0e1fe167ee36b7d02e3f82ff2d81ffd77.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "pkg_gomail", "label": "gomail", "file_type": "code", "type": "package", "ecosystem": "go", "source_file": "go.mod", "source_location": "L1"}], "edges": [{"source": "pkg_gomail", "target": "pkg_github_com_google_uuid", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_github_com_mattn_go_sqlite3", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_crypto", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_gopkg_in_yaml_v3", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}]}
|
||||||
graphify-out/cache/ast/v0.9.37/b283e117ff2c30abfaf0a84b2758dfeb3435d3173c75f1e2a6c71ab7ed5d66ce.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b39028991019a4dfe1daad2ecefb9c6f86c8f5ef47d9800f062222db80f4f17a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b66d5956eac9e579f12eae9655e68ac3a2c50c3596d37342f49470d344b14ba8.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b98c0c44afd27f47562c3da9cb76e58c4193d519ee9862d3ae1816f95feaa41f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/c25c438d3bf155368898c7f70410f9e975757eed01f159f1d32d5daa9c839130.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/c7cad25d1890df4b9cac5620a0f1a95768ef8553a7427170aec05cbb09a22869.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "$graphify-root$_internal_db_migrations_go", "label": "migrations.go", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L1"}, {"id": "db_migration", "label": "migration", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L7"}], "edges": [{"source": "$graphify-root$_internal_db_migrations_go", "target": "db_migration", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/db/migrations.go", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||||
graphify-out/cache/ast/v0.9.37/c81b85d428efba39e2e23373b8a3804a9a561c1ff244d01f76fb8422f32e77d2.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "pkg_gomail", "label": "gomail", "file_type": "code", "type": "package", "ecosystem": "go", "source_file": "go.mod", "source_location": "L1"}], "edges": [{"source": "pkg_gomail", "target": "pkg_github_com_google_uuid", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_github_com_mattn_go_sqlite3", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_github_com_miekg_dns", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_crypto", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_gopkg_in_yaml_v3", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_mod", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_net", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_sync", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_sys", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}, {"source": "pkg_gomail", "target": "pkg_golang_org_x_tools", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "go.mod", "source_location": "L1", "weight": 1.0}]}
|
||||||
graphify-out/cache/ast/v0.9.37/ca29268c40cc5f65dbf45c8921364aa79bb5885caa7174dea556aedc90b8cd1a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/cccdb86b85f35a922501056e6b223f359a9144dcb687c08ec9858b9b1250291a.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/cd56653b49713dec41e9d520421c67fd34885d76b5764d42936fc5085b732ab2.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/cfd994041d22932995ec2e05d09d3df3ac1721ee3373fa4a99553081d7b8ea33.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d016244ae9165febb2d63142bbd216d99ecc42d639945a92dad23618d7070f56.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d8927666f957c2535c17bd74b7e1215da12c0e2b3c518b4463584289466be595.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "$graphify-root$_internal_ratelimit_http_go", "label": "http.go", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L1"}, {"id": "ratelimit_limiter", "label": "Limiter", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L14"}, {"id": "ratelimit_limiter_httpmiddleware", "label": ".HTTPMiddleware()", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L14"}, {"id": "handler", "label": "Handler", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/ratelimit/http.go"}, {"id": "$graphify-root$_internal_ratelimit_http_clientip", "label": "ClientIP()", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L30"}, {"id": "request", "label": "Request", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/ratelimit/http.go"}], "edges": [{"source": "$graphify-root$_internal_ratelimit_http_go", "target": "go_pkg_net", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L4", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_ratelimit_http_go", "target": "go_pkg_net_http", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L5", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_ratelimit_http_go", "target": "go_pkg_strings", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L6", "weight": 1.0, "context": "import"}, {"source": "ratelimit_limiter", "target": "ratelimit_limiter_httpmiddleware", "relation": "method", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L14", "weight": 1.0}, {"source": "ratelimit_limiter_httpmiddleware", "target": "handler", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L14", "weight": 1.0, "context": "parameter_type"}, {"source": "ratelimit_limiter_httpmiddleware", "target": "handler", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L14", "weight": 1.0, "context": "return_type"}, {"source": "$graphify-root$_internal_ratelimit_http_go", "target": "$graphify-root$_internal_ratelimit_http_clientip", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L30", "weight": 1.0}, {"source": "$graphify-root$_internal_ratelimit_http_clientip", "target": "request", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L30", "weight": 1.0, "context": "parameter_type"}, {"source": "ratelimit_limiter_httpmiddleware", "target": "$graphify-root$_internal_ratelimit_http_clientip", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "ratelimit_limiter_httpmiddleware", "callee": "HandlerFunc", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L15"}, {"caller_nid": "ratelimit_limiter_httpmiddleware", "callee": "Allow", "is_member_call": true, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L17"}, {"caller_nid": "ratelimit_limiter_httpmiddleware", "callee": "Header", "is_member_call": true, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L18"}, {"caller_nid": "ratelimit_limiter_httpmiddleware", "callee": "ServeHTTP", "is_member_call": true, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L22"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "Get", "is_member_call": true, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L32"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "Split", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L33"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "TrimSpace", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L34"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "SplitHostPort", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L37"}]}
|
||||||
graphify-out/cache/ast/v0.9.37/d9555c876205fef227d3d7f19ab811333cdbbcb16f59f1f75a8089d063fd63b7.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d9d9ff100e0f84f784c1715ccf7da38e1b06d6a73e67dc9066bfc9a4219f4a65.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/db0a8a9c1bcf894c5c6f18fc931e5308c74a43e38336b7c686b2eb157c2d1b93.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ddc706c46bd8bcf6437fa62f76613325d8d696f73ceba933441e0f1ed68cfc5b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/e84d5b5a257d77353bb2a3fa5ce3b5833d0803895c33356adcc9b6e9ef23c7e4.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/eb93e0278e15b026ffcab122588b7443695857c69fa8978aee2545fb0fb95163.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/f77ff7308cb2fed7d87d9930ff5cd187c180a8776e26329da5e7e7eb818e7b82.json
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
1786283404.4033368
|
1786338980.5170527
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12425
-2829
File diff suppressed because it is too large
Load Diff
+117
-77
@@ -1,43 +1,43 @@
|
|||||||
{
|
{
|
||||||
".claude/settings.json": {
|
".claude/settings.json": {
|
||||||
"mtime": 1786281487.088728,
|
"mtime": 1786281487.088728,
|
||||||
"ast_hash": "22d1c817e36c7eccf36bf30ee13299cc",
|
"ast_hash": "",
|
||||||
"semantic_hash": "22d1c817e36c7eccf36bf30ee13299cc"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"cmd/gomail/main.go": {
|
"cmd/gomail/main.go": {
|
||||||
"mtime": 1786281911.6889164,
|
"mtime": 1786303371.9406705,
|
||||||
"ast_hash": "6a50dc1a9b0161c4c030b51307ff92cf",
|
"ast_hash": "32c3d08429cc99b216f59edf3e0e4caa",
|
||||||
"semantic_hash": "6a50dc1a9b0161c4c030b51307ff92cf"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"go.mod": {
|
"go.mod": {
|
||||||
"mtime": 1786281935.7638168,
|
"mtime": 1786304427.3009958,
|
||||||
"ast_hash": "38e6ab128168a0a88095fec4523f2992",
|
"ast_hash": "4bbf418e44c141deba9e99f9d780ed9b",
|
||||||
"semantic_hash": "38e6ab128168a0a88095fec4523f2992"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/accounts/link.go": {
|
"internal/accounts/link.go": {
|
||||||
"mtime": 1786281887.2038412,
|
"mtime": 1786299069.1242821,
|
||||||
"ast_hash": "4ffb8f5d3a6ac987d960c702f00f4e07",
|
"ast_hash": "5eed8ecb5be6a1c35c6591dedad976d1",
|
||||||
"semantic_hash": "4ffb8f5d3a6ac987d960c702f00f4e07"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/accounts/provider.go": {
|
"internal/accounts/provider.go": {
|
||||||
"mtime": 1786168320.0,
|
"mtime": 1786303040.5896587,
|
||||||
"ast_hash": "4a180151e798601f43bda25bd1a80410",
|
"ast_hash": "7b4ccfea1a6fbad3e8b519a295cb520b",
|
||||||
"semantic_hash": "4a180151e798601f43bda25bd1a80410"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/accounts/provider_gomail.go": {
|
"internal/accounts/provider_gomail.go": {
|
||||||
"mtime": 1786281891.3509514,
|
"mtime": 1786297740.5829873,
|
||||||
"ast_hash": "f0dd245cb144dca3f03520e632c1d5f5",
|
"ast_hash": "ac0d507cba9953d85a44ce0506217587",
|
||||||
"semantic_hash": "f0dd245cb144dca3f03520e632c1d5f5"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/accounts/provider_imap.go": {
|
"internal/accounts/provider_imap.go": {
|
||||||
"mtime": 1786281893.9837677,
|
"mtime": 1786298998.877657,
|
||||||
"ast_hash": "99587258525acd5ab4b20398a2d0d400",
|
"ast_hash": "4825cd303e132e3179500afe0e9098bd",
|
||||||
"semantic_hash": "99587258525acd5ab4b20398a2d0d400"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/accounts/provider_smtp_helper.go": {
|
"internal/accounts/provider_smtp_helper.go": {
|
||||||
"mtime": 1786281896.5890837,
|
"mtime": 1786289886.1606128,
|
||||||
"ast_hash": "188d01aeb8fe3fb73cb76a25ffda71b4",
|
"ast_hash": "4fec96778cdaa94b1ab2264236af8b48",
|
||||||
"semantic_hash": "188d01aeb8fe3fb73cb76a25ffda71b4"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/acme/challenge.go": {
|
"internal/acme/challenge.go": {
|
||||||
"mtime": 1786189310.0,
|
"mtime": 1786189310.0,
|
||||||
@@ -80,14 +80,14 @@
|
|||||||
"semantic_hash": "c74ed7b257b2272dcfb860b8a60746a0"
|
"semantic_hash": "c74ed7b257b2272dcfb860b8a60746a0"
|
||||||
},
|
},
|
||||||
"internal/config/config.go": {
|
"internal/config/config.go": {
|
||||||
"mtime": 1786189702.0,
|
"mtime": 1786289818.8781466,
|
||||||
"ast_hash": "12cee16f6dcfa6971217dd104e86f4d6",
|
"ast_hash": "68fd6f093c4ba00106bdbbe59786a269",
|
||||||
"semantic_hash": "12cee16f6dcfa6971217dd104e86f4d6"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/crypto/crypto.go": {
|
"internal/crypto/crypto.go": {
|
||||||
"mtime": 1784360971.0,
|
"mtime": 1786297602.7221303,
|
||||||
"ast_hash": "4567100200969235fd1559ad9be6f1b1",
|
"ast_hash": "8de4f9714d1c3e73a4322080d6b1a2ca",
|
||||||
"semantic_hash": "4567100200969235fd1559ad9be6f1b1"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/dav/dav.go": {
|
"internal/dav/dav.go": {
|
||||||
"mtime": 1786281900.5555487,
|
"mtime": 1786281900.5555487,
|
||||||
@@ -100,24 +100,24 @@
|
|||||||
"semantic_hash": "922a08de72be0ef63409ed402d01e098"
|
"semantic_hash": "922a08de72be0ef63409ed402d01e098"
|
||||||
},
|
},
|
||||||
"internal/db/db.go": {
|
"internal/db/db.go": {
|
||||||
"mtime": 1784361005.0,
|
"mtime": 1786297548.1684437,
|
||||||
"ast_hash": "64d527aad454dab7867ffd51aa3d72ca",
|
"ast_hash": "aed0290af4025c8c84089356f8746f08",
|
||||||
"semantic_hash": "64d527aad454dab7867ffd51aa3d72ca"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/db/migrations.go": {
|
"internal/db/migrations.go": {
|
||||||
"mtime": 1786193366.0,
|
"mtime": 1786300065.1340663,
|
||||||
"ast_hash": "d67ddd29ab706ae5ce670da624e30b2c",
|
"ast_hash": "2eb17b13bdc6bd5a954c82b4503aa8f9",
|
||||||
"semantic_hash": "d67ddd29ab706ae5ce670da624e30b2c"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/db/models.go": {
|
"internal/db/models.go": {
|
||||||
"mtime": 1786193384.0,
|
"mtime": 1786300091.6301413,
|
||||||
"ast_hash": "cf35e391db47a84bd0b4e05ba77b9a95",
|
"ast_hash": "12f132efac1ac74ab13ce8bfce0bfc69",
|
||||||
"semantic_hash": "cf35e391db47a84bd0b4e05ba77b9a95"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/db/queries.go": {
|
"internal/db/queries.go": {
|
||||||
"mtime": 1786193758.0,
|
"mtime": 1786301261.1620972,
|
||||||
"ast_hash": "c5f7a40030988031163220e54051b93d",
|
"ast_hash": "684ba8ac79df6845b0f0571ee6ff67a3",
|
||||||
"semantic_hash": "c5f7a40030988031163220e54051b93d"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/dkim/keys.go": {
|
"internal/dkim/keys.go": {
|
||||||
"mtime": 1785566243.0,
|
"mtime": 1785566243.0,
|
||||||
@@ -145,9 +145,9 @@
|
|||||||
"semantic_hash": "5518fd86059d729048685e2bc39c24b2"
|
"semantic_hash": "5518fd86059d729048685e2bc39c24b2"
|
||||||
},
|
},
|
||||||
"internal/imap/commands.go": {
|
"internal/imap/commands.go": {
|
||||||
"mtime": 1786281888.6448092,
|
"mtime": 1786294683.3800013,
|
||||||
"ast_hash": "e0a26bce9488a5d0bc680d5b11d15610",
|
"ast_hash": "584a4c4fa7ad664aaec36bc404825512",
|
||||||
"semantic_hash": "e0a26bce9488a5d0bc680d5b11d15610"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/imap/parser.go": {
|
"internal/imap/parser.go": {
|
||||||
"mtime": 1785638411.0,
|
"mtime": 1785638411.0,
|
||||||
@@ -155,9 +155,9 @@
|
|||||||
"semantic_hash": "687b7788b077688b9081f958b669a0e6"
|
"semantic_hash": "687b7788b077688b9081f958b669a0e6"
|
||||||
},
|
},
|
||||||
"internal/imap/server.go": {
|
"internal/imap/server.go": {
|
||||||
"mtime": 1786281911.0781865,
|
"mtime": 1786289824.8942587,
|
||||||
"ast_hash": "13154da7fca4635077e377476aef64c8",
|
"ast_hash": "50a8a0f58edd9d9bb6cafa385f3c97b2",
|
||||||
"semantic_hash": "13154da7fca4635077e377476aef64c8"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/imap/session.go": {
|
"internal/imap/session.go": {
|
||||||
"mtime": 1786281910.5246246,
|
"mtime": 1786281910.5246246,
|
||||||
@@ -180,9 +180,9 @@
|
|||||||
"semantic_hash": "2484a0bccd56908627cee4c769b689e5"
|
"semantic_hash": "2484a0bccd56908627cee4c769b689e5"
|
||||||
},
|
},
|
||||||
"internal/mailstore/maildir.go": {
|
"internal/mailstore/maildir.go": {
|
||||||
"mtime": 1786281898.3165317,
|
"mtime": 1786297713.2183998,
|
||||||
"ast_hash": "7224a5c029cdcdc63c0de93cb8a8eb51",
|
"ast_hash": "ca8749979e4a1ca9ada23c072945189c",
|
||||||
"semantic_hash": "7224a5c029cdcdc63c0de93cb8a8eb51"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/managesieve/server.go": {
|
"internal/managesieve/server.go": {
|
||||||
"mtime": 1786281899.3706038,
|
"mtime": 1786281899.3706038,
|
||||||
@@ -195,9 +195,9 @@
|
|||||||
"semantic_hash": "ab69e22b6add70fdb6c18f056c474e8d"
|
"semantic_hash": "ab69e22b6add70fdb6c18f056c474e8d"
|
||||||
},
|
},
|
||||||
"internal/oauth2/oauth2.go": {
|
"internal/oauth2/oauth2.go": {
|
||||||
"mtime": 1786169607.0,
|
"mtime": 1786298531.0961437,
|
||||||
"ast_hash": "5ab8fd85a331f7f82470ee354e309ec4",
|
"ast_hash": "4e1c7abbe92feb151acecfec03229c50",
|
||||||
"semantic_hash": "5ab8fd85a331f7f82470ee354e309ec4"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/pipeline/pipeline.go": {
|
"internal/pipeline/pipeline.go": {
|
||||||
"mtime": 1786281906.8561504,
|
"mtime": 1786281906.8561504,
|
||||||
@@ -215,9 +215,9 @@
|
|||||||
"semantic_hash": "3f92da867d5b128398a8ffc70b6fa475"
|
"semantic_hash": "3f92da867d5b128398a8ffc70b6fa475"
|
||||||
},
|
},
|
||||||
"internal/pipeline/stage_dmarc.go": {
|
"internal/pipeline/stage_dmarc.go": {
|
||||||
"mtime": 1786281917.6531692,
|
"mtime": 1786294688.9970334,
|
||||||
"ast_hash": "2d73846ca47178c53a0ffb341b7d754e",
|
"ast_hash": "fc9ece401684fb950771b70db7a54d9d",
|
||||||
"semantic_hash": "2d73846ca47178c53a0ffb341b7d754e"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/pipeline/stage_headers.go": {
|
"internal/pipeline/stage_headers.go": {
|
||||||
"mtime": 1786281909.176789,
|
"mtime": 1786281909.176789,
|
||||||
@@ -245,19 +245,19 @@
|
|||||||
"semantic_hash": "f954d508ac085f560928c06171779db5"
|
"semantic_hash": "f954d508ac085f560928c06171779db5"
|
||||||
},
|
},
|
||||||
"internal/pop3/pop3.go": {
|
"internal/pop3/pop3.go": {
|
||||||
"mtime": 1786281909.7660527,
|
"mtime": 1786289853.1900737,
|
||||||
"ast_hash": "0e3685f20c9ca05d3a28729c8ad7a29f",
|
"ast_hash": "e4756d72c5bc2bd89fd41a2205ca3d14",
|
||||||
"semantic_hash": "0e3685f20c9ca05d3a28729c8ad7a29f"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/queue/queue.go": {
|
"internal/queue/queue.go": {
|
||||||
"mtime": 1786281808.0265675,
|
"mtime": 1786300256.943359,
|
||||||
"ast_hash": "71c0ce0211d61d37f51f19678dbcdeb4",
|
"ast_hash": "5e9631b0601474ed28f3626a293e8e08",
|
||||||
"semantic_hash": "71c0ce0211d61d37f51f19678dbcdeb4"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/ratelimit/http.go": {
|
"internal/ratelimit/http.go": {
|
||||||
"mtime": 1786207203.0,
|
"mtime": 1786289690.9789355,
|
||||||
"ast_hash": "c06b19193ad3c3159551966c05f48ea5",
|
"ast_hash": "039681ec4aef1340ab4ba8bf83baa679",
|
||||||
"semantic_hash": "c06b19193ad3c3159551966c05f48ea5"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/ratelimit/ratelimit.go": {
|
"internal/ratelimit/ratelimit.go": {
|
||||||
"mtime": 1786207185.0,
|
"mtime": 1786207185.0,
|
||||||
@@ -325,19 +325,19 @@
|
|||||||
"semantic_hash": "5aeab637a3c3357bb2c13cacc21508c6"
|
"semantic_hash": "5aeab637a3c3357bb2c13cacc21508c6"
|
||||||
},
|
},
|
||||||
"internal/webmail/api.go": {
|
"internal/webmail/api.go": {
|
||||||
"mtime": 1786281903.8315737,
|
"mtime": 1786339172.9744284,
|
||||||
"ast_hash": "3ccccaaef2a926a90919805f67173db2",
|
"ast_hash": "fcc1ddeb2d5f749bee23623529d76b73",
|
||||||
"semantic_hash": "3ccccaaef2a926a90919805f67173db2"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/webmail/embed.go": {
|
"internal/webmail/embed.go": {
|
||||||
"mtime": 1786132251.0,
|
"mtime": 1786295803.857812,
|
||||||
"ast_hash": "441b827ff1643c3807b6efb92119a721",
|
"ast_hash": "78663d0210a610d1777c4b4151390cf1",
|
||||||
"semantic_hash": "441b827ff1643c3807b6efb92119a721"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"internal/webtoken/webtoken.go": {
|
"internal/webtoken/webtoken.go": {
|
||||||
"mtime": 1786193462.0,
|
"mtime": 1786289648.699828,
|
||||||
"ast_hash": "d94a2b905b20ccbd8a83e258245ba375",
|
"ast_hash": "b9be7cc5ea74ce6b82de79ef3644cb52",
|
||||||
"semantic_hash": "d94a2b905b20ccbd8a83e258245ba375"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
".claude/go-web-app-no-deps-SKILL.md": {
|
".claude/go-web-app-no-deps-SKILL.md": {
|
||||||
"mtime": 1786206554.0,
|
"mtime": 1786206554.0,
|
||||||
@@ -385,8 +385,48 @@
|
|||||||
"semantic_hash": "77b0b0e42cf45a961571ee5e3a3a219a"
|
"semantic_hash": "77b0b0e42cf45a961571ee5e3a3a219a"
|
||||||
},
|
},
|
||||||
"internal/webmail/static/index.html": {
|
"internal/webmail/static/index.html": {
|
||||||
"mtime": 1786132285.0,
|
"mtime": 1786302422.3206468,
|
||||||
"ast_hash": "3b77272b21933601eed33f3441d386cc",
|
"ast_hash": "240f0802126ee6142b639b0c3d1fd749",
|
||||||
"semantic_hash": "3b77272b21933601eed33f3441d386cc"
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webmail/static/app.js": {
|
||||||
|
"mtime": 1786338964.959641,
|
||||||
|
"ast_hash": "7905c891f2865aa88d99774efbad8c1d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_gmailapi.go": {
|
||||||
|
"mtime": 1786303230.0544362,
|
||||||
|
"ast_hash": "991624e71e139cb871a1c549e7b2583a",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/accounts/provider_graphapi.go": {
|
||||||
|
"mtime": 1786303329.356412,
|
||||||
|
"ast_hash": "4c7aca4b1ab67e5629fdd094e59caae4",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dane/dane.go": {
|
||||||
|
"mtime": 1786304412.353843,
|
||||||
|
"ast_hash": "077c045db0d9c08bcb1c91fda5f095ce",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/mtasts/mtasts.go": {
|
||||||
|
"mtime": 1786300014.9348822,
|
||||||
|
"ast_hash": "832952ea8843b31f4f8a01ffa3cfeb7f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/cbor.go": {
|
||||||
|
"mtime": 1786301093.0761018,
|
||||||
|
"ast_hash": "79612ad8167cf6a9e6e18977f1d90f0f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/webauthn/webauthn.go": {
|
||||||
|
"mtime": 1786301194.5917418,
|
||||||
|
"ast_hash": "f9abf441c2637cdb4bdfd28b358417a3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"internal/dnssec/dnssec.go": {
|
||||||
|
"mtime": 1786338656.3355203,
|
||||||
|
"ast_hash": "0041911983b254c6ba9b78906f3055c3",
|
||||||
|
"semantic_hash": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+116
-7
@@ -1,8 +1,12 @@
|
|||||||
package accounts
|
package accounts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gomail/internal/crypto"
|
"gomail/internal/crypto"
|
||||||
"gomail/internal/db"
|
"gomail/internal/db"
|
||||||
@@ -66,6 +70,71 @@ func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchOAuth2Email looks up the real email address for a just-authorized
|
||||||
|
// account via the provider's userinfo/profile endpoint — replacing the
|
||||||
|
// earlier ?email= query-param stand-in (see webmail's oauthCallback). This
|
||||||
|
// is the account's real identity, so a failure here is returned as an
|
||||||
|
// error rather than silently falling back to a placeholder.
|
||||||
|
func FetchOAuth2Email(ctx context.Context, provider, accessToken string) (string, error) {
|
||||||
|
var endpoint string
|
||||||
|
switch provider {
|
||||||
|
case "google":
|
||||||
|
endpoint = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
|
case "microsoft":
|
||||||
|
endpoint = "https://graph.microsoft.com/v1.0/me"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown provider %q", provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := oauth2.NewAuthedRequest(ctx, http.MethodGet, endpoint, accessToken, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("userinfo request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("userinfo endpoint returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == "google" {
|
||||||
|
var r struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing userinfo response: %w", err)
|
||||||
|
}
|
||||||
|
if r.Email == "" {
|
||||||
|
return "", fmt.Errorf("userinfo response had no email")
|
||||||
|
}
|
||||||
|
return r.Email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// microsoft
|
||||||
|
var r struct {
|
||||||
|
Mail string `json:"mail"`
|
||||||
|
UserPrincipalName string `json:"userPrincipalName"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing /me response: %w", err)
|
||||||
|
}
|
||||||
|
if r.Mail != "" {
|
||||||
|
return r.Mail, nil
|
||||||
|
}
|
||||||
|
if r.UserPrincipalName != "" {
|
||||||
|
// Some Graph account types return a null `mail` field —
|
||||||
|
// userPrincipalName is a documented, reliable fallback.
|
||||||
|
return r.UserPrincipalName, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("/me response had no mail or userPrincipalName")
|
||||||
|
}
|
||||||
|
|
||||||
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
|
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
|
||||||
// tokens (from a completed authorization code exchange) encrypted the same
|
// tokens (from a completed authorization code exchange) encrypted the same
|
||||||
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
|
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
|
||||||
@@ -115,6 +184,49 @@ func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayNam
|
|||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshedCredential decrypts account's stored OAuth2 credential and, if
|
||||||
|
// expired, transparently refreshes it via cfg and persists the new token —
|
||||||
|
// shared by every OAuth2-authenticated provider (IMAPProvider,
|
||||||
|
// GmailAPIProvider, GraphAPIProvider) so "is this expired, and if so
|
||||||
|
// refresh and persist" lives in exactly one place instead of once per
|
||||||
|
// provider. database may be nil (refresh then happens in-memory only, for
|
||||||
|
// the rare caller that doesn't have persistence available); cfg may be nil
|
||||||
|
// only if the credential is known not to be expired yet.
|
||||||
|
func refreshedCredential(ctx context.Context, database *db.DB, mk *crypto.MasterKey, account *db.LinkedAccount, cfg *oauth2.Config) (*OAuth2Credential, error) {
|
||||||
|
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
|
||||||
|
}
|
||||||
|
var cred OAuth2Credential
|
||||||
|
if err := json.Unmarshal(plain, &cred); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing stored OAuth2 credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !time.Now().UTC().After(cred.ExpiresAt) {
|
||||||
|
return &cred, nil
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
|
||||||
|
}
|
||||||
|
newTok, err := cfg.RefreshToken(ctx, cred.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("refreshing OAuth2 token: %w", err)
|
||||||
|
}
|
||||||
|
cred.AccessToken = newTok.AccessToken
|
||||||
|
cred.RefreshToken = newTok.RefreshToken
|
||||||
|
cred.ExpiresAt = newTok.ExpiresAt
|
||||||
|
|
||||||
|
if database != nil {
|
||||||
|
if updated, err := json.Marshal(cred); err == nil {
|
||||||
|
if encUpdated, encErr := crypto.Encrypt(mk, account.ID, "linked-account-cred", updated); encErr == nil {
|
||||||
|
database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
|
||||||
|
encUpdated, cred.ExpiresAt, account.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProviderFor returns the right MailProvider implementation for a linked
|
// ProviderFor returns the right MailProvider implementation for a linked
|
||||||
// account row — the single place that decides which backend handles which
|
// account row — the single place that decides which backend handles which
|
||||||
// provider string, so callers (webmail API, sync workers) never need a
|
// provider string, so callers (webmail API, sync workers) never need a
|
||||||
@@ -129,13 +241,10 @@ func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.D
|
|||||||
switch account.Provider {
|
switch account.Provider {
|
||||||
case db.ProviderIMAP:
|
case db.ProviderIMAP:
|
||||||
return NewIMAPProvider(account, mk), nil
|
return NewIMAPProvider(account, mk), nil
|
||||||
case db.ProviderGmail, db.ProviderM365:
|
case db.ProviderGmail:
|
||||||
providerKey := "google"
|
return NewGmailAPIProvider(account, mk, database, oauthConfigs["google"]), nil
|
||||||
if account.Provider == db.ProviderM365 {
|
case db.ProviderM365:
|
||||||
providerKey = "microsoft"
|
return NewGraphAPIProvider(account, mk, database, oauthConfigs["microsoft"]), nil
|
||||||
}
|
|
||||||
cfg := oauthConfigs[providerKey]
|
|
||||||
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("provider %q not supported", account.Provider)
|
return nil, fmt.Errorf("provider %q not supported", account.Provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
// actually lives.
|
// actually lives.
|
||||||
package accounts
|
package accounts
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
|
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
|
||||||
@@ -65,3 +68,41 @@ type MailProvider interface {
|
|||||||
Delete(ctx context.Context, folderID, messageID string) error
|
Delete(ctx context.Context, folderID, messageID string) error
|
||||||
Sync(ctx context.Context, since string) (*SyncResult, error)
|
Sync(ctx context.Context, since string) (*SyncResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalendarEvent and Contact are read-only views onto a linked account's
|
||||||
|
// own calendar/contacts, native-API only (Gmail Calendar API + Google
|
||||||
|
// People API, Microsoft Graph) — there is no IMAP-equivalent fallback for
|
||||||
|
// these, unlike mail. Local calendars/contacts are unaffected: they keep
|
||||||
|
// being served by internal/dav's CalDAV/CardDAV server, entirely separate
|
||||||
|
// from this.
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
End time.Time `json:"end"`
|
||||||
|
AllDay bool `json:"all_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
Phones []string `json:"phones"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarProvider and ContactProvider are deliberately separate from
|
||||||
|
// MailProvider — GoMailProvider (local calendar/contacts already exist
|
||||||
|
// via CalDAV/CardDAV) and IMAPProvider (generic IMAP has no calendar/
|
||||||
|
// contacts concept at all) don't implement either. Callers type-assert
|
||||||
|
// (`p, ok := provider.(CalendarProvider)`) and report "not supported"
|
||||||
|
// rather than a fake empty result — see internal/webmail/api.go's
|
||||||
|
// calendarEvents/contacts handlers.
|
||||||
|
type CalendarProvider interface {
|
||||||
|
ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactProvider interface {
|
||||||
|
ListContacts(ctx context.Context) ([]Contact, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package accounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gomail/internal/crypto"
|
||||||
|
"gomail/internal/db"
|
||||||
|
"gomail/internal/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gmailAPIBase is the real Gmail API v1 base URL — GmailAPIProvider.BaseURL
|
||||||
|
// defaults to this in NewGmailAPIProvider, and tests override the field
|
||||||
|
// directly to point at a fake server, the same way oauth2.Config's own
|
||||||
|
// AuthURL/TokenURL fields are overridden in this project's existing tests.
|
||||||
|
const gmailAPIBase = "https://gmail.googleapis.com/gmail/v1/users/me"
|
||||||
|
const gmailCalendarAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||||
|
const gmailPeopleAPIBase = "https://people.googleapis.com/v1"
|
||||||
|
|
||||||
|
// GmailAPIProvider implements MailProvider against the real Gmail API,
|
||||||
|
// replacing the earlier IMAP+XOAUTH2 transport for db.ProviderGmail
|
||||||
|
// accounts (see internal/accounts/link.go's ProviderFor). Gmail is
|
||||||
|
// label-based, not folder-based — every method below translates between
|
||||||
|
// this package's folder/flag vocabulary and Gmail's labels internally, so
|
||||||
|
// callers (webmail API, unified inbox) never need to know the difference.
|
||||||
|
type GmailAPIProvider struct {
|
||||||
|
BaseURL string // overridable for tests; defaults to gmailAPIBase
|
||||||
|
CalendarBaseURL string // overridable for tests; defaults to gmailCalendarAPIBase
|
||||||
|
PeopleBaseURL string // overridable for tests; defaults to gmailPeopleAPIBase
|
||||||
|
|
||||||
|
account *db.LinkedAccount
|
||||||
|
mk *crypto.MasterKey
|
||||||
|
database *db.DB
|
||||||
|
oauthConfig *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGmailAPIProvider(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *GmailAPIProvider {
|
||||||
|
return &GmailAPIProvider{
|
||||||
|
BaseURL: gmailAPIBase, CalendarBaseURL: gmailCalendarAPIBase, PeopleBaseURL: gmailPeopleAPIBase,
|
||||||
|
account: account, mk: mk, database: database, oauthConfig: oauthConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessToken returns a valid (transparently refreshed if needed) access
|
||||||
|
// token via the shared refreshedCredential helper used by every
|
||||||
|
// OAuth2-authenticated provider.
|
||||||
|
func (p *GmailAPIProvider) accessToken(ctx context.Context) (string, error) {
|
||||||
|
cred, err := refreshedCredential(ctx, p.database, p.mk, p.account, p.oauthConfig)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs an authenticated Gmail API request and decodes a successful
|
||||||
|
// JSON response into out (pass nil to discard the body, e.g. for
|
||||||
|
// trash/modify calls whose response this provider doesn't need).
|
||||||
|
func (p *GmailAPIProvider) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
return p.doURL(ctx, method, p.BaseURL+path, body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doURL is like do but takes a full URL — used for Calendar API v3 /
|
||||||
|
// People API v1, which live on different hosts than the Gmail API base
|
||||||
|
// (p.BaseURL), unlike everything else this provider calls.
|
||||||
|
func (p *GmailAPIProvider) doURL(ctx context.Context, method, url string, body, out any) error {
|
||||||
|
token, err := p.accessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := oauth2.NewAuthedRequest(ctx, method, url, token, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reqBody != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gmail api request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading gmail api response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("gmail api %s %s: status %d: %s", method, url, resp.StatusCode, truncateBody(respBody, 200))
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("parsing gmail api response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBody(b []byte, n int) string {
|
||||||
|
if len(b) > n {
|
||||||
|
return string(b[:n]) + "..."
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmailLabelType maps Gmail's system label IDs to this package's Folder.Type
|
||||||
|
// vocabulary — different strings than IMAP's Drafts/Junk, so this doesn't
|
||||||
|
// reuse provider_gomail.go's folderType.
|
||||||
|
func gmailLabelType(labelID string) string {
|
||||||
|
switch labelID {
|
||||||
|
case "INBOX":
|
||||||
|
return "inbox"
|
||||||
|
case "SENT":
|
||||||
|
return "sent"
|
||||||
|
case "DRAFT":
|
||||||
|
return "drafts"
|
||||||
|
case "TRASH":
|
||||||
|
return "trash"
|
||||||
|
case "SPAM":
|
||||||
|
return "junk"
|
||||||
|
default:
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type gmailLabel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
MessagesTotal int `json:"messagesTotal"`
|
||||||
|
MessagesUnread int `json:"messagesUnread"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) ListFolders(ctx context.Context) ([]Folder, error) {
|
||||||
|
var resp struct {
|
||||||
|
Labels []gmailLabel `json:"labels"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/labels", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := make([]Folder, 0, len(resp.Labels))
|
||||||
|
for _, l := range resp.Labels {
|
||||||
|
// Per-label counts need a separate labels.get call (labels.list
|
||||||
|
// doesn't include them) — only fetch it for the handful of system
|
||||||
|
// labels a folder list actually shows as top-level, to avoid an
|
||||||
|
// API call per label when most installs only care about INBOX.
|
||||||
|
total, unread := l.MessagesTotal, l.MessagesUnread
|
||||||
|
if l.Type == "system" {
|
||||||
|
var detail gmailLabel
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/labels/"+l.ID, nil, &detail); err == nil {
|
||||||
|
total, unread = detail.MessagesTotal, detail.MessagesUnread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folders = append(folders, Folder{
|
||||||
|
ID: l.ID, DisplayName: l.Name, Type: gmailLabelType(l.ID),
|
||||||
|
UnreadCount: unread, TotalCount: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return folders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
|
||||||
|
limit := opts.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var listResp struct {
|
||||||
|
Messages []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/messages?labelIds=%s&maxResults=%d", folderID, limit)
|
||||||
|
if err := p.do(ctx, http.MethodGet, path, nil, &listResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gmail's list endpoint returns IDs only — headers need a metadata call
|
||||||
|
// per message, the same "N per listing" shape the local mailbox's own
|
||||||
|
// header cache (internal/mailstore) was added to avoid, but there's no
|
||||||
|
// equivalent server-side cache to lean on for a remote account.
|
||||||
|
headers := make([]MessageHeader, 0, len(listResp.Messages))
|
||||||
|
for _, m := range listResp.Messages {
|
||||||
|
var msg struct {
|
||||||
|
LabelIDs []string `json:"labelIds"`
|
||||||
|
SizeEstimate int64 `json:"sizeEstimate"`
|
||||||
|
Payload struct {
|
||||||
|
Headers []struct {
|
||||||
|
Name, Value string
|
||||||
|
} `json:"headers"`
|
||||||
|
} `json:"payload"`
|
||||||
|
}
|
||||||
|
metaPath := "/messages/" + m.ID + "?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date"
|
||||||
|
if err := p.do(ctx, http.MethodGet, metaPath, nil, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := MessageHeader{ID: m.ID, FolderID: folderID, SizeBytes: msg.SizeEstimate}
|
||||||
|
for _, hdr := range msg.Payload.Headers {
|
||||||
|
switch hdr.Name {
|
||||||
|
case "From":
|
||||||
|
h.From = hdr.Value
|
||||||
|
case "To":
|
||||||
|
h.To = hdr.Value
|
||||||
|
case "Subject":
|
||||||
|
h.Subject = hdr.Value
|
||||||
|
case "Date":
|
||||||
|
h.Date = hdr.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.Flags = gmailLabelsToFlags(msg.LabelIDs)
|
||||||
|
headers = append(headers, h)
|
||||||
|
}
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmailLabelsToFlags/flagsToGmailLabels translate between IMAP-style flag
|
||||||
|
// strings and the two Gmail labels with a direct equivalent. Flags with no
|
||||||
|
// Gmail equivalent (\Answered, \Draft) are a named gap, not silently wrong.
|
||||||
|
func gmailLabelsToFlags(labelIDs []string) []string {
|
||||||
|
var flags []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range labelIDs {
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
if !seen["UNREAD"] {
|
||||||
|
flags = append(flags, "\\Seen")
|
||||||
|
}
|
||||||
|
if seen["STARRED"] {
|
||||||
|
flags = append(flags, "\\Flagged")
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
|
||||||
|
var resp struct {
|
||||||
|
LabelIDs []string `json:"labelIds"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
SizeEstimate int64 `json:"sizeEstimate"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/messages/"+messageID+"?format=raw", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(resp.Raw)
|
||||||
|
if err != nil {
|
||||||
|
// Gmail sometimes includes padding despite the API doc saying
|
||||||
|
// unpadded — fall back to standard raw-URL decoding.
|
||||||
|
raw, err = base64.RawURLEncoding.DecodeString(resp.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding raw message: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full := headerFromRaw(messageID, folderID, raw, "", resp.SizeEstimate)
|
||||||
|
full.Flags = gmailLabelsToFlags(resp.LabelIDs)
|
||||||
|
return &FullMessage{MessageHeader: full, Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) SendMessage(ctx context.Context, msg *OutgoingMessage) error {
|
||||||
|
raw := buildRFC5322(msg.From, msg)
|
||||||
|
encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(raw)
|
||||||
|
return p.do(ctx, http.MethodPost, "/messages/send", map[string]string{"raw": encoded}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, f := range flags {
|
||||||
|
want[f] = true
|
||||||
|
}
|
||||||
|
var add, remove []string
|
||||||
|
if want["\\Seen"] {
|
||||||
|
remove = append(remove, "UNREAD")
|
||||||
|
} else {
|
||||||
|
add = append(add, "UNREAD")
|
||||||
|
}
|
||||||
|
if want["\\Flagged"] {
|
||||||
|
add = append(add, "STARRED")
|
||||||
|
} else {
|
||||||
|
remove = append(remove, "STARRED")
|
||||||
|
}
|
||||||
|
body := map[string]any{"addLabelIds": add, "removeLabelIds": remove}
|
||||||
|
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/modify", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GmailAPIProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
|
||||||
|
body := map[string]any{"addLabelIds": []string{destFolderID}, "removeLabelIds": []string{folderID}}
|
||||||
|
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/modify", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete trashes the message (Gmail's TRASH label) rather than permanently
|
||||||
|
// deleting it — matches IMAP \Deleted softness and typical mail-client
|
||||||
|
// expectations; permanent delete is out of scope for this provider.
|
||||||
|
func (p *GmailAPIProvider) Delete(ctx context.Context, folderID, messageID string) error {
|
||||||
|
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/trash", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync captures a fresh historyId cursor and does a full re-list — the same
|
||||||
|
// depth as IMAPProvider.Sync (no CONDSTORE/QRESYNC there either). Real
|
||||||
|
// incremental diffing via users.history.list is deferred: nothing consumes
|
||||||
|
// SyncResult yet (no sync worker exists), so building it now would be
|
||||||
|
// untested, unused code.
|
||||||
|
func (p *GmailAPIProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
|
||||||
|
var profile struct {
|
||||||
|
HistoryID string `json:"historyId"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/profile", nil, &profile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folders, err := p.ListFolders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var all []MessageHeader
|
||||||
|
for _, f := range folders {
|
||||||
|
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
all = append(all, msgs...)
|
||||||
|
}
|
||||||
|
return &SyncResult{NewCursor: profile.HistoryID, NewMessages: all}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmailDateTime is Calendar API v3's event start/end shape: either a full
|
||||||
|
// dateTime (timed event) or a bare date (all-day event) — never both.
|
||||||
|
type gmailDateTime struct {
|
||||||
|
DateTime string `json:"dateTime"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d gmailDateTime) parse() (t time.Time, allDay bool) {
|
||||||
|
if d.Date != "" {
|
||||||
|
t, _ = time.Parse("2006-01-02", d.Date)
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
t, _ = time.Parse(time.RFC3339, d.DateTime)
|
||||||
|
return t, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEvents implements CalendarProvider against Calendar API v3. Calendar
|
||||||
|
// API is a different host than the Gmail API base, so this uses doURL
|
||||||
|
// rather than do.
|
||||||
|
func (p *GmailAPIProvider) ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error) {
|
||||||
|
eventsURL := fmt.Sprintf("%s/calendars/primary/events?timeMin=%s&timeMax=%s&singleEvents=true&orderBy=startTime",
|
||||||
|
p.CalendarBaseURL, url.QueryEscape(from.Format(time.RFC3339)), url.QueryEscape(to.Format(time.RFC3339)))
|
||||||
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Start gmailDateTime `json:"start"`
|
||||||
|
End gmailDateTime `json:"end"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := p.doURL(ctx, http.MethodGet, eventsURL, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events := make([]CalendarEvent, 0, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
start, allDay := it.Start.parse()
|
||||||
|
end, _ := it.End.parse()
|
||||||
|
events = append(events, CalendarEvent{
|
||||||
|
ID: it.ID, Summary: it.Summary, Location: it.Location, Description: it.Description,
|
||||||
|
Start: start, End: end, AllDay: allDay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContacts implements ContactProvider against People API v1.
|
||||||
|
func (p *GmailAPIProvider) ListContacts(ctx context.Context) ([]Contact, error) {
|
||||||
|
contactsURL := p.PeopleBaseURL + "/people/me/connections?personFields=names,emailAddresses,phoneNumbers"
|
||||||
|
var resp struct {
|
||||||
|
Connections []struct {
|
||||||
|
ResourceName string `json:"resourceName"`
|
||||||
|
Names []struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
} `json:"names"`
|
||||||
|
EmailAddresses []struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"emailAddresses"`
|
||||||
|
PhoneNumbers []struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"phoneNumbers"`
|
||||||
|
} `json:"connections"`
|
||||||
|
}
|
||||||
|
if err := p.doURL(ctx, http.MethodGet, contactsURL, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contacts := make([]Contact, 0, len(resp.Connections))
|
||||||
|
for _, c := range resp.Connections {
|
||||||
|
contact := Contact{ID: c.ResourceName}
|
||||||
|
if len(c.Names) > 0 {
|
||||||
|
contact.Name = c.Names[0].DisplayName
|
||||||
|
}
|
||||||
|
for _, e := range c.EmailAddresses {
|
||||||
|
contact.Emails = append(contact.Emails, e.Value)
|
||||||
|
}
|
||||||
|
for _, ph := range c.PhoneNumbers {
|
||||||
|
contact.Phones = append(contact.Phones, ph.Value)
|
||||||
|
}
|
||||||
|
contacts = append(contacts, contact)
|
||||||
|
}
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
@@ -80,6 +80,21 @@ func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts L
|
|||||||
|
|
||||||
var headers []MessageHeader
|
var headers []MessageHeader
|
||||||
for _, e := range page {
|
for _, e := range page {
|
||||||
|
// Fast path: decrypt the small cached header blob instead of the
|
||||||
|
// full message body. Falls through to a full read for messages
|
||||||
|
// delivered before the header cache existed (header_enc is NULL)
|
||||||
|
// or if the cached blob fails to decrypt/parse for any reason.
|
||||||
|
if len(e.HeaderEnc) > 0 {
|
||||||
|
if hdr, err := p.store.DecryptHeaderCache(e.ID, e.HeaderEnc); err == nil {
|
||||||
|
h := MessageHeader{ID: strconv.Itoa(e.UID), FolderID: folderID, SizeBytes: e.SizeBytes,
|
||||||
|
From: hdr.From, To: hdr.To, Subject: hdr.Subject, Date: hdr.Date}
|
||||||
|
if e.Flags != "" {
|
||||||
|
h.Flags = strings.Fields(e.Flags)
|
||||||
|
}
|
||||||
|
headers = append(headers, h)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
raw, err := p.store.Read(e.EMLPath)
|
raw, err := p.store.Read(e.EMLPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package accounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gomail/internal/crypto"
|
||||||
|
"gomail/internal/db"
|
||||||
|
"gomail/internal/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphAPIBase is the real Microsoft Graph v1.0 base URL —
|
||||||
|
// GraphAPIProvider.BaseURL defaults to this in NewGraphAPIProvider, and
|
||||||
|
// tests override the field directly to point at a fake server, same
|
||||||
|
// pattern as GmailAPIProvider.BaseURL.
|
||||||
|
const graphAPIBase = "https://graph.microsoft.com/v1.0/me"
|
||||||
|
|
||||||
|
// GraphAPIProvider implements MailProvider against the real Microsoft
|
||||||
|
// Graph API, replacing the earlier IMAP+XOAUTH2 transport for
|
||||||
|
// db.ProviderM365 accounts (see internal/accounts/link.go's ProviderFor).
|
||||||
|
// Graph has real mail folders (unlike Gmail's labels), so this maps onto
|
||||||
|
// MailProvider far more directly — folder IDs and message IDs are exactly
|
||||||
|
// what they already look like elsewhere in this package.
|
||||||
|
type GraphAPIProvider struct {
|
||||||
|
BaseURL string // overridable for tests; defaults to graphAPIBase
|
||||||
|
|
||||||
|
account *db.LinkedAccount
|
||||||
|
mk *crypto.MasterKey
|
||||||
|
database *db.DB
|
||||||
|
oauthConfig *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGraphAPIProvider(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *GraphAPIProvider {
|
||||||
|
return &GraphAPIProvider{BaseURL: graphAPIBase, account: account, mk: mk, database: database, oauthConfig: oauthConfig}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) accessToken(ctx context.Context) (string, error) {
|
||||||
|
cred, err := refreshedCredential(ctx, p.database, p.mk, p.account, p.oauthConfig)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// request performs an authenticated Graph API call and returns the raw
|
||||||
|
// response body — callers JSON-decode it themselves (or, for GetMessage's
|
||||||
|
// $value endpoint, use the raw MIME bytes directly).
|
||||||
|
func (p *GraphAPIProvider) request(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||||
|
token, err := p.accessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := oauth2.NewAuthedRequest(ctx, method, p.BaseURL+path, token, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if reqBody != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("graph api request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading graph api response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("graph api %s %s: status %d: %s", method, path, resp.StatusCode, truncateBody(respBody, 200))
|
||||||
|
}
|
||||||
|
return respBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
respBody, err := p.request(ctx, method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out != nil && len(respBody) > 0 {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("parsing graph api response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphFolderType maps Graph's wellKnownName values (or, failing that, the
|
||||||
|
// display name) to this package's Folder.Type vocabulary.
|
||||||
|
func graphFolderType(wellKnownName, displayName string) string {
|
||||||
|
switch wellKnownName {
|
||||||
|
case "inbox":
|
||||||
|
return "inbox"
|
||||||
|
case "sentitems":
|
||||||
|
return "sent"
|
||||||
|
case "drafts":
|
||||||
|
return "drafts"
|
||||||
|
case "deleteditems":
|
||||||
|
return "trash"
|
||||||
|
case "junkemail":
|
||||||
|
return "junk"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(displayName) {
|
||||||
|
case "inbox":
|
||||||
|
return "inbox"
|
||||||
|
case "sent items":
|
||||||
|
return "sent"
|
||||||
|
case "drafts":
|
||||||
|
return "drafts"
|
||||||
|
case "deleted items":
|
||||||
|
return "trash"
|
||||||
|
case "junk email":
|
||||||
|
return "junk"
|
||||||
|
default:
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphFolder struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
WellKnownName string `json:"wellKnownName"`
|
||||||
|
TotalItemCount int `json:"totalItemCount"`
|
||||||
|
UnreadItemCount int `json:"unreadItemCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) ListFolders(ctx context.Context) ([]Folder, error) {
|
||||||
|
var resp struct {
|
||||||
|
Value []graphFolder `json:"value"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/mailFolders?$top=100", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folders := make([]Folder, 0, len(resp.Value))
|
||||||
|
for _, f := range resp.Value {
|
||||||
|
folders = append(folders, Folder{
|
||||||
|
ID: f.ID, DisplayName: f.DisplayName, Type: graphFolderType(f.WellKnownName, f.DisplayName),
|
||||||
|
UnreadCount: f.UnreadItemCount, TotalCount: f.TotalItemCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return folders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphRecipient struct {
|
||||||
|
EmailAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"emailAddress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
From graphRecipient `json:"from"`
|
||||||
|
ToRecipients []graphRecipient `json:"toRecipients"`
|
||||||
|
ReceivedDateTime string `json:"receivedDateTime"`
|
||||||
|
IsRead bool `json:"isRead"`
|
||||||
|
Flag struct {
|
||||||
|
FlagStatus string `json:"flagStatus"`
|
||||||
|
} `json:"flag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphMessageToHeader translates Graph's isRead/flag fields into this
|
||||||
|
// package's IMAP-style flag-string convention (\Seen present means read —
|
||||||
|
// matching gmailLabelsToFlags' convention exactly, so the webmail API sees
|
||||||
|
// the same shape regardless of which provider a message came from).
|
||||||
|
func graphMessageToHeader(m graphMessage, folderID string) MessageHeader {
|
||||||
|
to := make([]string, 0, len(m.ToRecipients))
|
||||||
|
for _, r := range m.ToRecipients {
|
||||||
|
to = append(to, r.EmailAddress.Address)
|
||||||
|
}
|
||||||
|
h := MessageHeader{
|
||||||
|
ID: m.ID, FolderID: folderID,
|
||||||
|
From: m.From.EmailAddress.Address, To: strings.Join(to, ", "),
|
||||||
|
Subject: m.Subject, Date: m.ReceivedDateTime,
|
||||||
|
}
|
||||||
|
if m.IsRead {
|
||||||
|
h.Flags = append(h.Flags, "\\Seen")
|
||||||
|
}
|
||||||
|
if m.Flag.FlagStatus == "flagged" {
|
||||||
|
h.Flags = append(h.Flags, "\\Flagged")
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
|
||||||
|
limit := opts.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Value []graphMessage `json:"value"`
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=subject,from,toRecipients,receivedDateTime,isRead,flag", folderID, limit)
|
||||||
|
if err := p.do(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Graph's list call returns headers directly — no per-message follow-up
|
||||||
|
// call needed, unlike Gmail's list-then-metadata shape.
|
||||||
|
headers := make([]MessageHeader, 0, len(resp.Value))
|
||||||
|
for _, m := range resp.Value {
|
||||||
|
headers = append(headers, graphMessageToHeader(m, folderID))
|
||||||
|
}
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
|
||||||
|
// $value returns the raw MIME message body directly (message/rfc822),
|
||||||
|
// not JSON — request() gives us the bytes as-is.
|
||||||
|
raw, err := p.request(ctx, http.MethodGet, "/messages/"+messageID+"/$value", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta graphMessage
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/messages/"+messageID+"?$select=subject,from,toRecipients,receivedDateTime,isRead,flag", nil, &meta); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := graphMessageToHeader(meta, folderID)
|
||||||
|
h.SizeBytes = int64(len(raw))
|
||||||
|
return &FullMessage{MessageHeader: h, Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) SendMessage(ctx context.Context, msg *OutgoingMessage) error {
|
||||||
|
toRecipients := make([]map[string]any, 0, len(msg.To))
|
||||||
|
for _, addr := range msg.To {
|
||||||
|
toRecipients = append(toRecipients, map[string]any{"emailAddress": map[string]string{"address": addr}})
|
||||||
|
}
|
||||||
|
ccRecipients := make([]map[string]any, 0, len(msg.CC))
|
||||||
|
for _, addr := range msg.CC {
|
||||||
|
ccRecipients = append(ccRecipients, map[string]any{"emailAddress": map[string]string{"address": addr}})
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"message": map[string]any{
|
||||||
|
"subject": msg.Subject,
|
||||||
|
"body": map[string]string{"contentType": "Text", "content": msg.Body},
|
||||||
|
"toRecipients": toRecipients,
|
||||||
|
"ccRecipients": ccRecipients,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := p.request(ctx, http.MethodPost, "/sendMail", body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, f := range flags {
|
||||||
|
want[f] = true
|
||||||
|
}
|
||||||
|
flagStatus := "notFlagged"
|
||||||
|
if want["\\Flagged"] {
|
||||||
|
flagStatus = "flagged"
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"isRead": want["\\Seen"],
|
||||||
|
"flag": map[string]string{"flagStatus": flagStatus},
|
||||||
|
}
|
||||||
|
_, err := p.request(ctx, http.MethodPatch, "/messages/"+messageID, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GraphAPIProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
|
||||||
|
// Graph's move creates a new message resource in the destination
|
||||||
|
// folder (a new ID) — this interface's Move only reports success/
|
||||||
|
// failure, so the new ID (present in the response) is intentionally
|
||||||
|
// discarded rather than threaded back through a signature that has no
|
||||||
|
// way to return it to callers.
|
||||||
|
_, err := p.request(ctx, http.MethodPost, "/messages/"+messageID+"/move", map[string]string{"destinationId": destFolderID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the message — Graph's default DELETE lands it in Deleted
|
||||||
|
// Items rather than purging it, matching the same soft-delete convention
|
||||||
|
// as GmailAPIProvider.Delete's trash.
|
||||||
|
func (p *GraphAPIProvider) Delete(ctx context.Context, folderID, messageID string) error {
|
||||||
|
_, err := p.request(ctx, http.MethodDelete, "/messages/"+messageID, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync does a full re-list, the same depth as IMAPProvider.Sync and
|
||||||
|
// GmailAPIProvider.Sync (no CONDSTORE/QRESYNC or history diffing there
|
||||||
|
// either). Real Graph delta-query support (mailFolders/{id}/messages/delta)
|
||||||
|
// is deferred: nothing consumes SyncResult.NewCursor yet (no sync worker
|
||||||
|
// exists), and a delta query's cursor is scoped per-folder, not global the
|
||||||
|
// way Gmail's historyId is — building it now would be untested, unused
|
||||||
|
// code with no clear place to store a per-folder cursor in the current
|
||||||
|
// schema (LinkedAccount.SyncState is a single string).
|
||||||
|
func (p *GraphAPIProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
|
||||||
|
folders, err := p.ListFolders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var all []MessageHeader
|
||||||
|
for _, f := range folders {
|
||||||
|
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
all = append(all, msgs...)
|
||||||
|
}
|
||||||
|
return &SyncResult{NewMessages: all}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGraphDateTime parses Graph's dateTimeTimeZone shape — a naive
|
||||||
|
// timestamp (variable-precision fractional seconds, no offset) plus a
|
||||||
|
// separate IANA/Windows timeZone name. Falls back to UTC if the zone name
|
||||||
|
// can't be resolved rather than failing the whole request over a display
|
||||||
|
// timezone.
|
||||||
|
func parseGraphDateTime(dateTime, timeZone string) time.Time {
|
||||||
|
loc, err := time.LoadLocation(timeZone)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("2006-01-02T15:04:05.9999999", dateTime, loc)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphDateTimeTZ struct {
|
||||||
|
DateTime string `json:"dateTime"`
|
||||||
|
TimeZone string `json:"timeZone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEvents implements CalendarProvider against Graph's calendarView,
|
||||||
|
// which — unlike a plain /events listing — expands recurring events into
|
||||||
|
// individual occurrences within the window, matching what a calendar UI
|
||||||
|
// actually wants to render.
|
||||||
|
func (p *GraphAPIProvider) ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error) {
|
||||||
|
path := fmt.Sprintf("/calendarView?startDateTime=%s&endDateTime=%s",
|
||||||
|
url.QueryEscape(from.Format(time.RFC3339)), url.QueryEscape(to.Format(time.RFC3339)))
|
||||||
|
var resp struct {
|
||||||
|
Value []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
BodyPreview string `json:"bodyPreview"`
|
||||||
|
Location struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
} `json:"location"`
|
||||||
|
Start graphDateTimeTZ `json:"start"`
|
||||||
|
End graphDateTimeTZ `json:"end"`
|
||||||
|
IsAllDay bool `json:"isAllDay"`
|
||||||
|
} `json:"value"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events := make([]CalendarEvent, 0, len(resp.Value))
|
||||||
|
for _, it := range resp.Value {
|
||||||
|
events = append(events, CalendarEvent{
|
||||||
|
ID: it.ID, Summary: it.Subject, Location: it.Location.DisplayName, Description: it.BodyPreview,
|
||||||
|
Start: parseGraphDateTime(it.Start.DateTime, it.Start.TimeZone),
|
||||||
|
End: parseGraphDateTime(it.End.DateTime, it.End.TimeZone),
|
||||||
|
AllDay: it.IsAllDay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContacts implements ContactProvider against Graph's /me/contacts.
|
||||||
|
func (p *GraphAPIProvider) ListContacts(ctx context.Context) ([]Contact, error) {
|
||||||
|
var resp struct {
|
||||||
|
Value []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
EmailAddresses []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
} `json:"emailAddresses"`
|
||||||
|
BusinessPhones []string `json:"businessPhones"`
|
||||||
|
MobilePhone string `json:"mobilePhone"`
|
||||||
|
} `json:"value"`
|
||||||
|
}
|
||||||
|
if err := p.do(ctx, http.MethodGet, "/contacts", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contacts := make([]Contact, 0, len(resp.Value))
|
||||||
|
for _, c := range resp.Value {
|
||||||
|
contact := Contact{ID: c.ID, Name: c.DisplayName, Phones: append([]string{}, c.BusinessPhones...)}
|
||||||
|
if c.MobilePhone != "" {
|
||||||
|
contact.Phones = append(contact.Phones, c.MobilePhone)
|
||||||
|
}
|
||||||
|
for _, e := range c.EmailAddresses {
|
||||||
|
contact.Emails = append(contact.Emails, e.Address)
|
||||||
|
}
|
||||||
|
contacts = append(contacts, contact)
|
||||||
|
}
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"gomail/internal/crypto"
|
"gomail/internal/crypto"
|
||||||
"gomail/internal/db"
|
"gomail/internal/db"
|
||||||
"gomail/internal/imapclient"
|
"gomail/internal/imapclient"
|
||||||
"gomail/internal/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const dialTimeout = 20 * time.Second
|
const dialTimeout = 20 * time.Second
|
||||||
@@ -39,27 +38,19 @@ type OAuth2Credential struct {
|
|||||||
// connections" story without IDLE/pooling machinery this pass doesn't build
|
// connections" story without IDLE/pooling machinery this pass doesn't build
|
||||||
// yet, so simplicity wins: connect, do the one operation, disconnect. A
|
// yet, so simplicity wins: connect, do the one operation, disconnect. A
|
||||||
// later pass can add persistent connections if per-operation latency matters.
|
// later pass can add persistent connections if per-operation latency matters.
|
||||||
|
// IMAPProvider is password-authenticated only — db.ProviderIMAP accounts
|
||||||
|
// are always created via LinkIMAPAccount with AuthTypePassword.
|
||||||
|
// Gmail/M365 accounts (previously OAuth2-over-IMAP here) now use
|
||||||
|
// GmailAPIProvider/GraphAPIProvider instead — see link.go's ProviderFor.
|
||||||
type IMAPProvider struct {
|
type IMAPProvider struct {
|
||||||
account *db.LinkedAccount
|
account *db.LinkedAccount
|
||||||
mk *crypto.MasterKey
|
mk *crypto.MasterKey
|
||||||
database *db.DB // needed to persist a refreshed access token
|
|
||||||
oauthConfig *oauth2.Config // nil for password-auth accounts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
|
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
|
||||||
return &IMAPProvider{account: account, mk: mk}
|
return &IMAPProvider{account: account, mk: mk}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIMAPProviderOAuth2 is used for accounts.AuthTypeOAuth2 — the caller
|
|
||||||
// supplies the provider's oauth2.Config (built from operator-configured
|
|
||||||
// Client ID/Secret) so a stored access token can be refreshed transparently
|
|
||||||
// on expiry. database is used to persist the refreshed token — refreshing
|
|
||||||
// silently in memory only would force a re-refresh on every single
|
|
||||||
// operation instead of once per real expiry.
|
|
||||||
func NewIMAPProviderOAuth2(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *IMAPProvider {
|
|
||||||
return &IMAPProvider{account: account, mk: mk, database: database, oauthConfig: oauthConfig}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
|
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
|
||||||
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
|
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
|
||||||
// InsecureSkipVerify is a known gap, not a silent one: real ACME
|
// InsecureSkipVerify is a known gap, not a silent one: real ACME
|
||||||
@@ -85,13 +76,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.account.AuthType == db.AuthTypeOAuth2 {
|
|
||||||
if err := p.loginOAuth2(ctx, client); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
|
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decrypting stored credential: %w", err)
|
return nil, fmt.Errorf("decrypting stored credential: %w", err)
|
||||||
@@ -106,49 +90,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginOAuth2 decrypts the stored OAuth2 credential, transparently refreshes
|
|
||||||
// it if expired (persisting the new token so the next call doesn't have to
|
|
||||||
// refresh again), and authenticates via SASL XOAUTH2.
|
|
||||||
func (p *IMAPProvider) loginOAuth2(ctx context.Context, client *imapclient.Client) error {
|
|
||||||
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
|
|
||||||
}
|
|
||||||
var cred OAuth2Credential
|
|
||||||
if err := json.Unmarshal(plain, &cred); err != nil {
|
|
||||||
return fmt.Errorf("parsing stored OAuth2 credential: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().UTC().After(cred.ExpiresAt) {
|
|
||||||
if p.oauthConfig == nil {
|
|
||||||
return fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
|
|
||||||
}
|
|
||||||
newTok, err := p.oauthConfig.RefreshToken(ctx, cred.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("refreshing OAuth2 token: %w", err)
|
|
||||||
}
|
|
||||||
cred.AccessToken = newTok.AccessToken
|
|
||||||
cred.RefreshToken = newTok.RefreshToken
|
|
||||||
cred.ExpiresAt = newTok.ExpiresAt
|
|
||||||
|
|
||||||
if p.database != nil {
|
|
||||||
updated, err := json.Marshal(cred)
|
|
||||||
if err == nil {
|
|
||||||
if encUpdated, encErr := crypto.Encrypt(p.mk, p.account.ID, "linked-account-cred", updated); encErr == nil {
|
|
||||||
p.database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
|
|
||||||
encUpdated, cred.ExpiresAt, p.account.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sasl := oauth2.XOAUTH2SASLString(p.account.EmailAddress, cred.AccessToken)
|
|
||||||
if err := client.LoginXOAUTH2(sasl); err != nil {
|
|
||||||
return fmt.Errorf("XOAUTH2 login: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
|
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
|
||||||
client, err := p.connect(ctx)
|
client, err := p.connect(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"golang.org/x/crypto/hkdf"
|
"golang.org/x/crypto/hkdf"
|
||||||
)
|
)
|
||||||
@@ -57,17 +58,42 @@ func decodeKey(hexStr string) ([]byte, error) {
|
|||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyCache memoizes deriveKey results so repeatedly encrypting/decrypting the
|
||||||
|
// same record (e.g. re-viewing the same folder) skips the HKDF recompute —
|
||||||
|
// this is a real, measured cost when done per-message across a folder
|
||||||
|
// listing. Keyed on the raw master key bytes (as a string; no new exposure,
|
||||||
|
// the same bytes already live in-process via MasterKey.Current/Previous)
|
||||||
|
// alongside recordID/purpose so a key-rotation's Current vs Previous never
|
||||||
|
// collide.
|
||||||
|
// ponytail: unbounded — every distinct (recordID,purpose) ever seen stays
|
||||||
|
// cached for the process lifetime. Add an LRU/TTL eviction if a long-uptime
|
||||||
|
// instance with a very large distinct-message count ever makes this a real
|
||||||
|
// memory concern; not needed for a first pass.
|
||||||
|
var keyCache sync.Map
|
||||||
|
|
||||||
|
type keyCacheKey struct {
|
||||||
|
master string
|
||||||
|
recordID string
|
||||||
|
purpose string
|
||||||
|
}
|
||||||
|
|
||||||
// deriveKey produces a per-record 32-byte key from the master key using HKDF-SHA256.
|
// deriveKey produces a per-record 32-byte key from the master key using HKDF-SHA256.
|
||||||
// recordID should be a stable, unique identifier for the record (e.g. message ID,
|
// recordID should be a stable, unique identifier for the record (e.g. message ID,
|
||||||
// contact UID) — using the same recordID always derives the same key, which is
|
// contact UID) — using the same recordID always derives the same key, which is
|
||||||
// required for decryption to work.
|
// required for decryption to work.
|
||||||
func deriveKey(master []byte, recordID string, purpose string) ([]byte, error) {
|
func deriveKey(master []byte, recordID string, purpose string) ([]byte, error) {
|
||||||
|
ck := keyCacheKey{master: string(master), recordID: recordID, purpose: purpose}
|
||||||
|
if v, ok := keyCache.Load(ck); ok {
|
||||||
|
return v.([]byte), nil
|
||||||
|
}
|
||||||
|
|
||||||
info := []byte(purpose + ":" + recordID)
|
info := []byte(purpose + ":" + recordID)
|
||||||
r := hkdf.New(sha256.New, master, nil, info)
|
r := hkdf.New(sha256.New, master, nil, info)
|
||||||
key := make([]byte, keySize)
|
key := make([]byte, keySize)
|
||||||
if _, err := io.ReadFull(r, key); err != nil {
|
if _, err := io.ReadFull(r, key); err != nil {
|
||||||
return nil, fmt.Errorf("hkdf derive: %w", err)
|
return nil, fmt.Errorf("hkdf derive: %w", err)
|
||||||
}
|
}
|
||||||
|
keyCache.Store(ck, key)
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Package dane implements RFC 6698/7672 DANE for outbound SMTP delivery —
|
||||||
|
// verifying a remote MX's certificate against a TLSA DNS record instead of
|
||||||
|
// (or alongside) the normal CA/PKI trust model.
|
||||||
|
//
|
||||||
|
// Security caveat, stated plainly: DANE's guarantee depends entirely on the
|
||||||
|
// TLSA record itself coming from a DNSSEC-validated response. This package
|
||||||
|
// gets that validation from internal/dnssec, which performs real RRSIG/
|
||||||
|
// DNSKEY/DS chain-of-trust verification against IANA's root trust anchor —
|
||||||
|
// not merely a resolver's "AD" flag. An unauthenticated (or unverifiable)
|
||||||
|
// TLSA record is worthless (an attacker who can forge DNS can forge the
|
||||||
|
// record too) and is treated identically to "no record found".
|
||||||
|
package dane
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"gomail/internal/dnssec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate usage field values (RFC 6698 §2.1.1). Only DANE-TA/DANE-EE
|
||||||
|
// are matched by this package — RFC 7672 §3.1 recommends against PKIX-TA/
|
||||||
|
// PKIX-EE for SMTP, since both still depend on the CA/PKI trust model DANE
|
||||||
|
// exists to route around.
|
||||||
|
const (
|
||||||
|
UsagePKIXTA = 0
|
||||||
|
UsagePKIXEE = 1
|
||||||
|
UsageDANETA = 2
|
||||||
|
UsageDANEEE = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SelectorFullCert = 0
|
||||||
|
SelectorSPKI = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MatchingExact = 0
|
||||||
|
MatchingSHA256 = 1
|
||||||
|
MatchingSHA384 = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSARecord is one parsed TLSA resource record.
|
||||||
|
type TLSARecord struct {
|
||||||
|
Usage uint8
|
||||||
|
Selector uint8
|
||||||
|
MatchingType uint8
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup queries _<port>._tcp.<host> for TLSA records, requiring a fully
|
||||||
|
// DNSSEC-validated chain of trust (internal/dnssec) before trusting any of
|
||||||
|
// them. A broken or absent chain is treated identically to "no records",
|
||||||
|
// and records with usage 0/1 are skipped (logged, not silently dropped)
|
||||||
|
// since RFC 7672 recommends against them for SMTP.
|
||||||
|
func Lookup(ctx context.Context, host string, port int) ([]TLSARecord, error) {
|
||||||
|
qname := fmt.Sprintf("_%d._tcp.%s", port, host)
|
||||||
|
rrset, err := dnssec.Validate(ctx, qname, dns.TypeTLSA)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("TLSA lookup could not be DNSSEC-authenticated — ignoring; see internal/dane's package doc comment", "host", host, "port", port, "err", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []TLSARecord
|
||||||
|
for _, rr := range rrset {
|
||||||
|
tlsa, ok := rr.(*dns.TLSA)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := hex.DecodeString(tlsa.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec := TLSARecord{Usage: tlsa.Usage, Selector: tlsa.Selector, MatchingType: tlsa.MatchingType, Data: data}
|
||||||
|
if rec.Usage != UsageDANETA && rec.Usage != UsageDANEEE {
|
||||||
|
slog.Warn("TLSA record has a usage type not recommended for SMTP (RFC 7672 §3.1) — skipping", "host", host, "usage", rec.Usage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether cert (or its SPKI, per rec.Selector) matches
|
||||||
|
// rec's certificate association data under rec.MatchingType.
|
||||||
|
func (rec TLSARecord) matches(cert *x509.Certificate) bool {
|
||||||
|
var subject []byte
|
||||||
|
switch rec.Selector {
|
||||||
|
case SelectorFullCert:
|
||||||
|
subject = cert.Raw
|
||||||
|
case SelectorSPKI:
|
||||||
|
subject = cert.RawSubjectPublicKeyInfo
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var digest []byte
|
||||||
|
switch rec.MatchingType {
|
||||||
|
case MatchingExact:
|
||||||
|
digest = subject
|
||||||
|
case MatchingSHA256:
|
||||||
|
sum := sha256.Sum256(subject)
|
||||||
|
digest = sum[:]
|
||||||
|
case MatchingSHA384:
|
||||||
|
sum := sha512.Sum384(subject)
|
||||||
|
digest = sum[:]
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(digest, rec.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPeerCertificate builds a tls.Config.VerifyPeerCertificate callback
|
||||||
|
// that succeeds if ANY of records matches, per RFC 6698. Usage 3 (DANE-EE)
|
||||||
|
// checks only the leaf certificate the server presents; usage 2 (DANE-TA)
|
||||||
|
// checks every certificate presented (the constrained CA may be an
|
||||||
|
// intermediate, not the root). This deliberately never builds or verifies a
|
||||||
|
// chain to a trusted root store — usage 2/3's entire point is that the TLSA
|
||||||
|
// record itself is the trust anchor, not a CA pool. Pair with
|
||||||
|
// tls.Config.InsecureSkipVerify = true (this callback is the replacement
|
||||||
|
// verification, not an addition to normal PKI checking).
|
||||||
|
func VerifyPeerCertificate(records []TLSARecord) func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
if len(rawCerts) == 0 {
|
||||||
|
return fmt.Errorf("dane: server presented no certificates")
|
||||||
|
}
|
||||||
|
certs := make([]*x509.Certificate, 0, len(rawCerts))
|
||||||
|
for _, raw := range rawCerts {
|
||||||
|
cert, err := x509.ParseCertificate(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dane: parsing presented certificate: %w", err)
|
||||||
|
}
|
||||||
|
certs = append(certs, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
switch rec.Usage {
|
||||||
|
case UsageDANEEE:
|
||||||
|
if rec.matches(certs[0]) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case UsageDANETA:
|
||||||
|
for _, cert := range certs {
|
||||||
|
if rec.matches(cert) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("dane: no TLSA record matched the presented certificate chain")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-11
@@ -43,23 +43,35 @@ func Open(driver, dsn string) (*DB, error) {
|
|||||||
sqlDriverName = name
|
sqlDriverName = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if d == "sqlite" {
|
||||||
|
// Set these as mattn/go-sqlite3 DSN params, not a post-open PRAGMA
|
||||||
|
// Exec: with more than one pooled connection, each new physical
|
||||||
|
// connection database/sql opens is a fresh SQLite connection that
|
||||||
|
// does NOT inherit a PRAGMA set on a different one (journal_mode is
|
||||||
|
// the one exception — it's persisted in the DB file itself).
|
||||||
|
// _foreign_keys and _busy_timeout must be per-connection, so they
|
||||||
|
// have to ride in the DSN to apply to every connection the pool
|
||||||
|
// ever opens, not just the first.
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(dsn, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
dsn += sep + "_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||||
|
}
|
||||||
|
|
||||||
sqlDB, err := sql.Open(sqlDriverName, dsn)
|
sqlDB, err := sql.Open(sqlDriverName, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening database: %w", err)
|
return nil, fmt.Errorf("opening database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if d == "sqlite" {
|
if d == "sqlite" {
|
||||||
// SQLite doesn't handle concurrent writers well — serialize via single conn.
|
// WAL mode already lets SQLite itself handle concurrent readers +
|
||||||
sqlDB.SetMaxOpenConns(1)
|
// one writer (with _busy_timeout above covering writer contention) —
|
||||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
// a small pool, not a single shared connection, so concurrent
|
||||||
return nil, fmt.Errorf("enabling WAL mode: %w", err)
|
// requests across SMTP/IMAP/webmail/admin/etc. aren't all serialized
|
||||||
}
|
// through one connection for no reason.
|
||||||
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
sqlDB.SetMaxOpenConns(4)
|
||||||
return nil, fmt.Errorf("enabling foreign keys: %w", err)
|
sqlDB.SetMaxIdleConns(4)
|
||||||
}
|
|
||||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
|
||||||
return nil, fmt.Errorf("setting busy timeout: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
|||||||
@@ -368,6 +368,42 @@ CREATE TABLE mfa_backup_codes (
|
|||||||
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
|
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
|
||||||
|
|
||||||
ALTER TABLE users ADD COLUMN recovery_email TEXT;
|
ALTER TABLE users ADD COLUMN recovery_email TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Encrypted header summary (From/To/Subject/Date), populated at
|
||||||
|
// delivery time so folder listing can decrypt a few hundred bytes
|
||||||
|
// instead of the full message body. NULL for messages delivered
|
||||||
|
// before this migration — ListMessages falls back to a full read
|
||||||
|
// for those, see accounts.GoMailProvider.ListMessages.
|
||||||
|
name: "0012_mailbox_header_cache",
|
||||||
|
sql: map[string]string{
|
||||||
|
"sqlite": `
|
||||||
|
ALTER TABLE mailbox_index ADD COLUMN header_enc BLOB;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Cached MTA-STS policy per recipient domain (RFC 8461) — not
|
||||||
|
// encrypted, a domain's mail policy is public data at the same
|
||||||
|
// trust level as its SPF/DMARC/MX records, none of which are
|
||||||
|
// encrypted either. Cached because the RFC requires honoring the
|
||||||
|
// policy's own max_age rather than re-fetching on every message.
|
||||||
|
name: "0013_mta_sts_policies",
|
||||||
|
sql: map[string]string{
|
||||||
|
"sqlite": `
|
||||||
|
CREATE TABLE mta_sts_policies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
domain TEXT UNIQUE NOT NULL,
|
||||||
|
policy_id TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
mx_patterns TEXT NOT NULL,
|
||||||
|
max_age INTEGER NOT NULL,
|
||||||
|
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_mta_sts_policies_domain ON mta_sts_policies(domain);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ type MailboxEntry struct {
|
|||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
ReceivedAt time.Time
|
ReceivedAt time.Time
|
||||||
InternalDate time.Time
|
InternalDate time.Time
|
||||||
|
HeaderEnc []byte // encrypted CachedHeader JSON; nil for pre-migration rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Outbound queue ───────────────────────────────────────────────────────────
|
// ── Outbound queue ───────────────────────────────────────────────────────────
|
||||||
@@ -342,6 +343,20 @@ type TLSCert struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MTASTSPolicy is a cached RFC 8461 policy for a recipient domain, keyed by
|
||||||
|
// domain — not encrypted, a domain's published mail policy is public data
|
||||||
|
// (same trust level as its SPF/DMARC/MX records).
|
||||||
|
type MTASTSPolicy struct {
|
||||||
|
ID string
|
||||||
|
Domain string
|
||||||
|
PolicyID string
|
||||||
|
Mode string
|
||||||
|
MXPatterns string // JSON array
|
||||||
|
MaxAge int // seconds
|
||||||
|
FetchedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// ── MFA ───────────────────────────────────────────────────────────────────────
|
// ── MFA ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type MFABackupCode struct {
|
type MFABackupCode struct {
|
||||||
|
|||||||
+79
-10
@@ -288,9 +288,9 @@ func (db *DB) NextMailboxUID(userID, mailbox string) (int, error) {
|
|||||||
// InsertMailboxEntry records a delivered message in a user's mailbox index.
|
// InsertMailboxEntry records a delivered message in a user's mailbox index.
|
||||||
func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
|
func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date)
|
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate)
|
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate, e.HeaderEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert mailbox entry: %w", err)
|
return fmt.Errorf("insert mailbox entry: %w", err)
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@ func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
|
|||||||
// UID ascending — the order IMAP sequence numbers are defined against.
|
// UID ascending — the order IMAP sequence numbers are defined against.
|
||||||
func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error) {
|
func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error) {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date
|
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc
|
||||||
FROM mailbox_index WHERE user_id = ? AND mailbox = ? ORDER BY uid ASC
|
FROM mailbox_index WHERE user_id = ? AND mailbox = ? ORDER BY uid ASC
|
||||||
`, userID, mailbox)
|
`, userID, mailbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -312,7 +312,7 @@ func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error)
|
|||||||
var entries []MailboxEntry
|
var entries []MailboxEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e MailboxEntry
|
var e MailboxEntry
|
||||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate); err != nil {
|
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate, &e.HeaderEnc); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
@@ -584,11 +584,11 @@ func (db *DB) DeleteUser(id string) error {
|
|||||||
|
|
||||||
func (db *DB) GetUser(id string) (*User, error) {
|
func (db *DB) GetUser(id string) (*User, error) {
|
||||||
row := db.QueryRow(`SELECT id, tenant_id, domain_id, email, password_hash, display_name, role, active,
|
row := db.QueryRow(`SELECT id, tenant_id, domain_id, email, password_hash, display_name, role, active,
|
||||||
mfa_enabled, totp_secret_enc, recovery_email FROM users WHERE id = ?`, id)
|
mfa_enabled, totp_secret_enc, passkey_credentials_json, recovery_email FROM users WHERE id = ?`, id)
|
||||||
var u User
|
var u User
|
||||||
var displayName, recoveryEmail sql.NullString
|
var displayName, recoveryEmail sql.NullString
|
||||||
err := row.Scan(&u.ID, &u.TenantID, &u.DomainID, &u.Email, &u.PasswordHash, &displayName, &u.Role, &u.Active,
|
err := row.Scan(&u.ID, &u.TenantID, &u.DomainID, &u.Email, &u.PasswordHash, &displayName, &u.Role, &u.Active,
|
||||||
&u.MFAEnabled, &u.TOTPSecretEnc, &recoveryEmail)
|
&u.MFAEnabled, &u.TOTPSecretEnc, &u.PasskeyCredentialsJSON, &recoveryEmail)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1009,38 @@ func (db *DB) SetACMEAccountKey(domain string, keyEnc []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MTA-STS ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GetMTASTSPolicy returns the cached policy for domain, or ErrNotFound if
|
||||||
|
// none is cached (including an expired one — callers re-fetch on expiry,
|
||||||
|
// there is no separate "expired but present" state to distinguish).
|
||||||
|
func (db *DB) GetMTASTSPolicy(domain string) (*MTASTSPolicy, error) {
|
||||||
|
row := db.QueryRow(`SELECT id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at
|
||||||
|
FROM mta_sts_policies WHERE domain = ?`, domain)
|
||||||
|
var p MTASTSPolicy
|
||||||
|
err := row.Scan(&p.ID, &p.Domain, &p.PolicyID, &p.Mode, &p.MXPatterns, &p.MaxAge, &p.FetchedAt, &p.ExpiresAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get mta-sts policy: %w", err)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertMTASTSPolicy creates or updates the cached policy for p.Domain.
|
||||||
|
func (db *DB) UpsertMTASTSPolicy(p *MTASTSPolicy) error {
|
||||||
|
existing, err := db.GetMTASTSPolicy(p.Domain)
|
||||||
|
if err == nil {
|
||||||
|
_, err := db.Exec(`UPDATE mta_sts_policies SET policy_id = ?, mode = ?, mx_patterns = ?, max_age = ?, fetched_at = ?, expires_at = ? WHERE id = ?`,
|
||||||
|
p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt, existing.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`INSERT INTO mta_sts_policies (id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
uuidNew(), p.Domain, p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── MFA ───────────────────────────────────────────────────────────────────────
|
// ── MFA ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
|
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
|
||||||
@@ -1024,11 +1056,39 @@ func (db *DB) SetMFAEnabled(userID string, enabled bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearTOTPSecret removes TOTP specifically — it does NOT touch
|
||||||
|
// mfa_enabled, since a user with passkeys registered must keep MFA
|
||||||
|
// enabled after disabling just TOTP. Callers should follow this with
|
||||||
|
// RecomputeMFAEnabled.
|
||||||
func (db *DB) ClearTOTPSecret(userID string) error {
|
func (db *DB) ClearTOTPSecret(userID string) error {
|
||||||
_, err := db.Exec(`UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?`, userID)
|
_, err := db.Exec(`UPDATE users SET totp_secret_enc = NULL WHERE id = ?`, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPasskeyCredentials replaces the user's stored passkey credential list
|
||||||
|
// (a JSON array — see webauthn.StoredCredential) wholesale; callers read-
|
||||||
|
// modify-write the current list rather than this doing any partial update.
|
||||||
|
func (db *DB) SetPasskeyCredentials(userID, credentialsJSON string) error {
|
||||||
|
_, err := db.Exec(`UPDATE users SET passkey_credentials_json = ? WHERE id = ?`, credentialsJSON, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecomputeMFAEnabled sets mfa_enabled based on whether the user currently
|
||||||
|
// has any working second factor (TOTP confirmed, or at least one passkey)
|
||||||
|
// — called after ClearTOTPSecret or a passkey deletion, so removing one
|
||||||
|
// factor while the other remains doesn't silently disable the MFA
|
||||||
|
// requirement, and removing the last factor doesn't silently leave it
|
||||||
|
// enabled with nothing to satisfy it.
|
||||||
|
func (db *DB) RecomputeMFAEnabled(userID string) error {
|
||||||
|
user, err := db.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasTOTP := user.TOTPSecretEnc != nil
|
||||||
|
hasPasskey := user.PasskeyCredentialsJSON != "" && user.PasskeyCredentialsJSON != "[]"
|
||||||
|
return db.SetMFAEnabled(userID, hasTOTP || hasPasskey)
|
||||||
|
}
|
||||||
|
|
||||||
// ReplaceBackupCodes deletes any existing backup codes for the user and
|
// ReplaceBackupCodes deletes any existing backup codes for the user and
|
||||||
// inserts a fresh set — called once at MFA confirm time; codes are shown to
|
// inserts a fresh set — called once at MFA confirm time; codes are shown to
|
||||||
// the user exactly once, matching how app passwords are handled.
|
// the user exactly once, matching how app passwords are handled.
|
||||||
@@ -1078,10 +1138,19 @@ func (db *DB) ConsumeBackupCode(userID, candidateHash string) (bool, error) {
|
|||||||
if matchID == "" {
|
if matchID == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ?`, time.Now().UTC(), matchID); err != nil {
|
// AND used_at IS NULL + RowsAffected close the race: without it, two
|
||||||
|
// concurrent requests could both pass the SELECT scan above for the
|
||||||
|
// same code and both "successfully" consume it once MaxOpenConns > 1
|
||||||
|
// lets them run concurrently instead of serializing by accident.
|
||||||
|
res, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`, time.Now().UTC(), matchID)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return true, nil
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) SetRecoveryEmail(userID, email string) error {
|
func (db *DB) SetRecoveryEmail(userID, email string) error {
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Package dnssec performs real DNSSEC chain-of-trust validation — used by
|
||||||
|
// internal/dane to decide whether a TLSA record can be trusted, replacing
|
||||||
|
// a design that only checked the DNSSEC "AD" response flag. Built on
|
||||||
|
// github.com/miekg/dns, a deliberate, disclosed exception to this
|
||||||
|
// project's "no third-party protocol libraries" principle: miekg/dns
|
||||||
|
// handles DNS wire format and — critically — RRSIG.Verify's signature
|
||||||
|
// cryptography (RSA/ECDSA/EdDSA dispatch), the one piece of this whole
|
||||||
|
// codebase judged too high-risk to hand-roll. A subtle bug in hand-rolled
|
||||||
|
// chain validation either creates false security (silently accepting
|
||||||
|
// forged records) or breaks mail delivery outright.
|
||||||
|
//
|
||||||
|
// Fail-closed by design: every ambiguous case (missing DS, missing
|
||||||
|
// DNSKEY, a signature that doesn't verify, an expired signature, an
|
||||||
|
// unrecognized digest type) returns an error, which callers must treat as
|
||||||
|
// "not authenticated" — never as a reason to trust the data anyway. This
|
||||||
|
// is safe because of how the one caller (internal/dane) uses it: no
|
||||||
|
// authenticated TLSA record just means falling through to opportunistic/
|
||||||
|
// MTA-STS TLS, identical to today's behavior for any unsigned zone. A
|
||||||
|
// false rejection here costs nothing but the DANE guarantee; a false
|
||||||
|
// acceptance would mean trusting a forged certificate pin. This package
|
||||||
|
// is built to only ever fail in the safe direction.
|
||||||
|
//
|
||||||
|
// Deliberately out of scope: NSEC/NSEC3 denial-of-existence proofs.
|
||||||
|
// internal/dane only needs "if a TLSA record is asserted, is it validly
|
||||||
|
// signed" — not a cryptographic proof that no record exists — so a broken
|
||||||
|
// chain (no DS, unsigned zone) can simply mean "not authenticated"
|
||||||
|
// without needing to parse NSEC/NSEC3 records at all.
|
||||||
|
package dnssec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rootTrustAnchor is IANA's published root zone KSK-2017 DS record — the
|
||||||
|
// one axiom this package's chain of trust bottoms out at. Every DNSSEC-
|
||||||
|
// validating resolver (unbound, BIND, Knot Resolver, ...) ships with this
|
||||||
|
// same value. Published at data.iana.org/root-anchors/root-anchors.xml.
|
||||||
|
var rootTrustAnchor = &dns.DS{
|
||||||
|
KeyTag: 20326,
|
||||||
|
Algorithm: 8, // RSA/SHA-256
|
||||||
|
DigestType: dns.SHA256,
|
||||||
|
Digest: "E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D",
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = &dns.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
func resolvers() []string {
|
||||||
|
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||||
|
if err != nil || cfg == nil || len(cfg.Servers) == 0 {
|
||||||
|
return []string{"127.0.0.1:53"}
|
||||||
|
}
|
||||||
|
port := cfg.Port
|
||||||
|
if port == "" {
|
||||||
|
port = "53"
|
||||||
|
}
|
||||||
|
servers := make([]string, 0, len(cfg.Servers))
|
||||||
|
for _, s := range cfg.Servers {
|
||||||
|
servers = append(servers, net.JoinHostPort(s, port))
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// query asks for qname/qtype with the DNSSEC OK (DO) bit set — without it,
|
||||||
|
// most resolvers won't bother including RRSIG records in the response at
|
||||||
|
// all, DNSSEC-validating or not. Checking Disabled (CD) is also set: this
|
||||||
|
// package does its own validation and must not depend on the configured
|
||||||
|
// resolver's — a validating resolver that filters bad signatures on our
|
||||||
|
// behalf would make our own RRSIG.Verify calls untested dead code, and
|
||||||
|
// (worse) a resolver an attacker controls could set the AD flag on
|
||||||
|
// anything. CD=1 asks for the raw signed data every time, so a query
|
||||||
|
// against a permissive resolver and a query against a strict validating
|
||||||
|
// one are verified identically, by this code, not the resolver.
|
||||||
|
func query(ctx context.Context, qname string, qtype uint16) (*dns.Msg, error) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(dns.Fqdn(qname), qtype)
|
||||||
|
m.SetEdns0(4096, true)
|
||||||
|
m.RecursionDesired = true
|
||||||
|
m.CheckingDisabled = true
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, server := range resolvers() {
|
||||||
|
resp, _, err := client.ExchangeContext(ctx, m, server)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("query %s %s failed against all resolvers: %w", qname, dns.TypeToString[qtype], lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rrsetOf(msg *dns.Msg, rrtype uint16) []dns.RR {
|
||||||
|
var out []dns.RR
|
||||||
|
for _, rr := range msg.Answer {
|
||||||
|
if rr.Header().Rrtype == rrtype {
|
||||||
|
out = append(out, rr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rrsigsOf returns every RRSIG covering rrtype in msg's Answer section — an
|
||||||
|
// RRset is commonly covered by more than one (e.g. both KSK and ZSK sign
|
||||||
|
// the DNSKEY RRset, or two signatures coexist mid-rollover), so callers
|
||||||
|
// must try each rather than assume the first one found is the relevant
|
||||||
|
// one.
|
||||||
|
func rrsigsOf(msg *dns.Msg, covering uint16) []*dns.RRSIG {
|
||||||
|
var out []*dns.RRSIG
|
||||||
|
for _, rr := range msg.Answer {
|
||||||
|
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == covering {
|
||||||
|
out = append(out, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifiedBy reports whether any of sigs both matches a key in keys (by
|
||||||
|
// key tag) and verifies rrset under that key, within its validity period.
|
||||||
|
func verifiedBy(sigs []*dns.RRSIG, keys []*dns.DNSKEY, rrset []dns.RR) bool {
|
||||||
|
for _, sig := range sigs {
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.KeyTag() != sig.KeyTag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sig.Verify(key, rrset) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sig.ValidityPeriod(time.Now()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatedZoneKeys returns zone's DNSKEY RRset once its self-signature
|
||||||
|
// (by a KSK whose digest matches ds) is verified. ds nil means zone is the
|
||||||
|
// root, verified against the embedded rootTrustAnchor instead.
|
||||||
|
func validatedZoneKeys(ctx context.Context, zone string, ds *dns.DS) ([]*dns.DNSKEY, error) {
|
||||||
|
if ds == nil {
|
||||||
|
ds = rootTrustAnchor
|
||||||
|
}
|
||||||
|
resp, err := query(ctx, zone, dns.TypeDNSKEY)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dnskeyRRs := rrsetOf(resp, dns.TypeDNSKEY)
|
||||||
|
if len(dnskeyRRs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no DNSKEY records for zone %q", zone)
|
||||||
|
}
|
||||||
|
sigs := rrsigsOf(resp, dns.TypeDNSKEY)
|
||||||
|
if len(sigs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no RRSIG covering DNSKEY for zone %q", zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []*dns.DNSKEY
|
||||||
|
var matchedKSK *dns.DNSKEY
|
||||||
|
for _, rr := range dnskeyRRs {
|
||||||
|
key, ok := rr.(*dns.DNSKEY)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
if key.KeyTag() != ds.KeyTag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate := key.ToDS(ds.DigestType); candidate != nil && strings.EqualFold(candidate.Digest, ds.Digest) {
|
||||||
|
matchedKSK = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchedKSK == nil {
|
||||||
|
return nil, fmt.Errorf("no DNSKEY in zone %q matches the trusted DS (key tag %d)", zone, ds.KeyTag)
|
||||||
|
}
|
||||||
|
if !verifiedBy(sigs, []*dns.DNSKEY{matchedKSK}, dnskeyRRs) {
|
||||||
|
return nil, fmt.Errorf("DNSKEY RRSIG verification failed for zone %q against the trusted key", zone)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatedDS queries child's DS record, verified against the PARENT
|
||||||
|
// zone's already-trusted keys (a DS is signed by the parent, not the
|
||||||
|
// child — that's what makes it a delegation signer). Returns (nil, nil)
|
||||||
|
// — not an error — when there's genuinely no DS: child isn't a separate
|
||||||
|
// signed zone cut, the normal case for the overwhelming majority of
|
||||||
|
// labels (e.g. "_tcp" or "_25" under a TLSA lookup are never their own
|
||||||
|
// delegated zone).
|
||||||
|
func validatedDS(ctx context.Context, child string, parentKeys []*dns.DNSKEY) (*dns.DS, error) {
|
||||||
|
resp, err := query(ctx, child, dns.TypeDS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dsRRs := rrsetOf(resp, dns.TypeDS)
|
||||||
|
if len(dsRRs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
sigs := rrsigsOf(resp, dns.TypeDS)
|
||||||
|
if len(sigs) == 0 {
|
||||||
|
return nil, fmt.Errorf("DS record(s) for %q present but unsigned", child)
|
||||||
|
}
|
||||||
|
if !verifiedBy(sigs, parentKeys, dsRRs) {
|
||||||
|
return nil, fmt.Errorf("DS record(s) for %q could not be verified against the parent zone's trusted keys", child)
|
||||||
|
}
|
||||||
|
ds, ok := dsRRs[0].(*dns.DS)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("DS answer for %q did not contain a DS record", child)
|
||||||
|
}
|
||||||
|
return ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatedRRset fetches qname/qtype and verifies it against zoneKeys —
|
||||||
|
// the last step, after the chain of trust has been walked down to the
|
||||||
|
// zone that actually owns qname.
|
||||||
|
func validatedRRset(ctx context.Context, qname string, qtype uint16, zoneKeys []*dns.DNSKEY) ([]dns.RR, error) {
|
||||||
|
resp, err := query(ctx, qname, qtype)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rrset := rrsetOf(resp, qtype)
|
||||||
|
if len(rrset) == 0 {
|
||||||
|
return nil, fmt.Errorf("no %s records for %q", dns.TypeToString[qtype], qname)
|
||||||
|
}
|
||||||
|
sigs := rrsigsOf(resp, qtype)
|
||||||
|
if len(sigs) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s record(s) for %q present but unsigned", dns.TypeToString[qtype], qname)
|
||||||
|
}
|
||||||
|
if !verifiedBy(sigs, zoneKeys, rrset) {
|
||||||
|
return nil, fmt.Errorf("%s record(s) for %q could not be verified against the zone's trusted keys", dns.TypeToString[qtype], qname)
|
||||||
|
}
|
||||||
|
return rrset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate performs full DNSSEC chain validation for qname/qtype: starting
|
||||||
|
// from the embedded root trust anchor, it walks down every label of qname,
|
||||||
|
// checking for a real zone cut (a DS record) at each boundary — no DS
|
||||||
|
// simply means "still the same signing zone," not a failure — until it
|
||||||
|
// reaches the zone that actually owns qname, then verifies qname/qtype's
|
||||||
|
// own signature against that zone's validated keys. Returns the validated
|
||||||
|
// RRset only on a fully unbroken chain; any break returns an error.
|
||||||
|
func Validate(ctx context.Context, qname string, qtype uint16) ([]dns.RR, error) {
|
||||||
|
qname = dns.Fqdn(qname)
|
||||||
|
|
||||||
|
keys, err := validatedZoneKeys(ctx, ".", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("root zone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := dns.SplitDomainName(qname)
|
||||||
|
zone := "."
|
||||||
|
for i := len(labels) - 1; i >= 0; i-- {
|
||||||
|
var child string
|
||||||
|
if zone == "." {
|
||||||
|
child = labels[i] + "."
|
||||||
|
} else {
|
||||||
|
child = labels[i] + "." + zone
|
||||||
|
}
|
||||||
|
ds, err := validatedDS(ctx, child, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zone cut at %q: %w", child, err)
|
||||||
|
}
|
||||||
|
if ds != nil {
|
||||||
|
childKeys, err := validatedZoneKeys(ctx, child, ds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zone %q: %w", child, err)
|
||||||
|
}
|
||||||
|
keys = childKeys
|
||||||
|
}
|
||||||
|
zone = child
|
||||||
|
}
|
||||||
|
|
||||||
|
return validatedRRset(ctx, qname, qtype, keys)
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ package mailstore
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,6 +35,27 @@ func New(root string, mk *crypto.MasterKey, database *db.DB) *Store {
|
|||||||
return &Store{root: root, mk: mk, db: database}
|
return &Store{root: root, mk: mk, db: database}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CachedHeader is the small subset of a message's headers worth caching
|
||||||
|
// separately from the full body, so a folder listing can decrypt a few
|
||||||
|
// hundred bytes instead of the whole message just to show a list of
|
||||||
|
// subjects — see Deliver and DecryptHeaderCache.
|
||||||
|
type CachedHeader struct {
|
||||||
|
From, To, Subject, Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCachedHeader(raw []byte) CachedHeader {
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||||
|
if err != nil {
|
||||||
|
return CachedHeader{}
|
||||||
|
}
|
||||||
|
return CachedHeader{
|
||||||
|
From: msg.Header.Get("From"),
|
||||||
|
To: msg.Header.Get("To"),
|
||||||
|
Subject: msg.Header.Get("Subject"),
|
||||||
|
Date: msg.Header.Get("Date"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver writes a raw message into a user's mailbox, encrypting it at rest,
|
// Deliver writes a raw message into a user's mailbox, encrypting it at rest,
|
||||||
// allocates the next IMAP UID, and records the mailbox_index row. Returns the
|
// allocates the next IMAP UID, and records the mailbox_index row. Returns the
|
||||||
// assigned UID.
|
// assigned UID.
|
||||||
@@ -47,6 +70,18 @@ func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int,
|
|||||||
return 0, fmt.Errorf("encrypt message: %w", err)
|
return 0, fmt.Errorf("encrypt message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypted separately under its own purpose so folder listing can
|
||||||
|
// decrypt this small blob instead of the full message body — same
|
||||||
|
// per-record HKDF scheme, so "everything encrypted at rest" still
|
||||||
|
// holds. A failure here isn't fatal to delivery: ListMessages falls
|
||||||
|
// back to a full read when header_enc is absent.
|
||||||
|
var headerEnc []byte
|
||||||
|
if hdrJSON, err := json.Marshal(parseCachedHeader(raw)); err == nil {
|
||||||
|
if enc, err := crypto.Encrypt(s.mk, messageID, "message-header", hdrJSON); err == nil {
|
||||||
|
headerEnc = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
filename := maildirFilename(messageID)
|
filename := maildirFilename(messageID)
|
||||||
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", filename)
|
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", filename)
|
||||||
finalPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "cur", filename)
|
finalPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "cur", filename)
|
||||||
@@ -76,6 +111,7 @@ func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int,
|
|||||||
SizeBytes: int64(len(raw)),
|
SizeBytes: int64(len(raw)),
|
||||||
ReceivedAt: time.Now().UTC(),
|
ReceivedAt: time.Now().UTC(),
|
||||||
InternalDate: time.Now().UTC(),
|
InternalDate: time.Now().UTC(),
|
||||||
|
HeaderEnc: headerEnc,
|
||||||
}
|
}
|
||||||
if err := s.db.InsertMailboxEntry(entry); err != nil {
|
if err := s.db.InsertMailboxEntry(entry); err != nil {
|
||||||
// Best-effort cleanup of the file we just wrote — DB is the source of
|
// Best-effort cleanup of the file we just wrote — DB is the source of
|
||||||
@@ -104,6 +140,20 @@ func (s *Store) Read(path string) ([]byte, error) {
|
|||||||
return plaintext, nil
|
return plaintext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecryptHeaderCache decrypts a header_enc blob written by Deliver.
|
||||||
|
// messageID must be the same ID used at Deliver time (mailbox_index.id).
|
||||||
|
func (s *Store) DecryptHeaderCache(messageID string, headerEnc []byte) (CachedHeader, error) {
|
||||||
|
plaintext, err := crypto.Decrypt(s.mk, messageID, "message-header", headerEnc)
|
||||||
|
if err != nil {
|
||||||
|
return CachedHeader{}, fmt.Errorf("decrypt header cache: %w", err)
|
||||||
|
}
|
||||||
|
var h CachedHeader
|
||||||
|
if err := json.Unmarshal(plaintext, &h); err != nil {
|
||||||
|
return CachedHeader{}, fmt.Errorf("unmarshal header cache: %w", err)
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ensureMailboxDirs(userEmail, mailbox string) error {
|
func (s *Store) ensureMailboxDirs(userEmail, mailbox string) error {
|
||||||
base := s.mailboxDir(userEmail, mailbox)
|
base := s.mailboxDir(userEmail, mailbox)
|
||||||
for _, sub := range []string{"cur", "new", "tmp"} {
|
for _, sub := range []string{"cur", "new", "tmp"} {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Package mtasts implements RFC 8461 MTA-STS policy discovery for outbound
|
||||||
|
// SMTP delivery — a domain publishes a DNS TXT record plus an HTTPS-hosted
|
||||||
|
// policy document declaring which MX hosts must be used and whether TLS is
|
||||||
|
// mandatory. Unlike DANE, this doesn't depend on DNSSEC: the policy fetch's
|
||||||
|
// own TLS certificate (normal CA/PKI, already handled by net/http) is the
|
||||||
|
// trust anchor, per the RFC.
|
||||||
|
package mtasts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy is a parsed MTA-STS policy document.
|
||||||
|
type Policy struct {
|
||||||
|
ID string // from the _mta-sts TXT record, not the policy body
|
||||||
|
Mode string // "enforce" | "testing" | "none"
|
||||||
|
MXPatterns []string
|
||||||
|
MaxAge time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover fetches domain's MTA-STS policy. Returns (nil, nil) — not an
|
||||||
|
// error — if the domain has no _mta-sts TXT record at all, since that's the
|
||||||
|
// normal "this domain doesn't use MTA-STS" case.
|
||||||
|
func Discover(ctx context.Context, domain string) (*Policy, error) {
|
||||||
|
// Same net.DefaultResolver.LookupTXT convention already used by
|
||||||
|
// internal/pipeline's SPF/DMARC checks — no AD-flag need here, so no
|
||||||
|
// reason to use the hand-rolled dnsutil client for this lookup.
|
||||||
|
txts, err := net.DefaultResolver.LookupTXT(ctx, "_mta-sts."+domain)
|
||||||
|
if err != nil || len(txts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var policyID string
|
||||||
|
for _, txt := range txts {
|
||||||
|
if !strings.HasPrefix(txt, "v=STSv1") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(txt, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if id, ok := strings.CutPrefix(part, "id="); ok {
|
||||||
|
policyID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policyID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "https://mta-sts." + domain + "/.well-known/mta-sts.txt"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching mta-sts policy for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("mta-sts policy fetch for %s returned status %d", domain, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policies are meant to be small (a handful of mx lines) — cap the read
|
||||||
|
// against a hostile or misbehaving server rather than trusting Content-Length.
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading mta-sts policy body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := parsePolicy(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing mta-sts policy for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
policy.ID = policyID
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePolicy(body string) (*Policy, error) {
|
||||||
|
p := &Policy{}
|
||||||
|
var maxAgeSeconds int
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch key {
|
||||||
|
case "version":
|
||||||
|
if value != "STSv1" {
|
||||||
|
return nil, fmt.Errorf("unsupported policy version %q", value)
|
||||||
|
}
|
||||||
|
case "mode":
|
||||||
|
p.Mode = value
|
||||||
|
case "mx":
|
||||||
|
p.MXPatterns = append(p.MXPatterns, value)
|
||||||
|
case "max_age":
|
||||||
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
|
maxAgeSeconds = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Mode == "" {
|
||||||
|
return nil, fmt.Errorf("policy missing required 'mode' field")
|
||||||
|
}
|
||||||
|
p.MaxAge = time.Duration(maxAgeSeconds) * time.Second
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether host satisfies pattern, per RFC 8461 §4.1's
|
||||||
|
// one-label wildcard rule: "*.example.com" matches "mail.example.com" but
|
||||||
|
// not "example.com" itself or "a.mail.example.com".
|
||||||
|
func Matches(pattern, host string) bool {
|
||||||
|
pattern = strings.TrimSuffix(strings.ToLower(pattern), ".")
|
||||||
|
host = strings.TrimSuffix(strings.ToLower(host), ".")
|
||||||
|
|
||||||
|
suffix, isWildcard := strings.CutPrefix(pattern, "*.")
|
||||||
|
if !isWildcard {
|
||||||
|
return pattern == host
|
||||||
|
}
|
||||||
|
rest, ok := strings.CutSuffix(host, "."+suffix)
|
||||||
|
return ok && rest != "" && !strings.Contains(rest, ".")
|
||||||
|
}
|
||||||
@@ -154,6 +154,20 @@ func truncate(b []byte, n int) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAuthedRequest builds an HTTP request with the OAuth2 access token
|
||||||
|
// attached as a Bearer Authorization header — shared by anything that
|
||||||
|
// calls a provider's REST API (userinfo lookup, Gmail API, Graph API)
|
||||||
|
// rather than IMAP/SMTP. Callers still do their own Do()/JSON decoding,
|
||||||
|
// since every API's response shape differs.
|
||||||
|
func NewAuthedRequest(ctx context.Context, method, url, accessToken string, body io.Reader) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// XOAUTH2SASLString builds the SASL XOAUTH2 initial-response string (used
|
// XOAUTH2SASLString builds the SASL XOAUTH2 initial-response string (used
|
||||||
// by IMAP/SMTP clients authenticating with an OAuth2 access token instead
|
// by IMAP/SMTP clients authenticating with an OAuth2 access token instead
|
||||||
// of a password) per Google's documented format, which Microsoft also
|
// of a password) per Google's documented format, which Microsoft also
|
||||||
|
|||||||
+131
-3
@@ -4,7 +4,9 @@
|
|||||||
package queue
|
package queue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,9 +14,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gomail/internal/dane"
|
||||||
"gomail/internal/db"
|
"gomail/internal/db"
|
||||||
"gomail/internal/dkim"
|
"gomail/internal/dkim"
|
||||||
"gomail/internal/mailstore"
|
"gomail/internal/mailstore"
|
||||||
|
"gomail/internal/mtasts"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,7 +53,7 @@ func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
|
|||||||
return &Worker{
|
return &Worker{
|
||||||
database: database,
|
database: database,
|
||||||
store: store,
|
store: store,
|
||||||
deliverer: &MXDeliverer{Hostname: "gomail"},
|
deliverer: &MXDeliverer{Hostname: "gomail", Database: database},
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +255,7 @@ func isPermanentError(err error) bool {
|
|||||||
// uniformly regardless of which Deliverer implementation is in use.
|
// uniformly regardless of which Deliverer implementation is in use.
|
||||||
type MXDeliverer struct {
|
type MXDeliverer struct {
|
||||||
Hostname string // EHLO identity
|
Hostname string // EHLO identity
|
||||||
|
Database *db.DB // caches MTA-STS policies (RFC 8461 requires honoring max_age); nil just disables caching, not the feature — policy is re-fetched every delivery instead
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
||||||
@@ -271,7 +276,119 @@ func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tlsPolicy is what resolveTLSPolicy decides for one delivery attempt.
|
||||||
|
// mandatory distinguishes "TLS must succeed or this attempt fails" (DANE,
|
||||||
|
// MTA-STS enforce) from today's original opportunistic behavior (try,
|
||||||
|
// log and continue in plaintext on failure).
|
||||||
|
type tlsPolicy struct {
|
||||||
|
config *tls.Config
|
||||||
|
mandatory bool
|
||||||
|
source string // for logging: "dane" | "mta-sts:enforce" | "opportunistic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTLSPolicy decides what TLS behavior this delivery attempt must
|
||||||
|
// follow, most-specific first: DANE (per-host) over MTA-STS (per-domain)
|
||||||
|
// over plain opportunistic STARTTLS — today's original, unchanged default.
|
||||||
|
// A non-nil error means delivery to this host must not proceed at all
|
||||||
|
// (e.g. an MTA-STS enforce policy that doesn't list this host as valid —
|
||||||
|
// returned as a 550 so it bounces via the existing permanent-failure path
|
||||||
|
// instead of retrying forever against a host the domain's own policy
|
||||||
|
// disowns). See internal/dane and internal/mtasts package docs for the
|
||||||
|
// respective security models and caveats (DANE's DNSSEC dependency, in
|
||||||
|
// particular).
|
||||||
|
func (d *MXDeliverer) resolveTLSPolicy(ctx context.Context, host, domain string) (tlsPolicy, error) {
|
||||||
|
opportunistic := tlsPolicy{
|
||||||
|
config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12},
|
||||||
|
mandatory: false,
|
||||||
|
source: "opportunistic",
|
||||||
|
}
|
||||||
|
|
||||||
|
if records, err := dane.Lookup(ctx, host, 25); err != nil {
|
||||||
|
slog.Warn("DANE lookup failed, falling back to MTA-STS/opportunistic TLS", "host", host, "err", err)
|
||||||
|
} else if len(records) > 0 {
|
||||||
|
return tlsPolicy{
|
||||||
|
config: &tls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// Not disabling verification — replacing it. VerifyPeerCertificate
|
||||||
|
// is DANE's own check (RFC 6698 usage 2/3), see internal/dane.
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
VerifyPeerCertificate: dane.VerifyPeerCertificate(records),
|
||||||
|
},
|
||||||
|
mandatory: true,
|
||||||
|
source: "dane",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := d.mtaSTSPolicyFor(ctx, domain)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("MTA-STS policy lookup failed, falling back to opportunistic TLS", "domain", domain, "err", err)
|
||||||
|
return opportunistic, nil
|
||||||
|
}
|
||||||
|
if policy == nil || policy.Mode == "none" {
|
||||||
|
return opportunistic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hostMatches := false
|
||||||
|
for _, pattern := range policy.MXPatterns {
|
||||||
|
if mtasts.Matches(pattern, host) {
|
||||||
|
hostMatches = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch policy.Mode {
|
||||||
|
case "enforce":
|
||||||
|
if !hostMatches {
|
||||||
|
return tlsPolicy{}, fmt.Errorf("550 5.7.5 host %s is not listed in %s's MTA-STS policy (enforce mode)", host, domain)
|
||||||
|
}
|
||||||
|
return tlsPolicy{config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, mandatory: true, source: "mta-sts:enforce"}, nil
|
||||||
|
case "testing":
|
||||||
|
if !hostMatches {
|
||||||
|
slog.Warn("MTA-STS testing mode: host is not in the domain's policy (would fail under enforce)", "host", host, "domain", domain)
|
||||||
|
}
|
||||||
|
return opportunistic, nil
|
||||||
|
default:
|
||||||
|
return opportunistic, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mtaSTSPolicyFor returns domain's MTA-STS policy, using the DB cache when
|
||||||
|
// present and unexpired (RFC 8461 requires honoring the policy's own
|
||||||
|
// max_age) and fetching fresh otherwise. Returns (nil, nil) if the domain
|
||||||
|
// has no MTA-STS policy at all.
|
||||||
|
func (d *MXDeliverer) mtaSTSPolicyFor(ctx context.Context, domain string) (*mtasts.Policy, error) {
|
||||||
|
if d.Database != nil {
|
||||||
|
if cached, err := d.Database.GetMTASTSPolicy(domain); err == nil && time.Now().UTC().Before(cached.ExpiresAt) {
|
||||||
|
var patterns []string
|
||||||
|
if err := json.Unmarshal([]byte(cached.MXPatterns), &patterns); err == nil {
|
||||||
|
return &mtasts.Policy{ID: cached.PolicyID, Mode: cached.Mode, MXPatterns: patterns, MaxAge: time.Duration(cached.MaxAge) * time.Second}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := mtasts.Discover(ctx, domain)
|
||||||
|
if err != nil || policy == nil {
|
||||||
|
return policy, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Database != nil {
|
||||||
|
patternsJSON, _ := json.Marshal(policy.MXPatterns)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := d.Database.UpsertMTASTSPolicy(&db.MTASTSPolicy{
|
||||||
|
Domain: domain, PolicyID: policy.ID, Mode: policy.Mode,
|
||||||
|
MXPatterns: string(patternsJSON), MaxAge: int(policy.MaxAge.Seconds()),
|
||||||
|
FetchedAt: now, ExpiresAt: now.Add(policy.MaxAge),
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("failed to cache MTA-STS policy", "domain", domain, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
||||||
|
domain := domainOf(to)
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
|
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
|
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
|
||||||
@@ -289,11 +406,22 @@ func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
|||||||
return fmt.Errorf("EHLO to %s failed: %w", host, err)
|
return fmt.Errorf("EHLO to %s failed: %w", host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
policyCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
policy, err := d.resolveTLSPolicy(policyCtx, host, domain)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
if err := client.StartTLS(policy.config); err != nil {
|
||||||
if err := client.StartTLS(tlsConf); err != nil {
|
if policy.mandatory {
|
||||||
|
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) failed for %s: %w", policy.source, host, err)
|
||||||
|
}
|
||||||
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
|
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
|
||||||
}
|
}
|
||||||
|
} else if policy.mandatory {
|
||||||
|
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) required for %s but server does not offer STARTTLS", policy.source, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.Mail(from); err != nil {
|
if err := client.Mail(from); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package webauthn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cborDecode is a minimal CBOR (RFC 8949) decoder covering only the subset
|
||||||
|
// WebAuthn actually uses: unsigned/negative integers, byte strings, text
|
||||||
|
// strings, arrays, maps, and the true/false/null simple values —
|
||||||
|
// definite-length items only (WebAuthn's attestationObject/COSE keys never
|
||||||
|
// use CBOR's indefinite-length encoding). This is not a general-purpose
|
||||||
|
// CBOR library, the same way internal/dnsutil is not a general DNS library.
|
||||||
|
//
|
||||||
|
// All integers (major types 0 and 1) decode to Go int64 — COSE key labels
|
||||||
|
// mix small positive (kty=1, alg=3) and negative (crv=-1, x=-2, y=-3)
|
||||||
|
// values, and normalizing to one Go type avoids uint64-vs-int64 mismatches
|
||||||
|
// when looking values up in a decoded map.
|
||||||
|
func cborDecode(data []byte) (any, error) {
|
||||||
|
d := &cborDecoder{data: data}
|
||||||
|
return d.decodeValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cborDecodeWithLength is cborDecode plus how many bytes were consumed —
|
||||||
|
// needed when a CBOR item (a COSE key) is embedded inside a larger binary
|
||||||
|
// structure (authData) followed by more data, not the whole buffer.
|
||||||
|
func cborDecodeWithLength(data []byte) (any, int, error) {
|
||||||
|
d := &cborDecoder{data: data}
|
||||||
|
v, err := d.decodeValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return v, d.pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type cborDecoder struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *cborDecoder) readByte() (byte, error) {
|
||||||
|
if d.pos >= len(d.data) {
|
||||||
|
return 0, fmt.Errorf("cbor: unexpected end of data")
|
||||||
|
}
|
||||||
|
b := d.data[d.pos]
|
||||||
|
d.pos++
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *cborDecoder) readBytes(n int) ([]byte, error) {
|
||||||
|
if n < 0 || d.pos+n > len(d.data) {
|
||||||
|
return nil, fmt.Errorf("cbor: unexpected end of data (need %d bytes, have %d)", n, len(d.data)-d.pos)
|
||||||
|
}
|
||||||
|
b := d.data[d.pos : d.pos+n]
|
||||||
|
d.pos += n
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLength reads the additional-info length/value encoding shared by
|
||||||
|
// every major type: 0-23 is a literal value, 24/25/26/27 mean 1/2/4/8
|
||||||
|
// following bytes hold it. Indefinite length (additional info 31) is
|
||||||
|
// rejected — not used by anything this package parses.
|
||||||
|
func (d *cborDecoder) readLength(additionalInfo byte) (uint64, error) {
|
||||||
|
switch {
|
||||||
|
case additionalInfo < 24:
|
||||||
|
return uint64(additionalInfo), nil
|
||||||
|
case additionalInfo == 24:
|
||||||
|
b, err := d.readByte()
|
||||||
|
return uint64(b), err
|
||||||
|
case additionalInfo == 25:
|
||||||
|
b, err := d.readBytes(2)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(binary.BigEndian.Uint16(b)), nil
|
||||||
|
case additionalInfo == 26:
|
||||||
|
b, err := d.readBytes(4)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(binary.BigEndian.Uint32(b)), nil
|
||||||
|
case additionalInfo == 27:
|
||||||
|
b, err := d.readBytes(8)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return binary.BigEndian.Uint64(b), nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("cbor: indefinite-length items are not supported (additional info %d)", additionalInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *cborDecoder) decodeValue() (any, error) {
|
||||||
|
head, err := d.readByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
majorType := head >> 5
|
||||||
|
additionalInfo := head & 0x1F
|
||||||
|
|
||||||
|
switch majorType {
|
||||||
|
case 0: // unsigned integer
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return int64(n), nil
|
||||||
|
case 1: // negative integer: value = -1 - n
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return -1 - int64(n), nil
|
||||||
|
case 2: // byte string
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d.readBytes(int(n))
|
||||||
|
case 3: // text string
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := d.readBytes(int(n))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
case 4: // array
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr := make([]any, n)
|
||||||
|
for i := range arr {
|
||||||
|
v, err := d.decodeValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr[i] = v
|
||||||
|
}
|
||||||
|
return arr, nil
|
||||||
|
case 5: // map
|
||||||
|
n, err := d.readLength(additionalInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[any]any, n)
|
||||||
|
for i := uint64(0); i < n; i++ {
|
||||||
|
k, err := d.decodeValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := d.decodeValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case 7: // simple values: only false/true/null are meaningful here
|
||||||
|
switch additionalInfo {
|
||||||
|
case 20:
|
||||||
|
return false, nil
|
||||||
|
case 21:
|
||||||
|
return true, nil
|
||||||
|
case 22, 23:
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cbor: unsupported simple/float value (additional info %d)", additionalInfo)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cbor: unsupported major type %d", majorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// Package webauthn implements enough of the W3C WebAuthn spec to use
|
||||||
|
// passkeys as a second authentication factor alongside TOTP/backup codes
|
||||||
|
// (internal/totp) — not a general-purpose WebAuthn library. Hand-rolled on
|
||||||
|
// stdlib crypto (crypto/ecdsa, crypto/elliptic) plus this package's own
|
||||||
|
// minimal CBOR decoder (cbor.go), no third-party WebAuthn/CBOR library —
|
||||||
|
// same dependency-minimal principle as every other protocol in this
|
||||||
|
// codebase.
|
||||||
|
//
|
||||||
|
// Two deliberate scope decisions, stated plainly:
|
||||||
|
//
|
||||||
|
// 1. Attestation statements are read but never cryptographically
|
||||||
|
// verified. Proving *which physical authenticator model* registered a
|
||||||
|
// credential requires vendor root CA bundles and per-format parsing
|
||||||
|
// (packed/fido-u2f/tpm/android-safetynet/apple — five-plus separate
|
||||||
|
// formats), and doesn't add login security: every subsequent
|
||||||
|
// authentication is still fully verified by VerifyAssertion's own
|
||||||
|
// signature check regardless of how registration was attested. This
|
||||||
|
// matches attestation:"none" handling, the default most real-world
|
||||||
|
// passkey deployments (GitHub, Google) actually use.
|
||||||
|
// 2. Only the ES256 (ECDSA P-256) COSE algorithm is supported — what
|
||||||
|
// virtually every modern authenticator (Windows Hello, Touch/Face ID,
|
||||||
|
// YubiKeys, Android) defaults to. RS256/EdDSA are rejected with a
|
||||||
|
// clear error at registration, not silently mismatched later.
|
||||||
|
package webauthn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
coseKtyEC2 = 2
|
||||||
|
coseAlgES256 = -7
|
||||||
|
coseCrvP256 = 1
|
||||||
|
|
||||||
|
flagUserPresent = 0x01
|
||||||
|
flagUserVerified = 0x04
|
||||||
|
flagAttestedCredentialData = 0x40
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthData is the parsed contents of WebAuthn's authenticatorData
|
||||||
|
// structure (spec §6.1) — a fixed binary layout, not CBOR, embedded as a
|
||||||
|
// byte string inside the CBOR-encoded attestationObject.
|
||||||
|
type AuthData struct {
|
||||||
|
RPIDHash []byte
|
||||||
|
Flags byte
|
||||||
|
SignCount uint32
|
||||||
|
AAGUID []byte // zero-length for an assertion's authData (only present at registration)
|
||||||
|
CredentialID []byte
|
||||||
|
PublicKey *ecdsa.PublicKey // nil for an assertion's authData (only present at registration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthData) UserPresent() bool { return a.Flags&flagUserPresent != 0 }
|
||||||
|
func (a *AuthData) UserVerified() bool { return a.Flags&flagUserVerified != 0 }
|
||||||
|
|
||||||
|
// ParseAuthData parses a raw authenticatorData byte string — used both for
|
||||||
|
// registration (where it includes attestedCredentialData) and for
|
||||||
|
// authentication assertions (where it doesn't).
|
||||||
|
func ParseAuthData(data []byte) (*AuthData, error) {
|
||||||
|
const fixedLen = 32 + 1 + 4 // rpIdHash + flags + signCount
|
||||||
|
if len(data) < fixedLen {
|
||||||
|
return nil, fmt.Errorf("webauthn: authData too short (%d bytes, need at least %d)", len(data), fixedLen)
|
||||||
|
}
|
||||||
|
a := &AuthData{
|
||||||
|
RPIDHash: append([]byte{}, data[0:32]...),
|
||||||
|
Flags: data[32],
|
||||||
|
SignCount: binary.BigEndian.Uint32(data[33:37]),
|
||||||
|
}
|
||||||
|
offset := 37
|
||||||
|
if a.Flags&flagAttestedCredentialData != 0 {
|
||||||
|
if len(data) < offset+16+2 {
|
||||||
|
return nil, fmt.Errorf("webauthn: authData truncated in attested credential data")
|
||||||
|
}
|
||||||
|
a.AAGUID = append([]byte{}, data[offset:offset+16]...)
|
||||||
|
offset += 16
|
||||||
|
credIDLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
|
||||||
|
offset += 2
|
||||||
|
if len(data) < offset+credIDLen {
|
||||||
|
return nil, fmt.Errorf("webauthn: authData truncated in credential ID")
|
||||||
|
}
|
||||||
|
a.CredentialID = append([]byte{}, data[offset:offset+credIDLen]...)
|
||||||
|
offset += credIDLen
|
||||||
|
|
||||||
|
pubKey, consumed, err := parseCOSEKey(data[offset:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webauthn: parsing credential public key: %w", err)
|
||||||
|
}
|
||||||
|
a.PublicKey = pubKey
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCOSEKey decodes a COSE_Key CBOR map (RFC 9053 §7.1) starting at the
|
||||||
|
// beginning of data, returning the P-256 public key and how many bytes of
|
||||||
|
// data the CBOR item occupied (so the caller — mid-way through parsing a
|
||||||
|
// larger authData buffer — knows where it ends). Only EC2/ES256/P-256 is
|
||||||
|
// supported; see the package doc comment.
|
||||||
|
func parseCOSEKey(data []byte) (*ecdsa.PublicKey, int, error) {
|
||||||
|
v, consumed, err := cborDecodeWithLength(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
m, ok := v.(map[any]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, fmt.Errorf("COSE key is not a CBOR map")
|
||||||
|
}
|
||||||
|
kty, _ := m[int64(1)].(int64)
|
||||||
|
if kty != coseKtyEC2 {
|
||||||
|
return nil, 0, fmt.Errorf("unsupported COSE key type %d (only EC2/%d is supported)", kty, coseKtyEC2)
|
||||||
|
}
|
||||||
|
alg, _ := m[int64(3)].(int64)
|
||||||
|
if alg != coseAlgES256 {
|
||||||
|
return nil, 0, fmt.Errorf("unsupported COSE algorithm %d (only ES256/%d is supported)", alg, coseAlgES256)
|
||||||
|
}
|
||||||
|
crv, _ := m[int64(-1)].(int64)
|
||||||
|
if crv != coseCrvP256 {
|
||||||
|
return nil, 0, fmt.Errorf("unsupported COSE curve %d (only P-256/%d is supported)", crv, coseCrvP256)
|
||||||
|
}
|
||||||
|
xBytes, _ := m[int64(-2)].([]byte)
|
||||||
|
yBytes, _ := m[int64(-3)].([]byte)
|
||||||
|
if len(xBytes) == 0 || len(yBytes) == 0 {
|
||||||
|
return nil, 0, fmt.Errorf("COSE EC2 key missing x/y coordinate")
|
||||||
|
}
|
||||||
|
pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(xBytes), Y: new(big.Int).SetBytes(yBytes)}
|
||||||
|
return pub, consumed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAttestationObject CBOR-decodes a registration ceremony's
|
||||||
|
// attestationObject and extracts authData. The attestation statement
|
||||||
|
// ("attStmt"/"fmt") is intentionally not verified — see the package doc
|
||||||
|
// comment.
|
||||||
|
func ParseAttestationObject(raw []byte) (*AuthData, error) {
|
||||||
|
v, err := cborDecode(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webauthn: decoding attestation object: %w", err)
|
||||||
|
}
|
||||||
|
m, ok := v.(map[any]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("webauthn: attestation object is not a CBOR map")
|
||||||
|
}
|
||||||
|
authDataBytes, ok := m["authData"].([]byte)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("webauthn: attestation object missing authData")
|
||||||
|
}
|
||||||
|
return ParseAuthData(authDataBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePublicKey/DecodePublicKey store a verified P-256 public key as
|
||||||
|
// fixed-width big-endian X||Y coordinates (base64-encoded for storage in
|
||||||
|
// the credentials JSON) — simpler than re-deriving the COSE encoding on
|
||||||
|
// every load, since nothing after registration needs the original CBOR form.
|
||||||
|
func EncodePublicKey(pub *ecdsa.PublicKey) string {
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
pub.X.FillBytes(buf[0:32])
|
||||||
|
pub.Y.FillBytes(buf[32:64])
|
||||||
|
return base64.StdEncoding.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodePublicKey(encoded string) (*ecdsa.PublicKey, error) {
|
||||||
|
buf, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil || len(buf) != 64 {
|
||||||
|
return nil, fmt.Errorf("webauthn: invalid stored public key")
|
||||||
|
}
|
||||||
|
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(buf[0:32]), Y: new(big.Int).SetBytes(buf[32:64])}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientData is the parsed JSON body of WebAuthn's clientDataJSON (spec
|
||||||
|
// §5.8.1) — plain JSON, not CBOR.
|
||||||
|
type clientData struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyClientData checks clientDataJSON's type/challenge/origin against
|
||||||
|
// expectations, returning the parsed struct and its SHA-256 hash (needed
|
||||||
|
// by both registration and assertion verification).
|
||||||
|
func verifyClientData(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) ([32]byte, error) {
|
||||||
|
var cd clientData
|
||||||
|
if err := json.Unmarshal(clientDataJSON, &cd); err != nil {
|
||||||
|
return [32]byte{}, fmt.Errorf("webauthn: parsing clientDataJSON: %w", err)
|
||||||
|
}
|
||||||
|
if cd.Type != expectedType {
|
||||||
|
return [32]byte{}, fmt.Errorf("webauthn: clientData type %q, want %q", cd.Type, expectedType)
|
||||||
|
}
|
||||||
|
if cd.Challenge != expectedChallenge {
|
||||||
|
return [32]byte{}, fmt.Errorf("webauthn: challenge mismatch")
|
||||||
|
}
|
||||||
|
if cd.Origin != expectedOrigin {
|
||||||
|
return [32]byte{}, fmt.Errorf("webauthn: origin %q, want %q", cd.Origin, expectedOrigin)
|
||||||
|
}
|
||||||
|
return sha256.Sum256(clientDataJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChallenge returns a fresh random challenge, base64url-encoded (no
|
||||||
|
// padding) per WebAuthn's own convention for challenge/credential-ID
|
||||||
|
// encoding in JSON.
|
||||||
|
func NewChallenge() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("webauthn: generating challenge: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoredCredential is what gets persisted (as one element of the JSON
|
||||||
|
// array in db.User.PasskeyCredentialsJSON) per registered passkey.
|
||||||
|
type StoredCredential struct {
|
||||||
|
ID string `json:"id"` // base64url credential ID
|
||||||
|
PublicKey string `json:"public_key"` // see EncodePublicKey
|
||||||
|
SignCount uint32 `json:"sign_count"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRegistration validates a registration ceremony's response and
|
||||||
|
// returns the parsed AuthData (CredentialID/PublicKey) to store on
|
||||||
|
// success. expectedChallenge/expectedRPID/expectedOrigin must come from
|
||||||
|
// the server's own state (the challenge it issued, its own configured
|
||||||
|
// hostname/origin) — never trust these as inputs from the client.
|
||||||
|
func VerifyRegistration(clientDataJSON, attestationObject []byte, expectedChallenge, expectedRPID, expectedOrigin string) (*AuthData, error) {
|
||||||
|
if _, err := verifyClientData(clientDataJSON, "webauthn.create", expectedChallenge, expectedOrigin); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
authData, err := ParseAttestationObject(attestationObject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpIDHash := sha256.Sum256([]byte(expectedRPID))
|
||||||
|
if !bytes.Equal(authData.RPIDHash, rpIDHash[:]) {
|
||||||
|
return nil, fmt.Errorf("webauthn: rpIdHash mismatch")
|
||||||
|
}
|
||||||
|
if !authData.UserPresent() {
|
||||||
|
return nil, fmt.Errorf("webauthn: user presence flag not set")
|
||||||
|
}
|
||||||
|
if authData.PublicKey == nil || len(authData.CredentialID) == 0 {
|
||||||
|
return nil, fmt.Errorf("webauthn: attestation object missing attested credential data")
|
||||||
|
}
|
||||||
|
return authData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAssertion validates an authentication ceremony's response against
|
||||||
|
// a previously stored credential, returning the sign count to persist
|
||||||
|
// (callers should reject/warn if it didn't increase — see below — and
|
||||||
|
// always persist whatever value is returned).
|
||||||
|
func VerifyAssertion(cred StoredCredential, clientDataJSON, authenticatorData, signature []byte, expectedChallenge, expectedRPID, expectedOrigin string) (newSignCount uint32, err error) {
|
||||||
|
clientDataHash, err := verifyClientData(clientDataJSON, "webauthn.get", expectedChallenge, expectedOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
authData, err := ParseAuthData(authenticatorData)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rpIDHash := sha256.Sum256([]byte(expectedRPID))
|
||||||
|
if !bytes.Equal(authData.RPIDHash, rpIDHash[:]) {
|
||||||
|
return 0, fmt.Errorf("webauthn: rpIdHash mismatch")
|
||||||
|
}
|
||||||
|
if !authData.UserPresent() {
|
||||||
|
return 0, fmt.Errorf("webauthn: user presence flag not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := DecodePublicKey(cred.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per WebAuthn §7.2: the signature covers SHA-256(authenticatorData ||
|
||||||
|
// clientDataHash), signed with ECDSA — browsers produce ASN.1 DER
|
||||||
|
// signatures for this, which ecdsa.VerifyASN1 (stdlib, Go 1.15+)
|
||||||
|
// verifies directly.
|
||||||
|
signedData := append(append([]byte{}, authenticatorData...), clientDataHash[:]...)
|
||||||
|
digest := sha256.Sum256(signedData)
|
||||||
|
if !ecdsa.VerifyASN1(pubKey, digest[:], signature) {
|
||||||
|
return 0, fmt.Errorf("webauthn: signature verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-increasing counter can mean a cloned authenticator — but many
|
||||||
|
// real platform authenticators (Touch ID, Windows Hello) legitimately
|
||||||
|
// report 0 on every assertion, which is spec-compliant, not a clone.
|
||||||
|
// Only warn when at least one side has ever reported a nonzero count.
|
||||||
|
if (cred.SignCount != 0 || authData.SignCount != 0) && authData.SignCount <= cred.SignCount {
|
||||||
|
slog.Warn("webauthn: assertion sign count did not increase — possible cloned authenticator", "credential_id", cred.ID)
|
||||||
|
}
|
||||||
|
return authData.SignCount, nil
|
||||||
|
}
|
||||||
+753
-17
@@ -9,11 +9,14 @@ package webmail
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,6 +29,7 @@ import (
|
|||||||
"gomail/internal/mailstore"
|
"gomail/internal/mailstore"
|
||||||
"gomail/internal/oauth2"
|
"gomail/internal/oauth2"
|
||||||
"gomail/internal/totp"
|
"gomail/internal/totp"
|
||||||
|
"gomail/internal/webauthn"
|
||||||
"gomail/internal/webtoken"
|
"gomail/internal/webtoken"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -38,11 +42,15 @@ type Handler struct {
|
|||||||
store *mailstore.Store
|
store *mailstore.Store
|
||||||
mk *crypto.MasterKey
|
mk *crypto.MasterKey
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
|
hostname string // WebAuthn RP ID; origin is "https://"+hostname
|
||||||
|
|
||||||
oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured
|
oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured
|
||||||
|
|
||||||
oauthStateMu sync.Mutex
|
oauthStateMu sync.Mutex
|
||||||
oauthState map[string]oauthStateEntry // CSRF state -> pending link request
|
oauthState map[string]oauthStateEntry // CSRF state -> pending link request
|
||||||
|
|
||||||
|
webauthnMu sync.Mutex
|
||||||
|
webauthnState map[string]webauthnChallengeEntry // challenge -> pending registration/assertion, same shape as oauthState
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthStateEntry struct {
|
type oauthStateEntry struct {
|
||||||
@@ -51,11 +59,21 @@ type oauthStateEntry struct {
|
|||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
// webauthnChallengeEntry tracks one issued challenge. UserID is set for
|
||||||
|
// both registration (the already-authenticated user registering a new
|
||||||
|
// passkey) and login (the user identified by the mfa_pending token before
|
||||||
|
// the passkey assertion completes it).
|
||||||
|
type webauthnChallengeEntry struct {
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret, hostname string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
database: database, store: store, mk: mk, jwtSecret: jwtSecret,
|
database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname,
|
||||||
oauthConfigs: oauthConfigs,
|
oauthConfigs: oauthConfigs,
|
||||||
oauthState: make(map[string]oauthStateEntry),
|
oauthState: make(map[string]oauthStateEntry),
|
||||||
|
webauthnState: make(map[string]webauthnChallengeEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +86,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup))
|
mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup))
|
||||||
mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm))
|
mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm))
|
||||||
mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable))
|
mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable))
|
||||||
|
mux.HandleFunc("/api/me/passkeys/register/start", h.withAuth(h.passkeyRegisterStart))
|
||||||
|
mux.HandleFunc("/api/me/passkeys/register/finish", h.withAuth(h.passkeyRegisterFinish))
|
||||||
|
mux.HandleFunc("/api/me/passkeys", h.withAuth(h.passkeys))
|
||||||
|
mux.HandleFunc("/api/me/passkeys/", h.withAuth(h.passkeyByID))
|
||||||
|
mux.HandleFunc("/api/auth/passkey/start", h.passkeyLoginStart)
|
||||||
|
mux.HandleFunc("/api/auth/passkey/finish", h.passkeyLoginFinish)
|
||||||
mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail))
|
mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail))
|
||||||
mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords))
|
mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords))
|
||||||
mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID))
|
mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID))
|
||||||
@@ -75,11 +99,16 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages))
|
mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages))
|
||||||
mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages))
|
mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages))
|
||||||
mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID))
|
mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID))
|
||||||
|
mux.HandleFunc("/api/inbox/unified", h.withAuth(h.unifiedInbox))
|
||||||
|
mux.HandleFunc("/api/search", h.withAuth(h.search))
|
||||||
|
mux.HandleFunc("/api/calendar/events", h.withAuth(h.calendarEvents))
|
||||||
|
mux.HandleFunc("/api/contacts", h.withAuth(h.contacts))
|
||||||
mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine))
|
mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine))
|
||||||
mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine))
|
mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine))
|
||||||
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
|
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
|
||||||
mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts))
|
mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts))
|
||||||
mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect)
|
mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect)
|
||||||
|
mux.HandleFunc("/api/accounts/imap", h.withAuth(h.linkIMAPAccount))
|
||||||
mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount))
|
mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,19 +284,46 @@ func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.Use
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) {
|
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
// withAuth's own row fetch doesn't select mfa_enabled/recovery_email
|
||||||
|
// (most callers don't need them) — GetUser is the canonical full-row
|
||||||
|
// fetch that does.
|
||||||
|
fresh, err := h.database.GetUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"id": user.ID, "email": user.Email, "display_name": user.DisplayName, "role": user.Role,
|
"id": fresh.ID, "email": fresh.Email, "display_name": fresh.DisplayName, "role": fresh.Role,
|
||||||
|
"mfa_enabled": fresh.MFAEnabled, "recovery_email": fresh.RecoveryEmail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Folders & messages ──────────────────────────────────────────────────────────
|
// ── Folders & messages ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
|
// provider resolves which mailbox a request operates on. With no ?account=
|
||||||
return accounts.NewGoMailProvider(h.database, h.store, user)
|
// query param it's the user's own local mailbox (today's only behavior,
|
||||||
|
// unchanged). With ?account=<linked-account-id>, it's that account's
|
||||||
|
// provider — after confirming the account actually belongs to this user,
|
||||||
|
// since the ID otherwise comes straight from client input.
|
||||||
|
func (h *Handler) provider(r *http.Request, user *db.User) (accounts.MailProvider, error) {
|
||||||
|
id := r.URL.Query().Get("account")
|
||||||
|
if id == "" {
|
||||||
|
return accounts.NewGoMailProvider(h.database, h.store, user), nil
|
||||||
|
}
|
||||||
|
acct, err := h.database.GetLinkedAccount(id)
|
||||||
|
if err != nil || acct.UserID != user.ID {
|
||||||
|
return nil, fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
|
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
folders, err := h.provider(user).ListFolders(r.Context())
|
p, err := h.provider(r, user)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folders, err := p.ListFolders(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -293,7 +349,12 @@ func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.
|
|||||||
opts.Offset, _ = strconv.Atoi(o)
|
opts.Offset, _ = strconv.Atoi(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
headers, err := h.provider(user).ListMessages(r.Context(), folderID, opts)
|
p, err := h.provider(r, user)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headers, err := p.ListMessages(r.Context(), folderID, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -321,8 +382,13 @@ func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, use
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p, err := h.provider(r, user)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
|
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
|
||||||
if err := h.provider(user).SendMessage(r.Context(), msg); err != nil {
|
if err := p.SendMessage(r.Context(), msg); err != nil {
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
writeErr(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -342,7 +408,11 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
|||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
action = parts[2]
|
action = parts[2]
|
||||||
}
|
}
|
||||||
p := h.provider(user)
|
p, err := h.provider(r, user)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && action == "":
|
case r.Method == http.MethodGet && action == "":
|
||||||
@@ -389,6 +459,306 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type unifiedMessage struct {
|
||||||
|
accounts.MessageHeader
|
||||||
|
AccountID string `json:"account_id"` // "" means the local account — matches provider()'s ?account= convention
|
||||||
|
AccountLabel string `json:"account_label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unifiedInbox handles GET /api/inbox/unified — merges the Inbox folder
|
||||||
|
// across the user's local mailbox and every linked account into one
|
||||||
|
// newest-first list. A linked account that fails (unreachable server,
|
||||||
|
// expired token) is skipped and reported in "warnings", not allowed to
|
||||||
|
// fail the whole request — the same negative-path standard this project
|
||||||
|
// applies elsewhere (see GOMAIL_HANDOVER.md).
|
||||||
|
func (h *Handler) unifiedInbox(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
const perAccountLimit = 30
|
||||||
|
const overallLimit = 100
|
||||||
|
|
||||||
|
type source struct {
|
||||||
|
id, label string
|
||||||
|
p accounts.MailProvider
|
||||||
|
}
|
||||||
|
sources := []source{{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)}}
|
||||||
|
|
||||||
|
linked, err := h.database.ListLinkedAccounts(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range linked {
|
||||||
|
acct := &linked[i]
|
||||||
|
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||||
|
if perr != nil {
|
||||||
|
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
||||||
|
}
|
||||||
|
label := acct.DisplayName
|
||||||
|
if label == "" {
|
||||||
|
label = acct.EmailAddress
|
||||||
|
}
|
||||||
|
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []unifiedMessage
|
||||||
|
var warnings []string
|
||||||
|
for _, src := range sources {
|
||||||
|
folders, ferr := src.p.ListFolders(r.Context())
|
||||||
|
if ferr != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var inboxID string
|
||||||
|
for _, f := range folders {
|
||||||
|
if f.Type == "inbox" {
|
||||||
|
inboxID = f.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inboxID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headers, merr := src.p.ListMessages(r.Context(), inboxID, accounts.ListOpts{Limit: perAccountLimit})
|
||||||
|
if merr != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, merr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, hdr := range headers {
|
||||||
|
merged = append(merged, unifiedMessage{MessageHeader: hdr, AccountID: src.id, AccountLabel: src.label})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(merged, func(i, j int) bool {
|
||||||
|
ti, _ := mail.ParseDate(merged[i].Date)
|
||||||
|
tj, _ := mail.ParseDate(merged[j].Date)
|
||||||
|
return ti.After(tj)
|
||||||
|
})
|
||||||
|
if len(merged) > overallLimit {
|
||||||
|
merged = merged[:overallLimit]
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"messages": merged, "warnings": warnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchResult tags a match with the folder it lives in — a search spans
|
||||||
|
// every folder in the account, so (unlike a single-folder listing) the
|
||||||
|
// client needs to know which folder to open the message from. AccountID/
|
||||||
|
// AccountLabel follow unifiedMessage's convention ("" means the local
|
||||||
|
// account) — populated when a search fans out across every linked account
|
||||||
|
// (see search's doc comment), left blank for a single-account search.
|
||||||
|
type searchResult struct {
|
||||||
|
accounts.MessageHeader
|
||||||
|
FolderName string `json:"folder_name"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
AccountLabel string `json:"account_label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// search handles GET /api/search?q=...&body=1&folder=...&account=... —
|
||||||
|
// reuses ListFolders/ListMessages/GetMessage exactly like every other
|
||||||
|
// endpoint (same account-scoping via provider(), same header-cache-or-
|
||||||
|
// fallback decrypt ListMessages already does) rather than a separate
|
||||||
|
// index. Header matching (from/to/subject) is effectively free — it reuses
|
||||||
|
// the same decrypt ListMessages already pays for a folder view. Body
|
||||||
|
// matching is opt-in and live-decrypts on demand, capped at
|
||||||
|
// maxBodySearchScans messages total (shared across every account searched,
|
||||||
|
// not per-account) so one search can't force-decrypt an entire large
|
||||||
|
// mailbox — see this project's "everything encrypted at rest" guarantee,
|
||||||
|
// which an index over message content would weaken.
|
||||||
|
//
|
||||||
|
// With no ?account= and no ?folder=, the search fans out across the local
|
||||||
|
// mailbox and every linked account — same source list, same "skip and warn
|
||||||
|
// on a broken account" behavior as unifiedInbox — since a specific account
|
||||||
|
// or folder ID otherwise pins the search to one provider's namespace.
|
||||||
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxResults = 100
|
||||||
|
const maxBodySearchScans = 500
|
||||||
|
|
||||||
|
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||||
|
if query == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "q is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchBody := r.URL.Query().Get("body") == "1" || r.URL.Query().Get("body") == "true"
|
||||||
|
onlyFolder := r.URL.Query().Get("folder")
|
||||||
|
accountID := r.URL.Query().Get("account")
|
||||||
|
|
||||||
|
type source struct {
|
||||||
|
id, label string
|
||||||
|
p accounts.MailProvider
|
||||||
|
}
|
||||||
|
var sources []source
|
||||||
|
singleAccount := accountID != "" || onlyFolder != ""
|
||||||
|
if singleAccount {
|
||||||
|
p, err := h.provider(r, user)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sources = []source{{id: accountID, p: p}}
|
||||||
|
} else {
|
||||||
|
sources = append(sources, source{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)})
|
||||||
|
linked, err := h.database.ListLinkedAccounts(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range linked {
|
||||||
|
acct := &linked[i]
|
||||||
|
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||||
|
if perr != nil {
|
||||||
|
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
||||||
|
}
|
||||||
|
label := acct.DisplayName
|
||||||
|
if label == "" {
|
||||||
|
label = acct.EmailAddress
|
||||||
|
}
|
||||||
|
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []searchResult
|
||||||
|
var warnings []string
|
||||||
|
bodyScans := 0
|
||||||
|
truncated := false
|
||||||
|
for _, src := range sources {
|
||||||
|
var folders []accounts.Folder
|
||||||
|
if onlyFolder != "" {
|
||||||
|
folders = []accounts.Folder{{ID: onlyFolder}}
|
||||||
|
} else {
|
||||||
|
var ferr error
|
||||||
|
folders, ferr = src.p.ListFolders(r.Context())
|
||||||
|
if ferr != nil {
|
||||||
|
if singleAccount {
|
||||||
|
writeErr(w, http.StatusInternalServerError, ferr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.label != "" {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range folders {
|
||||||
|
headers, err := src.p.ListMessages(r.Context(), f.ID, accounts.ListOpts{})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, hdr := range headers {
|
||||||
|
matched := strings.Contains(strings.ToLower(hdr.From), query) ||
|
||||||
|
strings.Contains(strings.ToLower(hdr.To), query) ||
|
||||||
|
strings.Contains(strings.ToLower(hdr.Subject), query)
|
||||||
|
|
||||||
|
if !matched && searchBody {
|
||||||
|
if bodyScans >= maxBodySearchScans {
|
||||||
|
truncated = true
|
||||||
|
} else {
|
||||||
|
bodyScans++
|
||||||
|
if full, ferr := src.p.GetMessage(r.Context(), f.ID, hdr.ID); ferr == nil {
|
||||||
|
matched = strings.Contains(strings.ToLower(string(full.Raw)), query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
results = append(results, searchResult{MessageHeader: hdr, FolderName: f.DisplayName, AccountID: src.id, AccountLabel: src.label})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
ti, _ := mail.ParseDate(results[i].Date)
|
||||||
|
tj, _ := mail.ParseDate(results[j].Date)
|
||||||
|
return ti.After(tj)
|
||||||
|
})
|
||||||
|
if len(results) > maxResults {
|
||||||
|
results = results[:maxResults]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"messages": results, "truncated": truncated, "warnings": warnings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calendar/contacts (linked accounts only — local calendar/contacts are
|
||||||
|
// served by internal/dav's CalDAV/CardDAV server, a separate protocol) ──────
|
||||||
|
|
||||||
|
// linkedProviderFor looks up id, checks it belongs to user (same ownership
|
||||||
|
// check provider()/unifiedInbox already use), and builds its MailProvider.
|
||||||
|
// Unlike provider(), there is no "local account" fallback here — local
|
||||||
|
// calendar/contacts have no REST path, only CalDAV/CardDAV.
|
||||||
|
func (h *Handler) linkedProviderFor(user *db.User, id string) (accounts.MailProvider, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("account is required")
|
||||||
|
}
|
||||||
|
acct, err := h.database.GetLinkedAccount(id)
|
||||||
|
if err != nil || acct.UserID != user.ID {
|
||||||
|
return nil, fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) calendarEvents(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp, ok := p.(accounts.CalendarProvider)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "this account type does not support calendar access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
from := time.Now().UTC().AddDate(0, 0, -30)
|
||||||
|
to := time.Now().UTC().AddDate(0, 0, 30)
|
||||||
|
if v := r.URL.Query().Get("from"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
from = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("to"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
to = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := cp.ListEvents(r.Context(), from, to)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) contacts(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp, ok := p.(accounts.ContactProvider)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "this account type does not support contacts access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := cp.ListContacts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Quarantine ────────────────────────────────────────────────────────────────
|
// ── Quarantine ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
@@ -530,6 +900,44 @@ func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkIMAPAccount handles POST /api/accounts/imap — the password-based
|
||||||
|
// counterpart to the OAuth linking flow, for a generic IMAP/SMTP provider.
|
||||||
|
// Wraps accounts.LinkIMAPAccount, which already existed and was already
|
||||||
|
// fully wired for encrypted credential storage; this was the only missing
|
||||||
|
// piece, an HTTP entry point for it.
|
||||||
|
func (h *Handler) linkIMAPAccount(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
IMAPHost string
|
||||||
|
IMAPPort int
|
||||||
|
IMAPTLS string
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort int
|
||||||
|
SMTPTLS string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
|
||||||
|
req.Email == "" || req.Password == "" || req.IMAPHost == "" || req.SMTPHost == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "email, password, imap_host, and smtp_host are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DisplayName == "" {
|
||||||
|
req.DisplayName = req.Email
|
||||||
|
}
|
||||||
|
account, err := accounts.LinkIMAPAccount(h.database, h.mk, user.ID, req.DisplayName, req.Email, req.Password,
|
||||||
|
req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{"id": account.ID})
|
||||||
|
}
|
||||||
|
|
||||||
// oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback.
|
// oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback.
|
||||||
// start requires an authenticated session (checked inline, not via withAuth,
|
// start requires an authenticated session (checked inline, not via withAuth,
|
||||||
// since callback intentionally does NOT require one — it's a plain browser
|
// since callback intentionally does NOT require one — it's a plain browser
|
||||||
@@ -622,14 +1030,11 @@ func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider
|
|||||||
dbProvider = db.ProviderM365
|
dbProvider = db.ProviderM365
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: a real implementation would call the provider's userinfo/profile
|
email, err := accounts.FetchOAuth2Email(r.Context(), provider, token.AccessToken)
|
||||||
// endpoint here to learn the account's actual email address rather than
|
if err != nil {
|
||||||
// require it as a query param — deferred; for now the display name is
|
slog.Error("failed to look up account email from provider", "provider", provider, "err", err)
|
||||||
// generic and the operator/user can rename it, matching the minimum
|
writeErr(w, http.StatusBadGateway, "failed to look up account email")
|
||||||
// needed to prove the OAuth2 flow itself is correct end-to-end.
|
return
|
||||||
email := r.URL.Query().Get("email")
|
|
||||||
if email == "" {
|
|
||||||
email = provider + "-account"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token)
|
account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token)
|
||||||
@@ -764,9 +1169,340 @@ func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.Us
|
|||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.database.RecomputeMFAEnabled(user.ID); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Passkeys (WebAuthn) ──────────────────────────────────────────────────────
|
||||||
|
// See internal/webauthn's package doc comment for the two deliberate scope
|
||||||
|
// decisions (no attestation verification, ES256/P-256 only). Passkeys are
|
||||||
|
// an alternative second factor alongside TOTP/backup codes — they redeem
|
||||||
|
// the same mfa_pending token mfaVerify does, not a separate login flow.
|
||||||
|
|
||||||
|
func (h *Handler) origin() string { return "https://" + h.hostname }
|
||||||
|
|
||||||
|
func loadPasskeys(user *db.User) []webauthn.StoredCredential {
|
||||||
|
var creds []webauthn.StoredCredential
|
||||||
|
if user.PasskeyCredentialsJSON != "" {
|
||||||
|
json.Unmarshal([]byte(user.PasskeyCredentialsJSON), &creds)
|
||||||
|
}
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePasskeys(database *db.DB, userID string, creds []webauthn.StoredCredential) error {
|
||||||
|
if creds == nil {
|
||||||
|
creds = []webauthn.StoredCredential{}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := database.SetPasskeyCredentials(userID, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return database.RecomputeMFAEnabled(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) passkeyRegisterStart(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challenge, err := webauthn.NewChallenge()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.webauthnMu.Lock()
|
||||||
|
h.pruneExpiredWebauthnState()
|
||||||
|
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
||||||
|
h.webauthnMu.Unlock()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"rp": map[string]string{"id": h.hostname, "name": "GoMail"},
|
||||||
|
"user": map[string]string{"id": base64.RawURLEncoding.EncodeToString([]byte(user.ID)), "name": user.Email, "displayName": user.DisplayName},
|
||||||
|
"challenge": challenge,
|
||||||
|
"pubKeyCredParams": []map[string]any{
|
||||||
|
{"alg": -7, "type": "public-key"}, // ES256 — see internal/webauthn's scope doc comment
|
||||||
|
},
|
||||||
|
"timeout": 60000,
|
||||||
|
"attestation": "none",
|
||||||
|
"authenticatorSelection": map[string]string{"userVerification": "preferred"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Challenge string
|
||||||
|
Name string
|
||||||
|
ClientDataJSON string
|
||||||
|
AttestationObject string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.webauthnMu.Lock()
|
||||||
|
entry, ok := h.webauthnState[req.Challenge]
|
||||||
|
if ok {
|
||||||
|
delete(h.webauthnState, req.Challenge) // one-time use
|
||||||
|
}
|
||||||
|
h.webauthnMu.Unlock()
|
||||||
|
if !ok || entry.UserID != user.ID || time.Now().UTC().After(entry.ExpiresAt) {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid or expired registration challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
||||||
|
attestationObject, err2 := base64.RawURLEncoding.DecodeString(req.AttestationObject)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authData, err := webauthn.VerifyRegistration(clientDataJSON, attestationObject, req.Challenge, h.hostname, h.origin())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "passkey registration failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := req.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Passkey"
|
||||||
|
}
|
||||||
|
cred := webauthn.StoredCredential{
|
||||||
|
ID: base64.RawURLEncoding.EncodeToString(authData.CredentialID),
|
||||||
|
PublicKey: webauthn.EncodePublicKey(authData.PublicKey),
|
||||||
|
SignCount: authData.SignCount,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// withAuth's own row fetch doesn't select passkey_credentials_json
|
||||||
|
// (most callers don't need it) — GetUser is the canonical full-row
|
||||||
|
// fetch that does; using the withAuth-provided user here would silently
|
||||||
|
// discard every previously registered passkey on each new one.
|
||||||
|
fresh, err := h.database.GetUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds := append(loadPasskeys(fresh), cred)
|
||||||
|
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey added"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) passkeys(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type safeCred struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
fresh, err := h.database.GetUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds := loadPasskeys(fresh)
|
||||||
|
out := make([]safeCred, 0, len(creds))
|
||||||
|
for _, c := range creds {
|
||||||
|
out = append(out, safeCred{ID: c.ID, Name: c.Name, CreatedAt: c.CreatedAt})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) passkeyByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/me/passkeys/")
|
||||||
|
fresh, err := h.database.GetUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds := loadPasskeys(fresh)
|
||||||
|
kept := make([]webauthn.StoredCredential, 0, len(creds))
|
||||||
|
found := false
|
||||||
|
for _, c := range creds {
|
||||||
|
if c.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, c)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeErr(w, http.StatusNotFound, "passkey not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := savePasskeys(h.database, user.ID, kept); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) pruneExpiredWebauthnState() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for k, v := range h.webauthnState {
|
||||||
|
if now.After(v.ExpiresAt) {
|
||||||
|
delete(h.webauthnState, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyLoginStart handles POST /api/auth/passkey/start — takes the same
|
||||||
|
// mfa_pending token login() issues, returns a WebAuthn assertion challenge
|
||||||
|
// listing the user's registered credentials.
|
||||||
|
func (h *Handler) passkeyLoginStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct{ MFAToken string }
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
||||||
|
if err != nil || claims.Purpose != "mfa_pending" {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.database.GetUser(claims.Subject)
|
||||||
|
if err != nil || !user.Active {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds := loadPasskeys(user)
|
||||||
|
if len(creds) == 0 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "no passkeys registered for this account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, err := webauthn.NewChallenge()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.webauthnMu.Lock()
|
||||||
|
h.pruneExpiredWebauthnState()
|
||||||
|
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
||||||
|
h.webauthnMu.Unlock()
|
||||||
|
|
||||||
|
allow := make([]map[string]string, 0, len(creds))
|
||||||
|
for _, c := range creds {
|
||||||
|
allow = append(allow, map[string]string{"id": c.ID, "type": "public-key"})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"rpId": h.hostname, "challenge": challenge, "timeout": 60000,
|
||||||
|
"userVerification": "preferred", "allowCredentials": allow,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyLoginFinish handles POST /api/auth/passkey/finish — verifies the
|
||||||
|
// assertion and, on success, redeems the mfa_pending token for a real
|
||||||
|
// session exactly like mfaVerify does.
|
||||||
|
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
MFAToken string
|
||||||
|
Challenge string
|
||||||
|
CredentialID string
|
||||||
|
ClientDataJSON string
|
||||||
|
AuthenticatorData string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
||||||
|
if err != nil || claims.Purpose != "mfa_pending" {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.webauthnMu.Lock()
|
||||||
|
entry, ok := h.webauthnState[req.Challenge]
|
||||||
|
if ok {
|
||||||
|
delete(h.webauthnState, req.Challenge) // one-time use
|
||||||
|
}
|
||||||
|
h.webauthnMu.Unlock()
|
||||||
|
if !ok || entry.UserID != claims.Subject || time.Now().UTC().After(entry.ExpiresAt) {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid or expired passkey challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.database.GetUser(claims.Subject)
|
||||||
|
if err != nil || !user.Active {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds := loadPasskeys(user)
|
||||||
|
idx := -1
|
||||||
|
for i, c := range creds {
|
||||||
|
if c.ID == req.CredentialID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unknown credential")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
||||||
|
authenticatorData, err2 := base64.RawURLEncoding.DecodeString(req.AuthenticatorData)
|
||||||
|
signature, err3 := base64.RawURLEncoding.DecodeString(req.Signature)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newSignCount, err := webauthn.VerifyAssertion(creds[idx], clientDataJSON, authenticatorData, signature, req.Challenge, h.hostname, h.origin())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "passkey verification failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds[idx].SignCount = newSignCount
|
||||||
|
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── App passwords ─────────────────────────────────────────────────────────────
|
// ── App passwords ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
|
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package webmail
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed static/index.html
|
//go:embed static
|
||||||
var StaticFS embed.FS
|
var StaticFS embed.FS
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
const API = '/api';
|
||||||
|
let token = localStorage.getItem('gomail_token') || '';
|
||||||
|
let me = null;
|
||||||
|
let accounts = []; // [{id:'', label, provider:'local'}, ...linked]
|
||||||
|
let currentAccountId = 'UNIFIED';
|
||||||
|
let currentFolder = 'INBOX';
|
||||||
|
let folders = [];
|
||||||
|
let loadedMessages = []; // last-fetched folder/unified-inbox contents
|
||||||
|
let searchQuery = '';
|
||||||
|
let searchResults = null; // null = not searching; array = server search results
|
||||||
|
let searchTruncated = false;
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
let selectedKey = '';
|
||||||
|
let pendingMFAToken = '';
|
||||||
|
|
||||||
|
// ── fetch helper ──────────────────────────────────────────────────────────
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const r = await fetch(API + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, ...(opts.headers || {}) } });
|
||||||
|
if (r.status === 401) { showLogin(); return null; }
|
||||||
|
return r.ok ? r.json() : Promise.reject(await r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
function bodyOf(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
const decoded = atob(raw);
|
||||||
|
const idx = decoded.indexOf('\r\n\r\n');
|
||||||
|
return idx >= 0 ? decoded.slice(idx + 4) : decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
return isNaN(d) ? s : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initial(label) { return (label || '?').trim().charAt(0).toUpperCase() || '?'; }
|
||||||
|
|
||||||
|
// account-scoping: '' (local) omits the query param, matching provider()'s
|
||||||
|
// own default-to-local convention server-side.
|
||||||
|
function acctQuery(id) { return id ? '?account=' + encodeURIComponent(id) : ''; }
|
||||||
|
|
||||||
|
// ── auth ──────────────────────────────────────────────────────────────────
|
||||||
|
async function login() {
|
||||||
|
const email = document.getElementById('le').value, pwd = document.getElementById('lp').value;
|
||||||
|
try {
|
||||||
|
const d = await fetch(API + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Email: email, Password: pwd }) }).then(r => r.json());
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
if (d.mfa_required) { pendingMFAToken = d.mfa_token; showMFALogin(); return; }
|
||||||
|
token = d.token; localStorage.setItem('gomail_token', token);
|
||||||
|
showApp();
|
||||||
|
} catch (e) { const el = document.getElementById('lerr'); el.textContent = e.message || 'Login failed'; el.style.display = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mfaVerifyLogin() {
|
||||||
|
const code = document.getElementById('mfa-code').value;
|
||||||
|
try {
|
||||||
|
const d = await fetch(API + '/auth/mfa-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ MFAToken: pendingMFAToken, Code: code }) }).then(r => r.json());
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
token = d.token; localStorage.setItem('gomail_token', token);
|
||||||
|
showApp();
|
||||||
|
} catch (e) { const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Invalid code'; el.style.display = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() { localStorage.removeItem('gomail_token'); token = ''; showLogin(); }
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('login').style.display = 'flex';
|
||||||
|
document.getElementById('mfa-login').style.display = 'none';
|
||||||
|
document.getElementById('app').style.display = 'none';
|
||||||
|
}
|
||||||
|
function showMFALogin() {
|
||||||
|
document.getElementById('login').style.display = 'none';
|
||||||
|
document.getElementById('mfa-login').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showApp() {
|
||||||
|
document.getElementById('login').style.display = 'none';
|
||||||
|
document.getElementById('mfa-login').style.display = 'none';
|
||||||
|
document.getElementById('app').style.display = 'flex';
|
||||||
|
me = await api('/me'); if (!me) return;
|
||||||
|
document.getElementById('me-email').textContent = me.email;
|
||||||
|
await loadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
if (!token) { showLogin(); return; }
|
||||||
|
try { const m = await api('/me'); if (m) { me = m; document.getElementById('app').style.display = 'flex'; document.getElementById('me-email').textContent = me.email; await loadAccounts(); } else showLogin(); }
|
||||||
|
catch { showLogin(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── accounts ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadAccounts() {
|
||||||
|
const linked = await api('/accounts') || [];
|
||||||
|
accounts = [{ id: '', label: me.email, provider: 'local' }, ...linked.map(a => ({ id: a.id, label: a.display_name || a.email_address, provider: a.provider }))];
|
||||||
|
renderAccountSwitcher();
|
||||||
|
await selectAccount('UNIFIED');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountSwitcher() {
|
||||||
|
const rows = [{ id: 'UNIFIED', label: 'Unified Inbox', icon: '✦' }, ...accounts];
|
||||||
|
document.getElementById('account-switcher').innerHTML = rows.map(a => `
|
||||||
|
<div class="nav-row ${a.id === currentAccountId ? 'active' : ''}" onclick="selectAccount('${esc(a.id)}')">
|
||||||
|
<div class="seal">${a.icon || esc(initial(a.label))}</div>
|
||||||
|
<div class="nav-row-label">${esc(a.label)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery = ''; searchResults = null; searchTruncated = false;
|
||||||
|
const box = document.getElementById('search-box');
|
||||||
|
if (box) box.value = '';
|
||||||
|
const toggle = document.getElementById('search-body-toggle');
|
||||||
|
if (toggle) toggle.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAccount(id) {
|
||||||
|
clearSearch();
|
||||||
|
currentAccountId = id;
|
||||||
|
renderAccountSwitcher();
|
||||||
|
document.getElementById('view-mail').style.display = 'flex';
|
||||||
|
document.getElementById('view-quarantine').style.display = 'none';
|
||||||
|
document.getElementById('view-settings').style.display = 'none';
|
||||||
|
const existingWarning = document.getElementById('unified-warning');
|
||||||
|
if (existingWarning) existingWarning.remove();
|
||||||
|
if (id === 'UNIFIED') {
|
||||||
|
document.getElementById('folder-section').style.display = 'none';
|
||||||
|
document.getElementById('list-title').textContent = 'Unified Inbox';
|
||||||
|
await loadUnifiedInbox();
|
||||||
|
} else {
|
||||||
|
document.getElementById('folder-section').style.display = '';
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── folders (per-account view) ───────────────────────────────────────────
|
||||||
|
async function loadFolders() {
|
||||||
|
folders = await api('/folders' + acctQuery(currentAccountId)) || [];
|
||||||
|
if (!folders.find(f => f.id === currentFolder)) {
|
||||||
|
const inbox = folders.find(f => f.type === 'inbox');
|
||||||
|
currentFolder = inbox ? inbox.id : (folders[0] ? folders[0].id : 'INBOX');
|
||||||
|
}
|
||||||
|
renderFolderList();
|
||||||
|
await loadMessages(currentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolderList() {
|
||||||
|
document.getElementById('folder-list').innerHTML = folders.map(f => `
|
||||||
|
<div class="nav-row ${f.id === currentFolder ? 'active' : ''}" onclick="selectFolder('${esc(f.id)}')">
|
||||||
|
<div class="seal">${f.unread_count > 0 ? '<span class="dot"></span>' : ''}</div>
|
||||||
|
<div class="nav-row-label">${esc(f.display_name)}</div>
|
||||||
|
<div class="count-badge">${f.unread_count > 0 ? f.unread_count : ''}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFolder(id) {
|
||||||
|
clearSearch();
|
||||||
|
currentFolder = id;
|
||||||
|
renderFolderList();
|
||||||
|
const f = folders.find(x => x.id === id);
|
||||||
|
document.getElementById('list-title').textContent = f ? f.display_name : id;
|
||||||
|
await loadMessages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── messages ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadMessages(folderID) {
|
||||||
|
loadedMessages = await api('/folders/' + folderID + '/messages' + acctQuery(currentAccountId)) || [];
|
||||||
|
renderMessageList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnifiedInbox() {
|
||||||
|
const res = await api('/inbox/unified'); if (!res) return;
|
||||||
|
loadedMessages = res.messages || [];
|
||||||
|
renderMessageList();
|
||||||
|
const existing = document.getElementById('unified-warning');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
if (res.warnings && res.warnings.length) {
|
||||||
|
const notice = document.createElement('div');
|
||||||
|
notice.id = 'unified-warning';
|
||||||
|
notice.className = 'notice';
|
||||||
|
notice.style.margin = '0 12px 8px';
|
||||||
|
notice.textContent = 'Some accounts could not be reached: ' + res.warnings.join('; ');
|
||||||
|
document.getElementById('list-title').insertAdjacentElement('afterend', notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput(v) {
|
||||||
|
searchQuery = v;
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
document.getElementById('search-body-toggle').style.display = v ? '' : 'none';
|
||||||
|
if (!v) { searchResults = null; renderMessageList(); return; }
|
||||||
|
searchDebounceTimer = setTimeout(() => runSearch(false), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSearch calls the real server-side search (internal/webmail/api.go's
|
||||||
|
// search handler). Unified view sends no ?account=, so the server fans the
|
||||||
|
// search out across the local mailbox and every linked account (each
|
||||||
|
// result tagged with account_id/account_label, like the unified inbox);
|
||||||
|
// otherwise it's scoped to the one selected account.
|
||||||
|
async function runSearch(withBody) {
|
||||||
|
const q = searchQuery;
|
||||||
|
if (!q) return;
|
||||||
|
const realAccountId = currentAccountId === 'UNIFIED' ? '' : currentAccountId;
|
||||||
|
let url = '/search?q=' + encodeURIComponent(q);
|
||||||
|
if (withBody) url += '&body=1';
|
||||||
|
if (realAccountId) url += '&account=' + encodeURIComponent(realAccountId);
|
||||||
|
try {
|
||||||
|
const res = await api(url);
|
||||||
|
if (!res) return;
|
||||||
|
searchResults = res.messages || [];
|
||||||
|
searchTruncated = !!res.truncated;
|
||||||
|
renderMessageList();
|
||||||
|
} catch (e) { /* transient — leave prior results/state as-is */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageList() {
|
||||||
|
const list = document.getElementById('msg-list');
|
||||||
|
|
||||||
|
if (searchResults !== null) {
|
||||||
|
const notice = searchTruncated ? '<div class="notice" style="margin:0 12px 8px">Showing partial results — narrow your search for a complete list.</div>' : '';
|
||||||
|
if (!searchResults.length) { list.innerHTML = notice + '<div style="padding:20px;color:var(--text-faint);text-align:center">No matches</div>'; return; }
|
||||||
|
list.innerHTML = notice + searchResults.map(m => {
|
||||||
|
const unread = !(m.flags || []).includes('\\Seen');
|
||||||
|
const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId;
|
||||||
|
const key = acct + '|' + m.folder_id + '|' + m.id;
|
||||||
|
return `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(m.folder_id)}','${esc(m.id)}')">
|
||||||
|
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||||||
|
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||||||
|
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span class="chip">${esc(m.folder_name || '')}</span><span>${esc(formatDate(m.date))}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadedMessages.length) { list.innerHTML = '<div style="padding:20px;color:var(--text-faint);text-align:center">No messages</div>'; return; }
|
||||||
|
list.innerHTML = loadedMessages.map(m => {
|
||||||
|
const unread = !(m.flags || []).includes('\\Seen');
|
||||||
|
const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId;
|
||||||
|
const folderId = currentAccountId === 'UNIFIED' ? m.folder_id : currentFolder;
|
||||||
|
const key = acct + '|' + folderId + '|' + m.id;
|
||||||
|
return `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(folderId)}','${esc(m.id)}')">
|
||||||
|
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||||||
|
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||||||
|
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span>${esc(formatDate(m.date))}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewMessage(acct, folderId, id) {
|
||||||
|
selectedKey = acct + '|' + folderId + '|' + id;
|
||||||
|
renderMessageList();
|
||||||
|
const msg = await api('/messages/' + folderId + '/' + id + acctQuery(acct)); if (!msg) return;
|
||||||
|
document.getElementById('msg-view').innerHTML = `
|
||||||
|
<div class="reading-subject">${esc(msg.subject || '(no subject)')}</div>
|
||||||
|
<div class="reading-meta">
|
||||||
|
<div>From: ${esc(msg.from)}</div>
|
||||||
|
<div>To: ${esc(msg.to)}</div>
|
||||||
|
<div>${esc(formatDate(msg.date))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="reading-body">${esc(bodyOf(msg.raw))}</div>
|
||||||
|
<div style="margin-top:20px;display:flex;gap:8px">
|
||||||
|
<button onclick="deleteMessage('${esc(acct)}','${esc(folderId)}','${esc(id)}')" class="btn btn-ghost">Delete</button>
|
||||||
|
</div>`;
|
||||||
|
api('/messages/' + folderId + '/' + id + '/flags' + acctQuery(acct), { method: 'PUT', body: JSON.stringify({ Flags: ['\\Seen'] }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(acct, folderId, id) {
|
||||||
|
await api('/messages/' + folderId + '/' + id + acctQuery(acct), { method: 'DELETE' });
|
||||||
|
document.getElementById('msg-view').innerHTML = '<div class="reading-empty">Select a message</div>';
|
||||||
|
if (currentAccountId === 'UNIFIED') await loadUnifiedInbox(); else await loadMessages(currentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── compose ───────────────────────────────────────────────────────────────
|
||||||
|
function openCompose() {
|
||||||
|
const sel = document.getElementById('c-from');
|
||||||
|
sel.innerHTML = accounts.map(a => `<option value="${esc(a.id)}">${esc(a.label)}</option>`).join('');
|
||||||
|
sel.value = currentAccountId === 'UNIFIED' ? '' : currentAccountId;
|
||||||
|
document.getElementById('compose-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
function closeCompose() { document.getElementById('compose-modal').style.display = 'none'; }
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const from = document.getElementById('c-from').value;
|
||||||
|
const to = document.getElementById('c-to').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const subject = document.getElementById('c-subject').value;
|
||||||
|
const body = document.getElementById('c-body').value;
|
||||||
|
try {
|
||||||
|
await api('/messages' + acctQuery(from), { method: 'POST', body: JSON.stringify({ to, subject, body }) });
|
||||||
|
closeCompose();
|
||||||
|
document.getElementById('c-to').value = ''; document.getElementById('c-subject').value = ''; document.getElementById('c-body').value = '';
|
||||||
|
} catch (e) { alert('Send failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── quarantine ────────────────────────────────────────────────────────────
|
||||||
|
async function showQuarantine() {
|
||||||
|
document.getElementById('view-mail').style.display = 'none';
|
||||||
|
document.getElementById('view-settings').style.display = 'none';
|
||||||
|
document.getElementById('view-quarantine').style.display = 'block';
|
||||||
|
const entries = await api('/quarantine'); if (!entries) return;
|
||||||
|
document.getElementById('quarantine-list').innerHTML = entries.length ? entries.map(e => `
|
||||||
|
<div class="card" style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<div><div style="font-size:13px">Reason: ${esc(e.Reason || '—')}</div>
|
||||||
|
<div style="color:var(--text-faint);font-size:12px">Held: ${esc(e.CreatedAt)}</div></div>
|
||||||
|
<button onclick="releaseQ('${esc(e.ID)}')" class="btn btn-primary">Release</button>
|
||||||
|
</div>`).join('') : '<div style="color:var(--text-faint);text-align:center;padding:40px">Nothing held</div>';
|
||||||
|
}
|
||||||
|
async function releaseQ(id) {
|
||||||
|
try { await api('/quarantine/' + id + '/release', { method: 'POST' }); showQuarantine(); }
|
||||||
|
catch (e) { alert('Release failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── settings ──────────────────────────────────────────────────────────────
|
||||||
|
async function showSettings() {
|
||||||
|
document.getElementById('view-mail').style.display = 'none';
|
||||||
|
document.getElementById('view-quarantine').style.display = 'none';
|
||||||
|
document.getElementById('view-settings').style.display = 'block';
|
||||||
|
await renderSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSettings() {
|
||||||
|
me = await api('/me') || me;
|
||||||
|
const body = document.getElementById('settings-body');
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3>Two-factor authentication</h3>
|
||||||
|
<p class="hint">${me.mfa_enabled ? 'Enabled — a code or passkey is required at every sign-in.' : 'Not enabled. Add a code from an authenticator app or a passkey for a second sign-in step.'}</p>
|
||||||
|
<div id="mfa-area"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3>Passkeys</h3>
|
||||||
|
<p class="hint">A device, security key, or platform authenticator (Touch ID, Windows Hello) you can sign in with instead of typing a code.</p>
|
||||||
|
<div id="passkeys-area"></div>
|
||||||
|
<button onclick="addPasskey()" class="btn btn-primary" style="margin-top:10px">Add a passkey</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3>Recovery email</h3>
|
||||||
|
<p class="hint">Used for password reset — not your own mailbox, so you can't get locked out of it.</p>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input id="recovery-email-input" class="inp" placeholder="you@elsewhere.example" value="${esc(me.recovery_email || '')}">
|
||||||
|
<button onclick="saveRecoveryEmail()" class="btn btn-primary" style="flex:none">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3>App passwords</h3>
|
||||||
|
<p class="hint">For mail clients that need a password instead of your real one — IMAP/SMTP/POP3 login.</p>
|
||||||
|
<div id="app-passwords-area"></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:10px">
|
||||||
|
<input id="app-pw-label" class="inp" placeholder="Label, e.g. \"Phone Mail app\"">
|
||||||
|
<button onclick="createAppPassword()" class="btn btn-primary" style="flex:none">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3>Linked accounts</h3>
|
||||||
|
<p class="hint">Other mailboxes shown in Unified Inbox and the account switcher.</p>
|
||||||
|
<div id="linked-accounts-area"></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:14px">
|
||||||
|
<button onclick="startOAuth('google')" class="btn btn-ghost">Link Google account</button>
|
||||||
|
<button onclick="startOAuth('microsoft')" class="btn btn-ghost">Link Microsoft account</button>
|
||||||
|
<button onclick="toggleImapForm()" class="btn btn-ghost">Add IMAP account</button>
|
||||||
|
</div>
|
||||||
|
<div id="imap-form" style="display:none;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">
|
||||||
|
<div class="field"><input id="imap-email" class="inp" placeholder="Email address"></div>
|
||||||
|
<div class="field"><input id="imap-password" type="password" class="inp" placeholder="Password"></div>
|
||||||
|
<div class="field" style="display:flex;gap:8px">
|
||||||
|
<input id="imap-host" class="inp" placeholder="IMAP host">
|
||||||
|
<input id="imap-port" class="inp" placeholder="993" style="width:90px">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="display:flex;gap:8px">
|
||||||
|
<input id="smtp-host" class="inp" placeholder="SMTP host">
|
||||||
|
<input id="smtp-port" class="inp" placeholder="465" style="width:90px">
|
||||||
|
</div>
|
||||||
|
<button onclick="submitImapAccount()" class="btn btn-primary">Add account</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
renderMFAArea();
|
||||||
|
renderPasskeys();
|
||||||
|
renderAppPasswords();
|
||||||
|
renderLinkedAccountsSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMFAArea() {
|
||||||
|
const area = document.getElementById('mfa-area');
|
||||||
|
if (me.mfa_enabled) {
|
||||||
|
area.innerHTML = `
|
||||||
|
<div class="field"><input id="mfa-disable-pw" type="password" class="inp" placeholder="Current password" style="max-width:260px"></div>
|
||||||
|
<button onclick="mfaDisableSubmit()" class="btn btn-danger">Disable two-factor</button>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
area.innerHTML = `<button onclick="mfaSetupStart()" class="btn btn-primary">Set up two-factor</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mfaSetupStart() {
|
||||||
|
try {
|
||||||
|
const d = await api('/me/mfa/setup', { method: 'POST' });
|
||||||
|
document.getElementById('mfa-area').innerHTML = `
|
||||||
|
<p class="hint">Scan isn't available here — enter this manually in your authenticator app (Google Authenticator, 1Password, etc.):</p>
|
||||||
|
<div class="card" style="background:var(--bg);word-break:break-all;font-size:12px;margin-bottom:10px">${esc(d.provisioning_uri)}</div>
|
||||||
|
<div class="field"><input id="mfa-confirm-code" class="inp" placeholder="Enter the 6-digit code" style="max-width:200px"></div>
|
||||||
|
<button onclick="mfaConfirmSubmit()" class="btn btn-primary">Confirm</button>`;
|
||||||
|
} catch (e) { alert('Setup failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mfaConfirmSubmit() {
|
||||||
|
const code = document.getElementById('mfa-confirm-code').value;
|
||||||
|
try {
|
||||||
|
const d = await api('/me/mfa/confirm', { method: 'POST', body: JSON.stringify({ Code: code }) });
|
||||||
|
document.getElementById('mfa-area').innerHTML = `
|
||||||
|
<div class="notice">Two-factor enabled. Save these backup codes somewhere safe — each works once if you lose access to your authenticator app.</div>
|
||||||
|
<div class="card" style="background:var(--bg);font-family:monospace;font-size:13px;line-height:1.8">${d.backup_codes.map(esc).join('<br>')}</div>`;
|
||||||
|
me.mfa_enabled = true;
|
||||||
|
} catch (e) { alert('Invalid code: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mfaDisableSubmit() {
|
||||||
|
const pw = document.getElementById('mfa-disable-pw').value;
|
||||||
|
try {
|
||||||
|
await api('/me/mfa/disable', { method: 'POST', body: JSON.stringify({ Password: pw }) });
|
||||||
|
me.mfa_enabled = false;
|
||||||
|
renderMFAArea();
|
||||||
|
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── passkeys (WebAuthn) ─────────────────────────────────────────────────
|
||||||
|
function b64urlToBuf(b64url) {
|
||||||
|
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
|
||||||
|
const raw = atob(b64 + pad);
|
||||||
|
const buf = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
|
||||||
|
return buf.buffer;
|
||||||
|
}
|
||||||
|
function bufToB64url(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let str = '';
|
||||||
|
for (const b of bytes) str += String.fromCharCode(b);
|
||||||
|
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPasskeys() {
|
||||||
|
const area = document.getElementById('passkeys-area');
|
||||||
|
const list = await api('/me/passkeys') || [];
|
||||||
|
area.innerHTML = list.length ? list.map(p => `
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div><div style="font-size:13px">${esc(p.name)}</div><div style="color:var(--text-faint);font-size:11px">Added ${esc(formatDate(p.created_at))}</div></div>
|
||||||
|
<button onclick="deletePasskey('${esc(p.id)}')" class="btn btn-ghost">Remove</button>
|
||||||
|
</div>`).join('') : '<p class="hint">No passkeys registered yet.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPasskey() {
|
||||||
|
if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; }
|
||||||
|
const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey');
|
||||||
|
if (name === null) return;
|
||||||
|
try {
|
||||||
|
const options = await api('/me/passkeys/register/start', { method: 'POST' });
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
rp: options.rp,
|
||||||
|
user: { id: b64urlToBuf(options.user.id), name: options.user.name, displayName: options.user.displayName },
|
||||||
|
challenge: b64urlToBuf(options.challenge),
|
||||||
|
pubKeyCredParams: options.pubKeyCredParams,
|
||||||
|
timeout: options.timeout,
|
||||||
|
attestation: options.attestation,
|
||||||
|
authenticatorSelection: options.authenticatorSelection,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await api('/me/passkeys/register/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
Challenge: options.challenge,
|
||||||
|
Name: name || 'Passkey',
|
||||||
|
ClientDataJSON: bufToB64url(credential.response.clientDataJSON),
|
||||||
|
AttestationObject: bufToB64url(credential.response.attestationObject),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
renderPasskeys();
|
||||||
|
} catch (e) { alert('Failed to add passkey: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePasskey(id) {
|
||||||
|
try { await api('/me/passkeys/' + encodeURIComponent(id), { method: 'DELETE' }); renderPasskeys(); }
|
||||||
|
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usePasskeyLogin() {
|
||||||
|
if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; }
|
||||||
|
try {
|
||||||
|
const options = await fetch(API + '/auth/passkey/start', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ MFAToken: pendingMFAToken }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (options.error) throw new Error(options.error);
|
||||||
|
|
||||||
|
const assertion = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
rpId: options.rpId,
|
||||||
|
challenge: b64urlToBuf(options.challenge),
|
||||||
|
timeout: options.timeout,
|
||||||
|
userVerification: options.userVerification,
|
||||||
|
allowCredentials: options.allowCredentials.map(c => ({ id: b64urlToBuf(c.id), type: c.type })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = await fetch(API + '/auth/passkey/finish', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
MFAToken: pendingMFAToken,
|
||||||
|
Challenge: options.challenge,
|
||||||
|
CredentialID: bufToB64url(assertion.rawId),
|
||||||
|
ClientDataJSON: bufToB64url(assertion.response.clientDataJSON),
|
||||||
|
AuthenticatorData: bufToB64url(assertion.response.authenticatorData),
|
||||||
|
Signature: bufToB64url(assertion.response.signature),
|
||||||
|
}),
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
token = d.token; localStorage.setItem('gomail_token', token);
|
||||||
|
showApp();
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Passkey login failed'; el.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecoveryEmail() {
|
||||||
|
const v = document.getElementById('recovery-email-input').value;
|
||||||
|
try { await api('/me/recovery-email', { method: 'POST', body: JSON.stringify({ RecoveryEmail: v }) }); }
|
||||||
|
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAppPasswords() {
|
||||||
|
const area = document.getElementById('app-passwords-area');
|
||||||
|
const list = await api('/me/app-passwords') || [];
|
||||||
|
area.innerHTML = list.length ? list.map(e => `
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div><div style="font-size:13px">${esc(e.Label)}</div><div style="color:var(--text-faint);font-size:11px">Scopes: ${esc(e.Scopes)}</div></div>
|
||||||
|
<button onclick="deleteAppPassword('${esc(e.ID)}')" class="btn btn-ghost">Revoke</button>
|
||||||
|
</div>`).join('') : '<p class="hint">No app passwords yet.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAppPassword() {
|
||||||
|
const label = document.getElementById('app-pw-label').value;
|
||||||
|
if (!label) return;
|
||||||
|
try {
|
||||||
|
const d = await api('/me/app-passwords', { method: 'POST', body: JSON.stringify({ Label: label }) });
|
||||||
|
document.getElementById('app-passwords-area').insertAdjacentHTML('afterbegin',
|
||||||
|
`<div class="notice">Copy this now, it won't be shown again: <strong>${esc(d.token)}</strong></div>`);
|
||||||
|
document.getElementById('app-pw-label').value = '';
|
||||||
|
renderAppPasswords();
|
||||||
|
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAppPassword(id) {
|
||||||
|
try { await api('/me/app-passwords/' + id, { method: 'DELETE' }); renderAppPasswords(); }
|
||||||
|
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinkedAccountsSettings() {
|
||||||
|
const linked = accounts.filter(a => a.id);
|
||||||
|
document.getElementById('linked-accounts-area').innerHTML = linked.length ? linked.map(a => `
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div><div style="font-size:13px">${esc(a.label)}</div><div style="color:var(--text-faint);font-size:11px">${esc(a.provider)}</div></div>
|
||||||
|
<button onclick="unlinkAccount('${esc(a.id)}')" class="btn btn-ghost">Unlink</button>
|
||||||
|
</div>`).join('') : '<p class="hint">No linked accounts yet.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkAccount(id) {
|
||||||
|
try { await api('/accounts/' + id, { method: 'DELETE' }); await loadAccounts(); renderLinkedAccountsSettings(); }
|
||||||
|
catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOAuth(provider) {
|
||||||
|
try {
|
||||||
|
const d = await api('/accounts/oauth/' + provider + '/start');
|
||||||
|
window.location.href = d.auth_url;
|
||||||
|
} catch (e) { alert(e.error || ('Failed to start ' + provider + ' linking')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImapForm() {
|
||||||
|
const el = document.getElementById('imap-form');
|
||||||
|
el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitImapAccount() {
|
||||||
|
const req = {
|
||||||
|
Email: document.getElementById('imap-email').value,
|
||||||
|
Password: document.getElementById('imap-password').value,
|
||||||
|
IMAPHost: document.getElementById('imap-host').value,
|
||||||
|
IMAPPort: parseInt(document.getElementById('imap-port').value, 10) || 993,
|
||||||
|
IMAPTLS: 'implicit',
|
||||||
|
SMTPHost: document.getElementById('smtp-host').value,
|
||||||
|
SMTPPort: parseInt(document.getElementById('smtp-port').value, 10) || 465,
|
||||||
|
SMTPTLS: 'implicit',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api('/accounts/imap', { method: 'POST', body: JSON.stringify(req) });
|
||||||
|
toggleImapForm();
|
||||||
|
await loadAccounts();
|
||||||
|
renderLinkedAccountsSettings();
|
||||||
|
} catch (e) { alert('Failed: ' + (e.error || e.message)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
+158
-164
@@ -4,73 +4,187 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>GoMail</title>
|
<title>GoMail</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&display=swap" rel="stylesheet">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<style>
|
<style>
|
||||||
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
|
:root{
|
||||||
.sidebar{width:220px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0;bottom:0}
|
--bg:#12141c;
|
||||||
.main{margin-left:220px;display:flex;min-height:100vh}
|
--surface:#1a1d29;
|
||||||
.msg-list{width:340px;border-right:1px solid #334155;overflow-y:auto}
|
--surface-2:#20232f;
|
||||||
.msg-view{flex:1;padding:24px;overflow-y:auto}
|
--border:#2a2e3d;
|
||||||
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
|
--text:#e8e6e1;
|
||||||
.nav-item:hover{background:#334155}
|
--text-muted:#8b8d98;
|
||||||
.nav-item.active{background:#7c3aed22;color:#a78bfa}
|
--text-faint:#5b5e6b;
|
||||||
.msg-row{padding:12px 16px;border-bottom:1px solid #1e293b;cursor:pointer;font-size:13px}
|
--accent:#d9a441;
|
||||||
.msg-row:hover{background:#1e293b80}
|
--accent-dim:#d9a44122;
|
||||||
.msg-row.unread{font-weight:600}
|
--accent-text:#f0c878;
|
||||||
.btn{padding:7px 14px;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;border:none}
|
--danger:#e2685a;
|
||||||
.btn-primary{background:#7c3aed;color:#fff}
|
}
|
||||||
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
|
*{box-sizing:border-box}
|
||||||
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%}
|
body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;margin:0;font-size:14px}
|
||||||
|
.serif{font-family:'Source Serif 4',Georgia,serif}
|
||||||
|
::selection{background:var(--accent-dim)}
|
||||||
|
::-webkit-scrollbar{width:10px;height:10px}
|
||||||
|
::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px}
|
||||||
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
|
a{color:inherit}
|
||||||
|
|
||||||
|
/* ── layout shell ────────────────────────────────────────────── */
|
||||||
|
.app-shell{display:flex;height:100vh;overflow:hidden}
|
||||||
|
.rail{width:250px;flex:none;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column}
|
||||||
|
.rail-header{padding:18px 16px 10px;display:flex;align-items:center;gap:8px}
|
||||||
|
.rail-brand{font-weight:700;font-size:16px;letter-spacing:.01em}
|
||||||
|
.rail-body{flex:1;overflow-y:auto;padding:4px 8px}
|
||||||
|
.rail-footer{padding:10px 12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.workspace{flex:1;display:flex;min-width:0}
|
||||||
|
|
||||||
|
/* ── nav rows (accounts + folders) ──────────────────────────────── */
|
||||||
|
.section-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);padding:14px 10px 6px}
|
||||||
|
.nav-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;color:var(--text-muted);font-size:13px}
|
||||||
|
.nav-row:hover{background:var(--surface-2)}
|
||||||
|
.nav-row.active{background:var(--accent-dim);color:var(--accent-text)}
|
||||||
|
.seal{width:22px;height:22px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;background:var(--surface-2);color:var(--text-muted);border:1px solid var(--border)}
|
||||||
|
.nav-row.active .seal{background:var(--accent);color:#1a1206;border-color:var(--accent)}
|
||||||
|
.nav-row-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.count-badge{font-size:11px;color:var(--text-faint);min-width:16px;text-align:right}
|
||||||
|
.nav-row.active .count-badge{color:var(--accent-text)}
|
||||||
|
|
||||||
|
/* ── message list pane ──────────────────────────────────────────── */
|
||||||
|
.list-pane{width:360px;flex:none;border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0}
|
||||||
|
.list-toolbar{padding:12px;border-bottom:1px solid var(--border)}
|
||||||
|
.list-title{font-weight:700;font-size:15px;margin-bottom:8px}
|
||||||
|
.msg-list{flex:1;overflow-y:auto}
|
||||||
|
.msg-row{padding:12px 14px;border-bottom:1px solid var(--border);cursor:pointer}
|
||||||
|
.msg-row:hover{background:var(--surface)}
|
||||||
|
.msg-row.selected{background:var(--accent-dim)}
|
||||||
|
.msg-row .msg-from{display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px}
|
||||||
|
.msg-row.unread .msg-from{font-weight:700}
|
||||||
|
.msg-row .msg-subject{color:var(--text-muted);font-size:13px;margin-top:2px;font-family:'Source Serif 4',Georgia,serif}
|
||||||
|
.msg-row .msg-meta{color:var(--text-faint);font-size:11px;margin-top:4px;display:flex;gap:6px;align-items:center}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);flex:none}
|
||||||
|
.chip{font-size:10px;padding:1px 7px;border-radius:9px;background:var(--surface-2);color:var(--text-muted);border:1px solid var(--border)}
|
||||||
|
|
||||||
|
/* ── reading pane ────────────────────────────────────────────────── */
|
||||||
|
.reading-pane{flex:1;overflow-y:auto;padding:28px 32px;min-width:0}
|
||||||
|
.reading-empty{color:var(--text-faint);text-align:center;margin-top:80px}
|
||||||
|
.reading-subject{font-family:'Source Serif 4',Georgia,serif;font-weight:700;font-size:21px;color:#fff}
|
||||||
|
.reading-meta{color:var(--text-muted);font-size:13px;margin-top:6px;line-height:1.6}
|
||||||
|
.reading-body{white-space:pre-wrap;font-family:inherit;color:#d4d2ce;font-size:13.5px;line-height:1.6;margin-top:20px}
|
||||||
|
|
||||||
|
/* ── generic controls ────────────────────────────────────────────── */
|
||||||
|
.btn{padding:8px 15px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:filter .12s}
|
||||||
|
.btn:hover{filter:brightness(1.1)}
|
||||||
|
.btn-primary{background:var(--accent);color:#1a1206}
|
||||||
|
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)}
|
||||||
|
.btn-danger{background:transparent;color:var(--danger);border:1px solid #e2685a44}
|
||||||
|
.btn:focus-visible,.inp:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
|
.inp{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:13px;width:100%}
|
||||||
|
.inp::placeholder{color:var(--text-faint)}
|
||||||
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px}
|
||||||
|
.view-single{flex:1;overflow-y:auto;padding:28px 32px}
|
||||||
.modal-bg{position:fixed;inset:0;background:#00000088;z-index:50;display:flex;align-items:center;justify-content:center}
|
.modal-bg{position:fixed;inset:0;background:#00000088;z-index:50;display:flex;align-items:center;justify-content:center}
|
||||||
.modal{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px;width:560px;max-width:95vw}
|
.modal{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px;width:560px;max-width:95vw;max-height:88vh;overflow-y:auto}
|
||||||
.badge{padding:2px 8px;border-radius:10px;font-size:11px}
|
.label{font-size:12px;color:var(--text-muted);margin-bottom:4px;display:block}
|
||||||
|
.field{margin-bottom:12px}
|
||||||
|
.notice{background:var(--accent-dim);border:1px solid var(--accent);color:var(--accent-text);border-radius:8px;padding:10px 12px;font-size:12px;margin-bottom:12px}
|
||||||
|
.settings-section{margin-bottom:22px}
|
||||||
|
.settings-section h3{font-family:'Source Serif 4',Georgia,serif;font-size:17px;margin:0 0 4px}
|
||||||
|
.settings-section p.hint{color:var(--text-muted);font-size:12px;margin:0 0 12px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
||||||
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
|
<div class="card" style="width:320px">
|
||||||
<div style="text-align:center;margin-bottom:20px"><div style="font-size:2.5rem">📧</div>
|
<div style="text-align:center;margin-bottom:20px">
|
||||||
<h1 style="font-weight:700;color:#fff">GoMail</h1></div>
|
<div class="seal" style="width:44px;height:44px;font-size:18px;margin:0 auto 10px;background:var(--accent);color:#1a1206;border:none">G</div>
|
||||||
<input id="le" class="inp" placeholder="you@example.com" style="margin-bottom:10px">
|
<h1 class="serif" style="font-weight:700;color:#fff;font-size:22px;margin:0">GoMail</h1>
|
||||||
<input id="lp" type="password" class="inp" placeholder="Password" style="margin-bottom:10px" onkeydown="if(event.key==='Enter')login()">
|
</div>
|
||||||
|
<div class="field"><input id="le" class="inp" placeholder="you@example.com"></div>
|
||||||
|
<div class="field"><input id="lp" type="password" class="inp" placeholder="Password" onkeydown="if(event.key==='Enter')login()"></div>
|
||||||
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
|
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
|
||||||
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
|
<p id="lerr" style="display:none;color:var(--danger);font-size:12px;text-align:center;margin-top:10px"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="app" style="display:none">
|
<div id="mfa-login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
||||||
<aside class="sidebar">
|
<div class="card" style="width:320px">
|
||||||
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">📧 GoMail</div>
|
<h1 class="serif" style="font-weight:700;color:#fff;font-size:18px;margin:0 0 4px">Two-factor code</h1>
|
||||||
<div style="padding:12px 8px">
|
<p class="hint" style="color:var(--text-muted);font-size:12px;margin:0 0 14px">Enter a code from your authenticator app, or a backup code.</p>
|
||||||
<button onclick="openCompose()" class="btn btn-primary" style="width:100%;margin-bottom:12px">✎ Compose</button>
|
<div class="field"><input id="mfa-code" class="inp" placeholder="123456" onkeydown="if(event.key==='Enter')mfaVerifyLogin()"></div>
|
||||||
<div id="folder-list"></div>
|
<button onclick="mfaVerifyLogin()" class="btn btn-primary" style="width:100%">Verify</button>
|
||||||
<div class="nav-item" onclick="showQuarantine()" id="nav-quarantine" style="margin-top:8px">🔒 Quarantine</div>
|
<button onclick="usePasskeyLogin()" class="btn btn-ghost" style="width:100%;margin-top:8px">Use a passkey instead</button>
|
||||||
|
<p id="mfaerr" style="display:none;color:var(--danger);font-size:12px;text-align:center;margin-top:10px"></p>
|
||||||
</div>
|
</div>
|
||||||
<div style="position:absolute;bottom:0;padding:12px;border-top:1px solid #334155;width:100%;box-sizing:border-box">
|
</div>
|
||||||
<span id="me-email" style="font-size:12px;color:#64748b"></span>
|
|
||||||
<button onclick="logout()" style="float:right;font-size:11px;color:#475569;background:none;border:none;cursor:pointer">Logout</button>
|
<div id="app" style="display:none" class="app-shell">
|
||||||
|
<aside class="rail">
|
||||||
|
<div class="rail-header">
|
||||||
|
<div class="seal" style="background:var(--accent);color:#1a1206;border:none">G</div>
|
||||||
|
<div class="rail-brand serif">GoMail</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 12px 8px">
|
||||||
|
<button onclick="openCompose()" class="btn btn-primary" style="width:100%">✎ Compose</button>
|
||||||
|
</div>
|
||||||
|
<div class="rail-body">
|
||||||
|
<div class="section-label">Accounts</div>
|
||||||
|
<div id="account-switcher"></div>
|
||||||
|
<div id="folder-section">
|
||||||
|
<div class="section-label">Folders</div>
|
||||||
|
<div id="folder-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-label"> </div>
|
||||||
|
<div class="nav-row" onclick="showQuarantine()" id="nav-quarantine">
|
||||||
|
<div class="seal">🔒</div><div class="nav-row-label">Quarantine</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-row" onclick="showSettings()" id="nav-settings">
|
||||||
|
<div class="seal">⚙</div><div class="nav-row-label">Settings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rail-footer">
|
||||||
|
<span id="me-email" style="font-size:12px;color:var(--text-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||||
|
<button onclick="logout()" style="font-size:11px;color:var(--text-faint);background:none;border:none;cursor:pointer;flex:none">Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main">
|
<main class="workspace">
|
||||||
<div id="view-mail" style="display:flex;flex:1">
|
<div id="view-mail" style="display:flex;flex:1;min-width:0">
|
||||||
<div class="msg-list" id="msg-list"></div>
|
<section class="list-pane">
|
||||||
<div class="msg-view" id="msg-view"><div style="color:#475569;text-align:center;margin-top:60px">Select a message</div></div>
|
<div class="list-toolbar">
|
||||||
|
<div class="list-title" id="list-title">Inbox</div>
|
||||||
|
<input id="search-box" class="inp" placeholder="Search this account…" oninput="onSearchInput(this.value)">
|
||||||
|
<div id="search-body-toggle" style="display:none;margin-top:6px">
|
||||||
|
<a href="#" onclick="event.preventDefault();runSearch(true)" style="font-size:11px;color:var(--text-muted)">Search message bodies too (slower)</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="view-quarantine" style="display:none;flex:1;padding:24px">
|
</div>
|
||||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
|
<div class="msg-list" id="msg-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="reading-pane" id="msg-view"><div class="reading-empty">Select a message</div></section>
|
||||||
|
</div>
|
||||||
|
<div id="view-quarantine" class="view-single" style="display:none">
|
||||||
|
<h2 class="serif" style="font-weight:700;margin-bottom:16px">Quarantine</h2>
|
||||||
<div id="quarantine-list"></div>
|
<div id="quarantine-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="view-settings" class="view-single" style="display:none">
|
||||||
|
<h2 class="serif" style="font-weight:700;margin-bottom:20px">Settings</h2>
|
||||||
|
<div id="settings-body"></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="compose-modal" class="modal-bg" style="display:none">
|
<div id="compose-modal" class="modal-bg" style="display:none">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3 style="color:#fff;font-weight:700;margin-bottom:16px">New Message</h3>
|
<h3 class="serif" style="font-weight:700;margin-bottom:16px">New message</h3>
|
||||||
<input id="c-to" class="inp" placeholder="To" style="margin-bottom:8px">
|
<div class="field">
|
||||||
<input id="c-subject" class="inp" placeholder="Subject" style="margin-bottom:8px">
|
<label class="label">From</label>
|
||||||
<textarea id="c-body" class="inp" rows="8" placeholder="Message..." style="margin-bottom:12px"></textarea>
|
<select id="c-from" class="inp"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><input id="c-to" class="inp" placeholder="To"></div>
|
||||||
|
<div class="field"><input id="c-subject" class="inp" placeholder="Subject"></div>
|
||||||
|
<div class="field"><textarea id="c-body" class="inp" rows="8" placeholder="Write something…"></textarea></div>
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</button>
|
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</button>
|
||||||
<button onclick="sendMessage()" class="btn btn-primary">Send</button>
|
<button onclick="sendMessage()" class="btn btn-primary">Send</button>
|
||||||
@@ -78,126 +192,6 @@ body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-s
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="app.js"></script>
|
||||||
const API='/api';
|
|
||||||
let token=localStorage.getItem('gomail_token')||'';
|
|
||||||
let currentFolder='INBOX';
|
|
||||||
|
|
||||||
async function api(path,opts={}){
|
|
||||||
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
|
|
||||||
if(r.status===401){showLogin();return null;}
|
|
||||||
return r.ok?r.json():Promise.reject(await r.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(){
|
|
||||||
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
|
|
||||||
try{
|
|
||||||
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
|
|
||||||
if(d.error)throw new Error(d.error);
|
|
||||||
token=d.token;localStorage.setItem('gomail_token',token);
|
|
||||||
showApp();
|
|
||||||
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
|
|
||||||
}
|
|
||||||
function logout(){localStorage.removeItem('gomail_token');token='';showLogin();}
|
|
||||||
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
|
|
||||||
async function showApp(){
|
|
||||||
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
|
|
||||||
const me=await api('/me');if(!me)return;
|
|
||||||
document.getElementById('me-email').textContent=me.email;
|
|
||||||
loadFolders();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFolders(){
|
|
||||||
const folders=await api('/folders');if(!folders)return;
|
|
||||||
document.getElementById('folder-list').innerHTML=folders.map(f=>
|
|
||||||
`<div class="nav-item ${f.id===currentFolder?'active':''}" onclick="selectFolder('${f.id}')">
|
|
||||||
${f.display_name} ${f.unread_count>0?`<span class="badge" style="background:#7c3aed;color:#fff">${f.unread_count}</span>`:''}
|
|
||||||
</div>`).join('');
|
|
||||||
loadMessages(currentFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFolder(id){
|
|
||||||
currentFolder=id;
|
|
||||||
document.getElementById('view-mail').style.display='flex';
|
|
||||||
document.getElementById('view-quarantine').style.display='none';
|
|
||||||
loadFolders();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMessages(folderID){
|
|
||||||
const msgs=await api('/folders/'+folderID+'/messages');if(!msgs)return;
|
|
||||||
document.getElementById('msg-list').innerHTML=msgs.length?msgs.map(m=>{
|
|
||||||
const unread=!(m.Flags||[]).includes('\\Seen');
|
|
||||||
return `<div class="msg-row ${unread?'unread':''}" onclick="viewMessage('${folderID}','${m.ID}')">
|
|
||||||
<div style="color:#e2e8f0">${esc(m.From||'(unknown)')}</div>
|
|
||||||
<div style="color:#94a3b8">${esc(m.Subject||'(no subject)')}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join(''):'<div style="padding:20px;color:#475569;text-align:center">No messages</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function viewMessage(folderID,id){
|
|
||||||
const msg=await api('/messages/'+folderID+'/'+id);if(!msg)return;
|
|
||||||
document.getElementById('msg-view').innerHTML=`
|
|
||||||
<div style="border-bottom:1px solid #334155;padding-bottom:12px;margin-bottom:12px">
|
|
||||||
<div style="font-size:18px;font-weight:700;color:#fff">${esc(msg.Subject||'(no subject)')}</div>
|
|
||||||
<div style="color:#94a3b8;font-size:13px;margin-top:4px">From: ${esc(msg.From)}</div>
|
|
||||||
<div style="color:#94a3b8;font-size:13px">To: ${esc(msg.To)}</div>
|
|
||||||
</div>
|
|
||||||
<pre style="white-space:pre-wrap;font-family:inherit;color:#cbd5e1;font-size:13px">${esc(bodyOf(msg.Raw))}</pre>
|
|
||||||
<div style="margin-top:16px">
|
|
||||||
<button onclick="deleteMessage('${folderID}','${id}')" class="btn btn-ghost">🗑 Delete</button>
|
|
||||||
</div>`;
|
|
||||||
api('/messages/'+folderID+'/'+id+'/flags',{method:'PUT',body:JSON.stringify({Flags:['\\Seen']})});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bodyOf(raw){
|
|
||||||
if(!raw)return'';
|
|
||||||
const decoded=atob(raw);
|
|
||||||
const idx=decoded.indexOf('\r\n\r\n');
|
|
||||||
return idx>=0?decoded.slice(idx+4):decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMessage(folderID,id){
|
|
||||||
await api('/messages/'+folderID+'/'+id,{method:'DELETE'});
|
|
||||||
loadMessages(folderID);
|
|
||||||
document.getElementById('msg-view').innerHTML='<div style="color:#475569;text-align:center;margin-top:60px">Select a message</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCompose(){document.getElementById('compose-modal').style.display='flex';}
|
|
||||||
function closeCompose(){document.getElementById('compose-modal').style.display='none';}
|
|
||||||
async function sendMessage(){
|
|
||||||
const to=document.getElementById('c-to').value.split(',').map(s=>s.trim());
|
|
||||||
const subject=document.getElementById('c-subject').value;
|
|
||||||
const body=document.getElementById('c-body').value;
|
|
||||||
try{
|
|
||||||
await api('/messages',{method:'POST',body:JSON.stringify({to,subject,body})});
|
|
||||||
closeCompose();
|
|
||||||
document.getElementById('c-to').value='';document.getElementById('c-subject').value='';document.getElementById('c-body').value='';
|
|
||||||
}catch(e){alert('Send failed: '+(e.error||e.message));}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showQuarantine(){
|
|
||||||
document.getElementById('view-mail').style.display='none';
|
|
||||||
document.getElementById('view-quarantine').style.display='block';
|
|
||||||
const entries=await api('/quarantine');if(!entries)return;
|
|
||||||
document.getElementById('quarantine-list').innerHTML=entries.length?entries.map(e=>`
|
|
||||||
<div style="background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<div><div style="color:#e2e8f0;font-size:13px">Reason: ${esc(e.Reason||'—')}</div>
|
|
||||||
<div style="color:#64748b;font-size:12px">Held: ${e.CreatedAt}</div></div>
|
|
||||||
<button onclick="releaseQ('${e.ID}')" class="btn btn-primary">Release</button>
|
|
||||||
</div>`).join(''):'<div style="color:#475569;text-align:center;padding:40px">🎉 Nothing held</div>';
|
|
||||||
}
|
|
||||||
async function releaseQ(id){
|
|
||||||
try{await api('/quarantine/'+id+'/release',{method:'POST'});showQuarantine();}
|
|
||||||
catch(e){alert('Release failed: '+(e.error||e.message));}
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
||||||
|
|
||||||
async function boot(){
|
|
||||||
if(!token){showLogin();return;}
|
|
||||||
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
|
|
||||||
}
|
|
||||||
boot();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user