This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+6 -1
View File
@@ -5,7 +5,12 @@
"Bash(command -v staticcheck)",
"Bash(go build *)",
"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
View File
@@ -172,7 +172,7 @@ func main() {
// 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.
queueWorker := queue.NewWorker(database, store).
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname}).
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname, Database: database}).
WithKeyLookup(
func(fromDomain string) ([]byte, string, bool) {
dom, err := database.LookupDomainByName(fromDomain)
@@ -259,7 +259,7 @@ func main() {
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
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()
webmailHandler.RegisterRoutes(webmailMux)
@@ -362,7 +362,20 @@ func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
configs["google"] = &oauth2.Config{
ClientID: cfg.OAuth.Google.ClientID, ClientSecret: cfg.OAuth.Google.ClientSecret,
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{
ClientID: cfg.OAuth.Microsoft.ClientID, ClientSecret: cfg.OAuth.Microsoft.ClientSecret,
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"},
}
}
}
+9
View File
@@ -5,6 +5,15 @@ go 1.26.4
require (
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.49
github.com/miekg/dns v1.1.72
golang.org/x/crypto v0.54.0
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
)
+14
View File
@@ -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/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/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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+72 -1
View File
@@ -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"
}
+1
View File
@@ -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
View File
@@ -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"
}
+337
View File
@@ -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._
+12
View File
@@ -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
+432
View File
@@ -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"
}
+332
View File
@@ -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._
+12
View File
@@ -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
+432
View File
@@ -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
View File
@@ -1,85 +1,96 @@
# Graph Report - . (2026-08-09)
# Graph Report - webmail (2026-08-10)
## Corpus Check
- 78 files · ~64,934 words
- 85 files · ~82,339 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 953 nodes · 2017 edges · 64 communities (48 shown, 16 thin omitted)
- Extraction: 91% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 170 edges (avg confidence: 0.82)
- Token cost: 134,823 input · 0 output
- 1169 nodes · 2614 edges · 70 communities (52 shown, 18 thin omitted)
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 203 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)
- TOTP & Web Token Auth
- ACME/JWS Client
- CalDAV/CardDAV Handlers
- IMAP Command Parser
- Mail Provider Abstraction
- Server Config & Bootstrap
- Sieve Filter Interpreter
- DB Mutation Queries
- IMAP Server Loop
- POP3 Server Loop
- JMAP Auth & Sessions
- SMTP Session Header Parsing
- Admin API Handlers
- IMAP Client
- DKIM Key Signing
- Outbound Delivery Queue
- SMTP Server Networking
- Linked Account & Alias Queries
- TCP Server Lifecycle
- Calendar/Contact/TLS Queries
- OAuth2 Config
- Go-No-Deps Web App Pattern
- GoMail Build Phases Overview
- Quarantine & Message Queries
- iCal Parsing & Fuzzing
- LLM Spam Stage
- vCard Parsing & Fuzzing
- Admin Portal Bugs & CRUD
- Spam Pipeline Orchestrator
- SPF Stage
- TCP Boundary & Rate-Limit Findings
- Domain/Tenant Queries
- Rspamd Stage
- Per-IP Rate Limiter
- ClamAV Stage
- DMARC Stage
- Spam Header Injection Stage
- Iterative Build Discipline Skill
- Deployment & DNS Setup
- HTTP Middleware
- URL Extraction Stage
- SQLite Deadlock Findings
- List Rule Queries
- Sieve Script Queries
- Mail Context Domain Helpers
- Calendar Object Queries
- Contact Queries
- Shared api() Fetch Convention
- Session Continuity Practices
- Scope Prioritization Practices
- DB Migrations
- Shared esc() Helper
- SPA Login/Logout
- SPA App Boot/Routing
- Graphify Project Rules
- Admin SPA Router
- Raw MIME Body Extractor
- Go Module Root
- 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` - 77 edges
2. `User` - 56 edges
3. `Handler` - 35 edges
4. `Store` - 27 edges
5. `session` - 25 edges
6. `session` - 25 edges
7. `writeJSON()` - 24 edges
8. `writeErr()` - 24 edges
9. `MasterKey` - 23 edges
10. `session` - 20 edges
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]
@@ -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]
- **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
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
Nodes (31): AccountKey, Authorization, Challenge, ChallengeResponder, Client, directory, jwk, Order (+23 more)
### Community 2 - "CalDAV/CardDAV Handlers"
Cohesion: 0.08
Nodes (34): MailProvider, MasterKey, Handler, multistatusResponse, propSet, LinkedAccountProvider, Config, DB (+26 more)
### Community 2 - "webauthn.go"
Cohesion: 0.17
Nodes (16): cborDecode(), cborDecodeWithLength(), DecodePublicKey(), EncodePublicKey(), Time, ParseAttestationObject(), ParseAuthData(), parseCOSEKey() (+8 more)
### Community 3 - "IMAP Command Parser"
Cohesion: 0.06
Nodes (24): MailboxEntry, state, addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf() (+16 more)
### Community 3 - "session"
Cohesion: 0.11
Nodes (15): addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf(), matchesSearch(), parseSeqNum() (+7 more)
### Community 4 - "Mail Provider Abstraction"
Cohesion: 0.09
Nodes (25): Folder, FullMessage, GoMailProvider, IMAPCredential, IMAPProvider, ListOpts, MessageHeader, OAuth2Credential (+17 more)
### Community 4 - "GmailAPIProvider"
Cohesion: 0.07
Nodes (35): CalendarEvent, CalendarProvider, Contact, ContactProvider, Folder, FullMessage, GmailAPIProvider, gmailDateTime (+27 more)
### Community 5 - "Server Config & Bootstrap"
Cohesion: 0.09
Nodes (30): buildOAuthConfigs(), Config, main(), Config, DatabaseConfig, JMAPConfig, LinkedAccountsConfig, NotifyConfig (+22 more)
### Community 5 - "config.go"
Cohesion: 0.07
Nodes (36): buildOAuthConfigs(), Config, Handler, ipAllowlistMiddleware(), main(), Config, DatabaseConfig, JMAPConfig (+28 more)
### Community 6 - "Sieve Filter Interpreter"
### Community 6 - "parser"
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
Nodes (17): Config, Context, DB, Duration, Listener, WaitGroup, NewServer(), escapeQuoted() (+9 more)
### Community 9 - "POP3 Server Loop"
Cohesion: 0.15
Nodes (13): Config, Conn, Context, DB, Duration, Listener, ReadWriter, WaitGroup (+5 more)
### Community 9 - "session"
Cohesion: 0.13
Nodes (16): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+8 more)
### Community 10 - "JMAP Auth & Sessions"
Cohesion: 0.12
Nodes (20): Scope, Authenticate(), checkAppPassword(), DB, Context, DB, Request, ResponseWriter (+12 more)
### Community 10 - "Handler"
Cohesion: 0.19
Nodes (14): Context, DB, Request, ResponseWriter, ServeMux, jmapRole(), NewHandler(), splitCompositeID() (+6 more)
### Community 11 - "SMTP Session Header Parsing"
Cohesion: 0.16
Nodes (15): extractHeader(), extractHeaderMap(), extractMessageID(), extractSubject(), Conn, Context, IP, ReadWriter (+7 more)
### Community 11 - "session"
Cohesion: 0.09
Nodes (28): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+20 more)
### Community 12 - "Admin API Handlers"
### Community 12 - "writeErr"
Cohesion: 0.20
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
### Community 13 - "IMAP Client"
### Community 13 - "Client"
Cohesion: 0.15
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
### Community 14 - "DKIM Key Signing"
### Community 14 - "Sign"
Cohesion: 0.14
Nodes (20): KeyPair, ExtractSignatureInfo(), GenerateKeyPair(), PrivateKey, ParseDNSPublicKey(), ParsePrivateKey(), buildDKIMHeader(), canonicalizeBodyRelaxed() (+12 more)
### Community 15 - "Outbound Delivery Queue"
Cohesion: 0.18
Nodes (11): backoffDuration(), domainOf(), DB, Duration, isPermanentError(), lookupMXHosts(), NewWorker(), Deliverer (+3 more)
### Community 15 - "Worker"
Cohesion: 0.10
Nodes (25): TLSARecord, Certificate, Context, Lookup(), VerifyPeerCertificate(), Discover(), Context, Duration (+17 more)
### Community 16 - "SMTP Server Networking"
Cohesion: 0.19
Nodes (14): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+6 more)
### Community 16 - "session"
Cohesion: 0.12
Nodes (14): Scope, state, Authenticate(), checkAppPassword(), DB, Config, Conn, Context (+6 more)
### Community 17 - "Linked Account & Alias Queries"
Cohesion: 0.14
Nodes (11): Alias, AppPassword, CheckResult, LinkedAccount, LinkedAccountAuthType, MessageCheck, MFABackupCode, ReleaseToken (+3 more)
### Community 17 - "models.go"
Cohesion: 0.15
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
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"
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"
### 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 Build Phases Overview"
### 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 - "Quarantine & Message Queries"
Cohesion: 0.15
Nodes (5): Message, MessageVerdict, QuarantineEntry, QuarantineStatus, Time
### Community 23 - "QuarantineEntry"
Cohesion: 0.22
Nodes (3): QuarantineEntry, QuarantineStatus, Time
### Community 24 - "iCal Parsing & Fuzzing"
### Community 24 - "ical.go"
Cohesion: 0.24
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
### Community 25 - "LLM Spam Stage"
### Community 25 - ".Run"
Cohesion: 0.29
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
### Community 26 - "vCard Parsing & Fuzzing"
### Community 26 - "vcard.go"
Cohesion: 0.27
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
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"
Cohesion: 0.40
Nodes (8): DefaultStages(), Config, Context, NewOrchestrator(), StagesFromConfig(), verdictFor(), Orchestrator, Stage
### Community 28 - "app.js"
Cohesion: 0.10
Nodes (56): accounts, acctQuery(), addPasskey(), api(), b64urlToBuf(), bodyOf(), boot(), bufToB64url() (+48 more)
### Community 29 - "SPF Stage"
Cohesion: 0.40
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
### Community 29 - "Server"
Cohesion: 0.18
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
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"
Cohesion: 0.36
Nodes (5): Context, Duration, rspamdResponse, RspamdStage, rspamdSymbol
### Community 32 - "checkSPF"
Cohesion: 0.40
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
### Community 33 - "Per-IP Rate Limiter"
### Community 33 - "Limiter"
Cohesion: 0.33
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
Nodes (4): Context, Duration, parseClamAddr(), ClamAVStage
### Community 35 - "DMARC Stage"
Cohesion: 0.39
Nodes (5): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), DMARCStage
### Community 36 - ".Run"
Cohesion: 0.22
Nodes (7): dmarcTag(), extractDomainFromHeader(), Context, orgDomain(), Context, DMARCStage, HeaderStage
### Community 36 - "Spam Header Injection Stage"
Cohesion: 0.33
Nodes (4): Context, injectSpamHeaders(), HeaderStage, StageResult
### Community 37 - "Iterative Build Discipline Skill"
### 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 - "Deployment & DNS Setup"
### 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 - "HTTP Middleware"
Cohesion: 0.33
Nodes (4): Handler, clientIP(), Limiter, Request
### Community 39 - "GraphAPIProvider"
Cohesion: 0.17
Nodes (14): GraphAPIProvider, graphDateTimeTZ, graphFolder, graphMessage, graphRecipient, Config, Contact, Context (+6 more)
### Community 40 - "URL Extraction Stage"
### Community 40 - ".Run"
Cohesion: 0.47
Nodes (3): dedupe(), Context, URLStage
### Community 41 - "SQLite Deadlock Findings"
### 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 - "Mail Context Domain Helpers"
Cohesion: 0.50
Nodes (3): domainOf(), IP, MailContext
### Community 44 - "MailContext"
Cohesion: 0.31
Nodes (5): Message, MessageVerdict, domainOf(), IP, MailContext
### Community 47 - "Shared api() Fetch Convention"
### 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
@@ -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
## 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.
- **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
_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._
- **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 `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`?**
_High betweenness centrality (0.323) - 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`?**
_High betweenness centrality (0.109) - 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`?**
_High betweenness centrality (0.098) - this node is a cross-community bridge._
- **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.104) - 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._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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": []}
@@ -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}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
1786283404.4033368
1786338980.5170527
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12425 -2829
View File
File diff suppressed because it is too large Load Diff
+117 -77
View File
@@ -1,43 +1,43 @@
{
".claude/settings.json": {
"mtime": 1786281487.088728,
"ast_hash": "22d1c817e36c7eccf36bf30ee13299cc",
"semantic_hash": "22d1c817e36c7eccf36bf30ee13299cc"
"ast_hash": "",
"semantic_hash": ""
},
"cmd/gomail/main.go": {
"mtime": 1786281911.6889164,
"ast_hash": "6a50dc1a9b0161c4c030b51307ff92cf",
"semantic_hash": "6a50dc1a9b0161c4c030b51307ff92cf"
"mtime": 1786303371.9406705,
"ast_hash": "32c3d08429cc99b216f59edf3e0e4caa",
"semantic_hash": ""
},
"go.mod": {
"mtime": 1786281935.7638168,
"ast_hash": "38e6ab128168a0a88095fec4523f2992",
"semantic_hash": "38e6ab128168a0a88095fec4523f2992"
"mtime": 1786304427.3009958,
"ast_hash": "4bbf418e44c141deba9e99f9d780ed9b",
"semantic_hash": ""
},
"internal/accounts/link.go": {
"mtime": 1786281887.2038412,
"ast_hash": "4ffb8f5d3a6ac987d960c702f00f4e07",
"semantic_hash": "4ffb8f5d3a6ac987d960c702f00f4e07"
"mtime": 1786299069.1242821,
"ast_hash": "5eed8ecb5be6a1c35c6591dedad976d1",
"semantic_hash": ""
},
"internal/accounts/provider.go": {
"mtime": 1786168320.0,
"ast_hash": "4a180151e798601f43bda25bd1a80410",
"semantic_hash": "4a180151e798601f43bda25bd1a80410"
"mtime": 1786303040.5896587,
"ast_hash": "7b4ccfea1a6fbad3e8b519a295cb520b",
"semantic_hash": ""
},
"internal/accounts/provider_gomail.go": {
"mtime": 1786281891.3509514,
"ast_hash": "f0dd245cb144dca3f03520e632c1d5f5",
"semantic_hash": "f0dd245cb144dca3f03520e632c1d5f5"
"mtime": 1786297740.5829873,
"ast_hash": "ac0d507cba9953d85a44ce0506217587",
"semantic_hash": ""
},
"internal/accounts/provider_imap.go": {
"mtime": 1786281893.9837677,
"ast_hash": "99587258525acd5ab4b20398a2d0d400",
"semantic_hash": "99587258525acd5ab4b20398a2d0d400"
"mtime": 1786298998.877657,
"ast_hash": "4825cd303e132e3179500afe0e9098bd",
"semantic_hash": ""
},
"internal/accounts/provider_smtp_helper.go": {
"mtime": 1786281896.5890837,
"ast_hash": "188d01aeb8fe3fb73cb76a25ffda71b4",
"semantic_hash": "188d01aeb8fe3fb73cb76a25ffda71b4"
"mtime": 1786289886.1606128,
"ast_hash": "4fec96778cdaa94b1ab2264236af8b48",
"semantic_hash": ""
},
"internal/acme/challenge.go": {
"mtime": 1786189310.0,
@@ -80,14 +80,14 @@
"semantic_hash": "c74ed7b257b2272dcfb860b8a60746a0"
},
"internal/config/config.go": {
"mtime": 1786189702.0,
"ast_hash": "12cee16f6dcfa6971217dd104e86f4d6",
"semantic_hash": "12cee16f6dcfa6971217dd104e86f4d6"
"mtime": 1786289818.8781466,
"ast_hash": "68fd6f093c4ba00106bdbbe59786a269",
"semantic_hash": ""
},
"internal/crypto/crypto.go": {
"mtime": 1784360971.0,
"ast_hash": "4567100200969235fd1559ad9be6f1b1",
"semantic_hash": "4567100200969235fd1559ad9be6f1b1"
"mtime": 1786297602.7221303,
"ast_hash": "8de4f9714d1c3e73a4322080d6b1a2ca",
"semantic_hash": ""
},
"internal/dav/dav.go": {
"mtime": 1786281900.5555487,
@@ -100,24 +100,24 @@
"semantic_hash": "922a08de72be0ef63409ed402d01e098"
},
"internal/db/db.go": {
"mtime": 1784361005.0,
"ast_hash": "64d527aad454dab7867ffd51aa3d72ca",
"semantic_hash": "64d527aad454dab7867ffd51aa3d72ca"
"mtime": 1786297548.1684437,
"ast_hash": "aed0290af4025c8c84089356f8746f08",
"semantic_hash": ""
},
"internal/db/migrations.go": {
"mtime": 1786193366.0,
"ast_hash": "d67ddd29ab706ae5ce670da624e30b2c",
"semantic_hash": "d67ddd29ab706ae5ce670da624e30b2c"
"mtime": 1786300065.1340663,
"ast_hash": "2eb17b13bdc6bd5a954c82b4503aa8f9",
"semantic_hash": ""
},
"internal/db/models.go": {
"mtime": 1786193384.0,
"ast_hash": "cf35e391db47a84bd0b4e05ba77b9a95",
"semantic_hash": "cf35e391db47a84bd0b4e05ba77b9a95"
"mtime": 1786300091.6301413,
"ast_hash": "12f132efac1ac74ab13ce8bfce0bfc69",
"semantic_hash": ""
},
"internal/db/queries.go": {
"mtime": 1786193758.0,
"ast_hash": "c5f7a40030988031163220e54051b93d",
"semantic_hash": "c5f7a40030988031163220e54051b93d"
"mtime": 1786301261.1620972,
"ast_hash": "684ba8ac79df6845b0f0571ee6ff67a3",
"semantic_hash": ""
},
"internal/dkim/keys.go": {
"mtime": 1785566243.0,
@@ -145,9 +145,9 @@
"semantic_hash": "5518fd86059d729048685e2bc39c24b2"
},
"internal/imap/commands.go": {
"mtime": 1786281888.6448092,
"ast_hash": "e0a26bce9488a5d0bc680d5b11d15610",
"semantic_hash": "e0a26bce9488a5d0bc680d5b11d15610"
"mtime": 1786294683.3800013,
"ast_hash": "584a4c4fa7ad664aaec36bc404825512",
"semantic_hash": ""
},
"internal/imap/parser.go": {
"mtime": 1785638411.0,
@@ -155,9 +155,9 @@
"semantic_hash": "687b7788b077688b9081f958b669a0e6"
},
"internal/imap/server.go": {
"mtime": 1786281911.0781865,
"ast_hash": "13154da7fca4635077e377476aef64c8",
"semantic_hash": "13154da7fca4635077e377476aef64c8"
"mtime": 1786289824.8942587,
"ast_hash": "50a8a0f58edd9d9bb6cafa385f3c97b2",
"semantic_hash": ""
},
"internal/imap/session.go": {
"mtime": 1786281910.5246246,
@@ -180,9 +180,9 @@
"semantic_hash": "2484a0bccd56908627cee4c769b689e5"
},
"internal/mailstore/maildir.go": {
"mtime": 1786281898.3165317,
"ast_hash": "7224a5c029cdcdc63c0de93cb8a8eb51",
"semantic_hash": "7224a5c029cdcdc63c0de93cb8a8eb51"
"mtime": 1786297713.2183998,
"ast_hash": "ca8749979e4a1ca9ada23c072945189c",
"semantic_hash": ""
},
"internal/managesieve/server.go": {
"mtime": 1786281899.3706038,
@@ -195,9 +195,9 @@
"semantic_hash": "ab69e22b6add70fdb6c18f056c474e8d"
},
"internal/oauth2/oauth2.go": {
"mtime": 1786169607.0,
"ast_hash": "5ab8fd85a331f7f82470ee354e309ec4",
"semantic_hash": "5ab8fd85a331f7f82470ee354e309ec4"
"mtime": 1786298531.0961437,
"ast_hash": "4e1c7abbe92feb151acecfec03229c50",
"semantic_hash": ""
},
"internal/pipeline/pipeline.go": {
"mtime": 1786281906.8561504,
@@ -215,9 +215,9 @@
"semantic_hash": "3f92da867d5b128398a8ffc70b6fa475"
},
"internal/pipeline/stage_dmarc.go": {
"mtime": 1786281917.6531692,
"ast_hash": "2d73846ca47178c53a0ffb341b7d754e",
"semantic_hash": "2d73846ca47178c53a0ffb341b7d754e"
"mtime": 1786294688.9970334,
"ast_hash": "fc9ece401684fb950771b70db7a54d9d",
"semantic_hash": ""
},
"internal/pipeline/stage_headers.go": {
"mtime": 1786281909.176789,
@@ -245,19 +245,19 @@
"semantic_hash": "f954d508ac085f560928c06171779db5"
},
"internal/pop3/pop3.go": {
"mtime": 1786281909.7660527,
"ast_hash": "0e3685f20c9ca05d3a28729c8ad7a29f",
"semantic_hash": "0e3685f20c9ca05d3a28729c8ad7a29f"
"mtime": 1786289853.1900737,
"ast_hash": "e4756d72c5bc2bd89fd41a2205ca3d14",
"semantic_hash": ""
},
"internal/queue/queue.go": {
"mtime": 1786281808.0265675,
"ast_hash": "71c0ce0211d61d37f51f19678dbcdeb4",
"semantic_hash": "71c0ce0211d61d37f51f19678dbcdeb4"
"mtime": 1786300256.943359,
"ast_hash": "5e9631b0601474ed28f3626a293e8e08",
"semantic_hash": ""
},
"internal/ratelimit/http.go": {
"mtime": 1786207203.0,
"ast_hash": "c06b19193ad3c3159551966c05f48ea5",
"semantic_hash": "c06b19193ad3c3159551966c05f48ea5"
"mtime": 1786289690.9789355,
"ast_hash": "039681ec4aef1340ab4ba8bf83baa679",
"semantic_hash": ""
},
"internal/ratelimit/ratelimit.go": {
"mtime": 1786207185.0,
@@ -325,19 +325,19 @@
"semantic_hash": "5aeab637a3c3357bb2c13cacc21508c6"
},
"internal/webmail/api.go": {
"mtime": 1786281903.8315737,
"ast_hash": "3ccccaaef2a926a90919805f67173db2",
"semantic_hash": "3ccccaaef2a926a90919805f67173db2"
"mtime": 1786339172.9744284,
"ast_hash": "fcc1ddeb2d5f749bee23623529d76b73",
"semantic_hash": ""
},
"internal/webmail/embed.go": {
"mtime": 1786132251.0,
"ast_hash": "441b827ff1643c3807b6efb92119a721",
"semantic_hash": "441b827ff1643c3807b6efb92119a721"
"mtime": 1786295803.857812,
"ast_hash": "78663d0210a610d1777c4b4151390cf1",
"semantic_hash": ""
},
"internal/webtoken/webtoken.go": {
"mtime": 1786193462.0,
"ast_hash": "d94a2b905b20ccbd8a83e258245ba375",
"semantic_hash": "d94a2b905b20ccbd8a83e258245ba375"
"mtime": 1786289648.699828,
"ast_hash": "b9be7cc5ea74ce6b82de79ef3644cb52",
"semantic_hash": ""
},
".claude/go-web-app-no-deps-SKILL.md": {
"mtime": 1786206554.0,
@@ -385,8 +385,48 @@
"semantic_hash": "77b0b0e42cf45a961571ee5e3a3a219a"
},
"internal/webmail/static/index.html": {
"mtime": 1786132285.0,
"ast_hash": "3b77272b21933601eed33f3441d386cc",
"semantic_hash": "3b77272b21933601eed33f3441d386cc"
"mtime": 1786302422.3206468,
"ast_hash": "240f0802126ee6142b639b0c3d1fd749",
"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
View File
@@ -1,8 +1,12 @@
package accounts
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gomail/internal/crypto"
"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
// tokens (from a completed authorization code exchange) encrypted the same
// 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
}
// 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
// account row — the single place that decides which backend handles which
// 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 {
case db.ProviderIMAP:
return NewIMAPProvider(account, mk), nil
case db.ProviderGmail, db.ProviderM365:
providerKey := "google"
if account.Provider == db.ProviderM365 {
providerKey = "microsoft"
}
cfg := oauthConfigs[providerKey]
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
case db.ProviderGmail:
return NewGmailAPIProvider(account, mk, database, oauthConfigs["google"]), nil
case db.ProviderM365:
return NewGraphAPIProvider(account, mk, database, oauthConfigs["microsoft"]), nil
default:
return nil, fmt.Errorf("provider %q not supported", account.Provider)
}
+42 -1
View File
@@ -5,7 +5,10 @@
// actually lives.
package accounts
import "context"
import (
"context"
"time"
)
type Folder struct {
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
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)
}
+421
View File
@@ -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
}
+15
View File
@@ -80,6 +80,21 @@ func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts L
var headers []MessageHeader
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)
if err != nil {
continue
+407
View File
@@ -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
}
+6 -65
View File
@@ -13,7 +13,6 @@ import (
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
"gomail/internal/oauth2"
)
const dialTimeout = 20 * time.Second
@@ -39,27 +38,19 @@ type OAuth2Credential struct {
// connections" story without IDLE/pooling machinery this pass doesn't build
// yet, so simplicity wins: connect, do the one operation, disconnect. A
// 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 {
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB // needed to persist a refreshed access token
oauthConfig *oauth2.Config // nil for password-auth accounts
account *db.LinkedAccount
mk *crypto.MasterKey
}
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
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) {
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
// 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)
if err != nil {
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
}
// 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) {
client, err := p.connect(ctx)
if err != nil {
+26
View File
@@ -12,6 +12,7 @@ import (
"encoding/hex"
"fmt"
"io"
"sync"
"golang.org/x/crypto/hkdf"
)
@@ -57,17 +58,42 @@ func decodeKey(hexStr string) ([]byte, error) {
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.
// 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
// required for decryption to work.
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)
r := hkdf.New(sha256.New, master, nil, info)
key := make([]byte, keySize)
if _, err := io.ReadFull(r, key); err != nil {
return nil, fmt.Errorf("hkdf derive: %w", err)
}
keyCache.Store(ck, key)
return key, nil
}
+160
View File
@@ -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
View File
@@ -43,23 +43,35 @@ func Open(driver, dsn string) (*DB, error) {
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)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if d == "sqlite" {
// SQLite doesn't handle concurrent writers well — serialize via single conn.
sqlDB.SetMaxOpenConns(1)
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enabling WAL mode: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("setting busy timeout: %w", err)
}
// WAL mode already lets SQLite itself handle concurrent readers +
// one writer (with _busy_timeout above covering writer contention) —
// a small pool, not a single shared connection, so concurrent
// requests across SMTP/IMAP/webmail/admin/etc. aren't all serialized
// through one connection for no reason.
sqlDB.SetMaxOpenConns(4)
sqlDB.SetMaxIdleConns(4)
}
if err := sqlDB.Ping(); err != nil {
+36
View File
@@ -368,6 +368,42 @@ CREATE TABLE mfa_backup_codes (
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
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);
`,
},
},
+15
View File
@@ -144,6 +144,7 @@ type MailboxEntry struct {
SizeBytes int64
ReceivedAt time.Time
InternalDate time.Time
HeaderEnc []byte // encrypted CachedHeader JSON; nil for pre-migration rows
}
// ── Outbound queue ───────────────────────────────────────────────────────────
@@ -342,6 +343,20 @@ type TLSCert struct {
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 ───────────────────────────────────────────────────────────────────────
type MFABackupCode struct {
+79 -10
View File
@@ -288,9 +288,9 @@ func (db *DB) NextMailboxUID(userID, mailbox string) (int, error) {
// InsertMailboxEntry records a delivered message in a user's mailbox index.
func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
_, err := db.Exec(`
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate)
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate, e.HeaderEnc)
if err != nil {
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.
func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error) {
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
`, userID, mailbox)
if err != nil {
@@ -312,7 +312,7 @@ func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error)
var entries []MailboxEntry
for rows.Next() {
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
}
entries = append(entries, e)
@@ -584,11 +584,11 @@ func (db *DB) DeleteUser(id string) 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,
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 displayName, recoveryEmail sql.NullString
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 {
return nil, ErrNotFound
}
@@ -1009,6 +1009,38 @@ func (db *DB) SetACMEAccountKey(domain string, keyEnc []byte) error {
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 ───────────────────────────────────────────────────────────────────────
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
@@ -1024,11 +1056,39 @@ func (db *DB) SetMFAEnabled(userID string, enabled bool) error {
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 {
_, 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
}
// 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
// inserts a fresh set — called once at MFA confirm time; codes are shown to
// 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 == "" {
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 true, nil
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
func (db *DB) SetRecoveryEmail(userID, email string) error {
+278
View File
@@ -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)
}
+50
View File
@@ -6,8 +6,10 @@ package mailstore
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/mail"
"os"
"path/filepath"
"strings"
@@ -33,6 +35,27 @@ func New(root string, mk *crypto.MasterKey, database *db.DB) *Store {
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,
// allocates the next IMAP UID, and records the mailbox_index row. Returns the
// 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)
}
// 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)
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", 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)),
ReceivedAt: time.Now().UTC(),
InternalDate: time.Now().UTC(),
HeaderEnc: headerEnc,
}
if err := s.db.InsertMailboxEntry(entry); err != nil {
// 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
}
// 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 {
base := s.mailboxDir(userEmail, mailbox)
for _, sub := range []string{"cur", "new", "tmp"} {
+135
View File
@@ -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, ".")
}
+14
View File
@@ -154,6 +154,20 @@ func truncate(b []byte, n int) string {
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
// by IMAP/SMTP clients authenticating with an OAuth2 access token instead
// of a password) per Google's documented format, which Microsoft also
+131 -3
View File
@@ -4,7 +4,9 @@
package queue
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
@@ -12,9 +14,11 @@ import (
"strings"
"time"
"gomail/internal/dane"
"gomail/internal/db"
"gomail/internal/dkim"
"gomail/internal/mailstore"
"gomail/internal/mtasts"
)
const (
@@ -49,7 +53,7 @@ func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
return &Worker{
database: database,
store: store,
deliverer: &MXDeliverer{Hostname: "gomail"},
deliverer: &MXDeliverer{Hostname: "gomail", Database: database},
stopCh: make(chan struct{}),
}
}
@@ -251,6 +255,7 @@ func isPermanentError(err error) bool {
// uniformly regardless of which Deliverer implementation is in use.
type MXDeliverer struct {
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 {
@@ -271,7 +276,119 @@ func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
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 {
domain := domainOf(to)
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
if err != nil {
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)
}
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 {
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
if err := client.StartTLS(tlsConf); err != nil {
if err := client.StartTLS(policy.config); 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)
}
} 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 {
+176
View File
@@ -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)
}
}
+297
View File
@@ -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
}
+755 -19
View File
@@ -9,11 +9,14 @@ package webmail
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/mail"
"sort"
"strconv"
"strings"
"sync"
@@ -26,6 +29,7 @@ import (
"gomail/internal/mailstore"
"gomail/internal/oauth2"
"gomail/internal/totp"
"gomail/internal/webauthn"
"gomail/internal/webtoken"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
@@ -38,11 +42,15 @@ type Handler struct {
store *mailstore.Store
mk *crypto.MasterKey
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
oauthStateMu sync.Mutex
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 {
@@ -51,11 +59,21 @@ type oauthStateEntry struct {
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{
database: database, store: store, mk: mk, jwtSecret: jwtSecret,
oauthConfigs: oauthConfigs,
oauthState: make(map[string]oauthStateEntry),
database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname,
oauthConfigs: oauthConfigs,
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/confirm", h.withAuth(h.mfaConfirm))
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/app-passwords", h.withAuth(h.appPasswords))
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/messages", h.withAuth(h.sendOrListMessages))
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.releaseQuarantine))
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
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/imap", h.withAuth(h.linkIMAPAccount))
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) {
// 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{
"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 ──────────────────────────────────────────────────────────
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
return accounts.NewGoMailProvider(h.database, h.store, user)
// provider resolves which mailbox a request operates on. With no ?account=
// 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) {
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 {
writeErr(w, http.StatusInternalServerError, err.Error())
return
@@ -293,7 +349,12 @@ func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.
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 {
writeErr(w, http.StatusInternalServerError, err.Error())
return
@@ -321,8 +382,13 @@ func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, use
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}
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())
return
}
@@ -342,7 +408,11 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
if len(parts) >= 3 {
action = parts[2]
}
p := h.provider(user)
p, err := h.provider(r, user)
if err != nil {
writeErr(w, http.StatusForbidden, err.Error())
return
}
switch {
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 ────────────────────────────────────────────────────────────────
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"})
}
// 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.
// start requires an authenticated session (checked inline, not via withAuth,
// 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
}
// Note: a real implementation would call the provider's userinfo/profile
// endpoint here to learn the account's actual email address rather than
// require it as a query param — deferred; for now the display name is
// generic and the operator/user can rename it, matching the minimum
// needed to prove the OAuth2 flow itself is correct end-to-end.
email := r.URL.Query().Get("email")
if email == "" {
email = provider + "-account"
email, err := accounts.FetchOAuth2Email(r.Context(), provider, token.AccessToken)
if err != nil {
slog.Error("failed to look up account email from provider", "provider", provider, "err", err)
writeErr(w, http.StatusBadGateway, "failed to look up account email")
return
}
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())
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"})
}
// ── 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 ─────────────────────────────────────────────────────────────
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
+1 -1
View File
@@ -2,5 +2,5 @@ package webmail
import "embed"
//go:embed static/index.html
//go:embed static
var StaticFS embed.FS
+601
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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
View File
@@ -4,73 +4,187 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<style>
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
.sidebar{width:220px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0;bottom:0}
.main{margin-left:220px;display:flex;min-height:100vh}
.msg-list{width:340px;border-right:1px solid #334155;overflow-y:auto}
.msg-view{flex:1;padding:24px;overflow-y:auto}
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
.nav-item:hover{background:#334155}
.nav-item.active{background:#7c3aed22;color:#a78bfa}
.msg-row{padding:12px 16px;border-bottom:1px solid #1e293b;cursor:pointer;font-size:13px}
.msg-row:hover{background:#1e293b80}
.msg-row.unread{font-weight:600}
.btn{padding:7px 14px;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;border:none}
.btn-primary{background:#7c3aed;color:#fff}
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%}
:root{
--bg:#12141c;
--surface:#1a1d29;
--surface-2:#20232f;
--border:#2a2e3d;
--text:#e8e6e1;
--text-muted:#8b8d98;
--text-faint:#5b5e6b;
--accent:#d9a441;
--accent-dim:#d9a44122;
--accent-text:#f0c878;
--danger:#e2685a;
}
*{box-sizing:border-box}
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{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px;width:560px;max-width:95vw}
.badge{padding:2px 8px;border-radius:10px;font-size:11px}
.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}
.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>
</head>
<body>
<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 style="text-align:center;margin-bottom:20px"><div style="font-size:2.5rem">📧</div>
<h1 style="font-weight:700;color:#fff">GoMail</h1></div>
<input id="le" class="inp" placeholder="you@example.com" style="margin-bottom:10px">
<input id="lp" type="password" class="inp" placeholder="Password" style="margin-bottom:10px" onkeydown="if(event.key==='Enter')login()">
<div class="card" style="width:320px">
<div style="text-align:center;margin-bottom:20px">
<div class="seal" style="width:44px;height:44px;font-size:18px;margin:0 auto 10px;background:var(--accent);color:#1a1206;border:none">G</div>
<h1 class="serif" style="font-weight:700;color:#fff;font-size:22px;margin:0">GoMail</h1>
</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>
<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 id="app" style="display:none">
<aside class="sidebar">
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">📧 GoMail</div>
<div style="padding:12px 8px">
<button onclick="openCompose()" class="btn btn-primary" style="width:100%;margin-bottom:12px">✎ Compose</button>
<div id="folder-list"></div>
<div class="nav-item" onclick="showQuarantine()" id="nav-quarantine" style="margin-top:8px">🔒 Quarantine</div>
<div id="mfa-login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
<div class="card" style="width:320px">
<h1 class="serif" style="font-weight:700;color:#fff;font-size:18px;margin:0 0 4px">Two-factor code</h1>
<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>
<div class="field"><input id="mfa-code" class="inp" placeholder="123456" onkeydown="if(event.key==='Enter')mfaVerifyLogin()"></div>
<button onclick="mfaVerifyLogin()" class="btn btn-primary" style="width:100%">Verify</button>
<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 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="position:absolute;bottom:0;padding:12px;border-top:1px solid #334155;width:100%;box-sizing:border-box">
<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 style="padding:0 12px 8px">
<button onclick="openCompose()" class="btn btn-primary" style="width:100%">&#9998; 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">&nbsp;</div>
<div class="nav-row" onclick="showQuarantine()" id="nav-quarantine">
<div class="seal">&#128274;</div><div class="nav-row-label">Quarantine</div>
</div>
<div class="nav-row" onclick="showSettings()" id="nav-settings">
<div class="seal">&#9881;</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>
</aside>
<main class="main">
<div id="view-mail" style="display:flex;flex:1">
<div class="msg-list" id="msg-list"></div>
<div class="msg-view" id="msg-view"><div style="color:#475569;text-align:center;margin-top:60px">Select a message</div></div>
<main class="workspace">
<div id="view-mail" style="display:flex;flex:1;min-width:0">
<section class="list-pane">
<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 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" style="display:none;flex:1;padding:24px">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
<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>
<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>
</div>
<div id="compose-modal" class="modal-bg" style="display:none">
<div class="modal">
<h3 style="color:#fff;font-weight:700;margin-bottom:16px">New Message</h3>
<input id="c-to" class="inp" placeholder="To" style="margin-bottom:8px">
<input id="c-subject" class="inp" placeholder="Subject" style="margin-bottom:8px">
<textarea id="c-body" class="inp" rows="8" placeholder="Message..." style="margin-bottom:12px"></textarea>
<h3 class="serif" style="font-weight:700;margin-bottom:16px">New message</h3>
<div class="field">
<label class="label">From</label>
<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">
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</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>
<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
async function boot(){
if(!token){showLogin();return;}
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
}
boot();
</script>
<script src="app.js"></script>
</body>
</html>