first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
---
name: go-web-app-no-deps
description: Build a server-rendered web application in Go using ONLY the standard library (net/http, html/template, database/sql, embed) — no web framework (no Gin/Echo/Fiber/gorilla), no template engine beyond stdlib html/template, no third-party JS framework. Uses a Flask/Jinja2-style template inheritance pattern (a base.html layout with named blocks that page templates override) and a dark Tailwind CSS theme with a defined CSS custom-property palette. Use this whenever the user asks for a Go web app, admin panel, dashboard, file manager, webmail client, or any browser-based tool built in Go without third-party dependencies, or mentions wanting "Flask-like templates in Go" or a dark Tailwind theme for a Go backend. Also use when debugging a Go html/template project where blocks from different pages are bleeding into each other, or where static assets aren't updating in the browser after a change.
---
# Go Web App, No Third-Party Dependencies
Two real projects converged on this exact pattern: a multi-account webmail
client and a browser-based file explorer, both pure Go + stdlib
`html/template` + vanilla JS + Tailwind CSS dark theme. The patterns below
are what actually worked after hitting real bugs — not a theoretical
"here's how you'd probably do it."
## Project layout
```
project/
├── cmd/appname/main.go
├── internal/
│ ├── handlers/ # HTTP handlers, one file per feature area
│ ├── <domain logic>/ # the actual app logic, no http.* imports here
│ └── render/ # the template Renderer (see below)
├── web/
│ ├── templates/
│ │ ├── base.html # the ONE layout every page extends
│ │ └── <page>.html # one file per page (login.html, app.html, admin.html...)
│ └── static/
│ ├── css/appname.css
│ └── js/appname.js
└── go.mod
```
Embed `web/` into the binary with `//go:embed web/templates web/static` on
a package-level `embed.FS` var — ships as one binary, no separate asset
deploy step.
## The template renderer — get this right first, it's where the real bug lives
**The wrong way (looks fine until you have 2+ pages):** parsing every
template file together with `template.ParseGlob("web/templates/*.html")`
into one shared template set. Every page file typically defines a block
named the same thing (`{{define "content"}}` or similar) to plug into the
layout. When they're all parsed into one shared namespace, **the last
one parsed silently wins for every page** — page A renders page B's
content, or a block from one page bleeds into another. This is subtle:
it can look correct for a single page and only breaks once a second page
exists.
**The right way:** parse the base layout + exactly one page file into a
**fresh template instance per page**, so no other page's `{{define}}`
blocks are ever in the same namespace.
```go
package render
import (
"bytes"
"embed"
"fmt"
"html/template"
"net/http"
)
type Renderer struct {
templates map[string]*template.Template
}
// pages lists every page template that extends base.html.
var pages = []string{"login.html", "app.html", "admin.html"}
func New(fsys embed.FS) (*Renderer, error) {
r := &Renderer{templates: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
// Fresh template.New("base") EACH iteration — this is the whole fix.
t, err := template.New("base").ParseFS(fsys, "web/templates/base.html", "web/templates/"+page)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", page, err)
}
name := page[:len(page)-len(".html")]
r.templates[name] = t
}
return r, nil
}
// Render executes "base" (the layout), which pulls in this page's block
// overrides — rendered to a buffer first so a mid-execution template error
// never sends a half-written response to the client.
func (r *Renderer) Render(w http.ResponseWriter, status int, name string, data any) {
t, ok := r.templates[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
```
Construct one `*Renderer` at startup (fails fast on any template syntax
error) and pass it to every handler — don't reparse per-request.
## base.html — the Flask-`{% block %}` equivalent
Go's `html/template` has `{{block "name" .}}...default...{{end}}`
(defines a block with a default body, overridable) and `{{define "name"}}`
(overrides it from another file) — this is structurally the same idea as
Jinja2's `{% block %}` / `{% extends %}` + child-template overrides, just
different syntax.
```html
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}App{{end}}</title>
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetVersion}}">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/app.js?v={{.AssetVersion}}"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}
```
A page file overrides only the blocks it needs:
```html
{{define "title"}}Sign in{{end}}
{{define "body_class"}}login-page{{end}}
{{define "body"}}
<div class="login-card">
<h1>Sign in</h1>
<form id="login-form">...</form>
</div>
{{end}}
```
`.AssetVersion` (a build timestamp or git short-hash passed into every
`Render` call's data) matters more than it looks — see cache-busting below.
## Static assets: embed + version query param, always
```go
staticFS, _ := fs.Sub(webFS, "web/static")
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
```
Browsers aggressively cache `/static/*` files. During active development
this reliably causes "I edited the CSS/JS and nothing changed" confusion.
Fix it at the template level, not by fighting cache headers: put a
version string in every static asset URL (`app.css?v=<version>`) and bump
it whenever the file changes — a build timestamp baked in at compile time
via `-ldflags "-X main.buildVersion=..."` works well and needs no manual
bumping. For endpoints returning dynamic content that must never be
stale even mid-session (an edited file's raw content, a just-changed
API response), also set explicit headers: `Cache-Control: no-cache` for
GETs that should always revalidate, `Cache-Control: no-store` for
mutating POSTs.
## Dark Tailwind theme — CSS custom properties, not hardcoded hex everywhere
Use Tailwind's CDN build (`<script src="https://cdn.tailwindcss.com">`)
for utility classes, but define the actual palette as CSS custom
properties once, referenced from both Tailwind's arbitrary-value syntax
and any hand-written CSS — changing the theme later means editing one
`:root` block, not hunting hex codes through every template.
A palette that's worked well across real dark-themed Go apps:
```css
:root{
--bg:#0d0f14; --surface:#111318; --surface2:#161920; --surface3:#1c1f28;
--border:#1e2330; --border2:#252a38;
--text:#dde1ed; --text2:#9aa0b8; --muted:#5a6278;
--accent:#5b8def; --accent-dim:rgba(91,141,239,.12); --accent-glow:rgba(91,141,239,.2);
--danger:#ef4444; --success:#22c55e; --warn:#f59e0b;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif}
```
`--surface` / `--surface2` / `--surface3` give you three stacked-panel
depths (page background → card → nested card) without inventing new
grays each time. `--accent-dim` / `--accent-glow` are for hover/active
states and focus rings — a translucent version of the accent color reads
as "highlighted" without needing a second color.
Common components worth having a convention for from the start: a CSS
spinner (border-based, no image/SVG dependency), a toast notification
container (`position:fixed`, stacked, auto-dismiss), and a context-menu
pattern (`position:fixed`, toggled via a `.open` class) — all three show
up in nearly every admin/dashboard/file-manager style app.
## JavaScript: vanilla, but watch function scoping
No framework, no bundler — plain `<script>` tags, functions attached via
`onclick="..."` or `addEventListener`. The one recurring bug: **don't
declare a function inside a conditional block** if anything outside that
block (including inline `onclick` handlers in the HTML) needs to call it:
```js
// WRONG — startUpload is undefined outside this if-block's scope
if (ALLOW_UPLOAD) {
function startUpload() { ... }
}
// RIGHT — always declared, gate the *behavior* inside instead
function startUpload() {
if (!ALLOW_UPLOAD) return;
...
}
```
Fetch calls follow one plain convention: JSON in, JSON out, `X-CSRF-Token`
header on every mutating request (see below), and a shared `api()` helper
that throws on non-2xx so callers can `try/catch` uniformly instead of
checking `res.ok` everywhere by hand.
## Security basics worth building in from the start, not bolting on later
- **CSRF (double-submit cookie)**: generate a random token
(`crypto/rand`), set it as a `SameSite=Strict` cookie, and require the
same value echoed back in an `X-CSRF-Token` header on every mutating
request; reject if they don't match. No session-store dependency needed.
- **Path traversal**: for any handler that takes a path from the client
(file browser, static content by name), resolve it with
`filepath.EvalSymlinks` and verify the result still has the intended
root directory as a prefix before touching the filesystem.
- **Atomic writes**: write to a temp file in the same directory, then
`os.Rename` over the target — never write in place, so a crash
mid-write can't corrupt existing content.
- **CSP header**: set a `Content-Security-Policy` on HTML responses even
in a no-framework app — it's one header, not a dependency.
## What "no third-party dependencies" means in practice
Pure stdlib covers further than people expect for this kind of app:
`net/http` (routing via `http.ServeMux` — Go 1.22+'s method+pattern
matching like `mux.HandleFunc("POST /api/users", ...)` removes most of
the reason people reach for a router library), `html/template`,
`encoding/json`, `database/sql` (+ a driver — `mattn/go-sqlite3` or
similar is the one common exception, since Go has no built-in SQL
driver), `crypto/rand` for tokens, `embed` for shipping assets in the
binary. Reach for a third-party package only when the standard library
has no answer at all (a specific DB driver, CGO-only bindings) — not for
convenience on things stdlib already does.
Binary file not shown.
+199
View File
@@ -0,0 +1,199 @@
---
name: iterative-build-discipline
description: Practices for building large, multi-phase software projects (servers, protocol implementations, hand-rolled clients, multi-session builds) where correctness and honest progress tracking matter more than speed. Use this whenever a project spans multiple sessions or phases, involves implementing a network protocol or parser from scratch, needs to prove correctness without access to the real external service it talks to, or risks losing state between sessions (sandbox resets, context limits, long-running builds). Also use when maintaining living planning/handover documents across many edits, or when deciding whether to test something before believing it works. Trigger this proactively for phased builds even if the user doesn't ask for "best practices" — the value is in not needing to be asked.
---
# Iterative Build Discipline
Distilled from building GoMail, a ~14,000-line self-hosted email server
implemented phase-by-phase across many sessions (SMTP/IMAP/JMAP/CalDAV/
ManageSieve/OAuth2/ACME, all hand-rolled on stdlib). The patterns below
caught real bugs and prevented real losses, repeatedly, across nearly
every phase. This isn't theoretical advice — every rule here has a
"here's the actual bug this caught" behind it.
## Core principle
**"It compiles" and "the happy path returned 200" are not evidence of
correctness.** Every phase of GoMail had at least one real bug that a
build-only check would have missed entirely and shipped silently. The
single highest-leverage habit is: after writing a chunk of new
functionality, write a real test that exercises it as a real client
would, including the paths where it should fail. Do this before
declaring the phase done, not as an afterthought.
## Testing philosophy
### Write disposable, real end-to-end tests — not unit-test theater
For each meaningful chunk of work, write a throwaway test program that:
1. Boots the real server(s) as goroutines/processes in the same test run
2. Acts as a **real client** over the **real wire protocol** — raw TCP for
custom protocols, real `net/http` for HTTP, not an in-process function
call that skips the actual serialization/parsing
3. Asserts against real downstream state (a database row, a file on disk),
not just a status code
4. Covers negative paths deliberately: wrong auth, malformed input,
cross-user/cross-tenant access attempts, replay of a one-time token,
unreachable dependency
5. Gets deleted once it passes — it was scaffolding, not the deliverable
This pattern found a real bug in nearly every phase of a 15-phase project:
a NULL-scan panic from a naively-wrapped nullable column, a boolean check
that covered only one of two equivalent session kinds (silently dropping
an entire code path for one server mode), a regex missing the `(?s)`
DOTALL flag that made an entire class of multi-line responses parse as
empty, a wire command sent with the wrong syntax that "worked" by
returning an empty result instead of an error, a struct with no JSON tags
that would have silently shipped `TotalCount` instead of `total_count` on
the wire, a SQLite connection-pool deadlock from calling `Query` then
`Exec` in the same function without closing `rows` first, and a fake
protocol test server whose oversized buffered `Read()` silently over-read
into subsequent protocol bytes and desynced its own parser (looked
exactly like a real product bug until traced).
**None of these would have been caught by "the code compiles and looks
right."** All of them were caught by writing a client that actually spoke
the protocol and checking what actually happened downstream.
### When you can't test against the real external service, build a fake
one that actually enforces the protocol — not a rubber stamp
For OAuth2, ACME, ClamAV, rspamd, and an LLM server, there was no real
instance available in the build environment (no real Google/Microsoft app
credentials, no public domain to satisfy Let's Encrypt's HTTP-01
validator, no ClamAV/rspamd/llama.cpp installed). The fix was never to
skip testing — it was to build a **from-scratch fake server that
genuinely implements the documented protocol**, then prove the real
client code against it:
- A fake ACME server that does real ES256 JWS signature verification and
real RFC 7638 JWK thumbprint recomputation, and only issues a
certificate if a real HTTP callback to the challenge responder actually
succeeds — not a server that just returns "OK" to whatever it's sent.
- A fake ClamAV that reassembles the streamed chunks and inspects the
bytes for the real EICAR test signature, rather than returning a canned
"clean" response regardless of input.
- A fake OAuth2 authorization server implementing the real
authorization-code + refresh-token grant flows, including one-time-use
codes and CSRF state validation.
This is real verification of protocol correctness — not a substitute for
eventually testing against the real service, but a much stronger check
than "it compiles" and often catches the exact class of bug that would
otherwise only surface against production. **Explicitly flag, in both
code comments and any handoff docs, that this is fake-server verification
and that live verification against the real service is still an open
item** — don't let a passing fake-server test quietly become "this is
proven to work."
### When a real published test vector exists, use it
For anything implementing a spec with official test vectors (RFC 6238's
TOTP Appendix B, for instance), verify against the actual published
numbers rather than only checking internal self-consistency. This is a
strictly stronger correctness check: internal self-consistency can pass
while the implementation is confidently wrong in a way that happens to be
symmetric; matching an external published vector rules that out.
### A negative test is not optional polish
Auth-failure paths, permission-boundary checks, tampered-input rejection,
and replay-prevention are not "nice to have if there's time" — in this
project, testing them found real security-relevant gaps (a privilege
escalation attempt that wasn't actually blocked until tested, a CSRF
state that could be replayed until a check was added, a signature
verification path that would have accepted a forged input). Budget time
for these as part of "done," not after it.
## Session and state continuity
### Checkpoint working state as an artifact, not just as conversation history
In any environment where the working directory or container can reset
(sandbox limits, context window limits, session boundaries), the actual
recovery mechanism is a **packaged artifact saved outside the working
directory** — a tarball, a committed file, whatever the environment's
durable output channel is. Do this after every meaningful increment, not
just at the end. Conversation history describing what you did is not a
substitute for the file existing somewhere durable.
### Before editing a living document, verify its current state — don't
assume your last edit landed
Long sessions with many sequential edits to the same planning/handover
document are exactly where partial failures compound silently: an
aborted tool call, a `str_replace` that matched the wrong location, or an
edit applied against stale context can leave a document with duplicated
or contradictory sections (a "done" entry sitting right next to a stale
"not started" stub for the same item). Before trusting that a previous
edit succeeded, check: grep for the section header count (should be
exactly 1), read the actual current content of the section you're about
to touch, and only then edit. After editing, verify again — don't chain
edits on documents based on what you intended to have written rather than
what's actually there.
### Keep a living plan document AND a separate onboarding/handover document
The plan document is the source of truth for **what's done, what's
tested, what's deferred and why** — organized by phase/milestone, updated
every time scope changes. The handover document is written for **a fresh
reader with zero context** — environment setup that differs between the
build environment and a real deployment target, decisions not to
re-litigate, and a prioritized "what to do next" list. These serve
different readers and get stale in different ways; don't collapse them
into one document, but do keep them in sync with each other (a completed
phase should be reflected in both).
## Scoping and honesty
### When a sub-feature is genuinely out of scope for the current pass, say so explicitly — don't half-build it
Real examples from this project: WebAuthn/passkeys were skipped entirely
because they require CBOR decoding, which isn't in Go's standard library
and is a large sub-project in its own right — attempting a partial
implementation would have produced something that looked done but wasn't
trustworthy. QR code rendering for TOTP setup was skipped in favor of the
plain-text provisioning URI (which every real authenticator app accepts
as manual entry) because real QR encoding is a separate, non-trivial
piece of work. In both cases, the deferral was written down explicitly —
in code comments and in the plan document — with the reason, rather than
silently leaving a stub or, worse, a fake implementation that doesn't
actually work.
### Prioritize by what actually reduces risk, not strictly by original task order
When a later-numbered piece of work would close a security gap that
affects everything already built (in this project: real TLS certificate
issuance, since every existing self-signed-cert fallback was a flagged
weakness), it's worth pulling that forward ahead of strict sequential
order — and saying so explicitly, with the reasoning, rather than
silently reordering.
### A fact stated in a handoff document should be checked, not assumed durable
Version numbers, "as of" dates, and "extract this tarball" instructions
written early in a long session go stale as the session continues. Before
finalizing a handoff document, re-read it as if you were the person
receiving it fresh — do the setup instructions still point at the right
artifact? Does the "current phase" claim match what's actually true now?
## Technical patterns worth remembering generically
- **Raw TCP protocol work (client or test server)**: never assume one
`Read()` call aligns with the sender's `Write()` call boundaries — TCP
doesn't preserve them. Use a `bufio.Reader` with exact-length
`io.ReadFull` reads for anything with a defined wire format (a command
prefix, a length-prefixed chunk), not a single oversized buffered
`Read()`.
- **A database connection pool capped to one connection** (common for
embedded databases like SQLite in a single-process server): never call
a second query/exec while an earlier `*sql.Rows` from the same
connection is still open — close it explicitly first, don't rely on a
deferred close if you need the connection again before the function
returns.
- **Wire-format structs**: always give them explicit serialization tags.
A struct with no tags will round-trip fine in same-language tests
(Go-to-Go) while silently breaking the actual wire contract expected by
any other client.
- **When verifying a hand-rolled crypto/signing implementation**, prefer
checking against a real published test vector over only checking
internal round-trip consistency, wherever the spec provides one.
## What this skill is not
This isn't a checklist to run through mechanically per line — it's a set
of habits earned from what actually went wrong across a large real
project. The common thread: **verify against reality (real protocol,
real published vectors, real negative paths, real current document
state) instead of trusting that code which compiles or looks right is
therefore correct.**
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "/home/haku/.local/bin/graphify hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "/home/haku/.local/bin/graphify hook-guard read"
}
]
}
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(go version *)",
"Bash(command -v staticcheck)",
"Bash(go build *)",
"Bash(go vet *)",
"Bash(go test *)"
]
}
}
+1
View File
@@ -0,0 +1 @@
tests/
+9
View File
@@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+253
View File
@@ -0,0 +1,253 @@
# GoMail — Handover Document
**For: continuing this project in Claude Code / Claude CLI on a real machine**
**As of: end of Phase 15 (all 15 phases in the plan now touched — see the action plan for exact per-phase status)**
If you're a fresh Claude instance reading this with no other context: this
document plus `gomail-action-plan-v4.md` (in the same delivery) are
everything you need. Read both before writing any code.
---
## What this project is
**GoMail** — a complete self-hosted email server in a single Go binary,
replacing postfix + dovecot + roundcube + spamassassin + radicale. Built
from scratch across 9 completed phases, dependency-minimal (stdlib-first —
almost everything is hand-rolled: SMTP, IMAP, POP3, JMAP, CalDAV/CardDAV,
JWT, DKIM, SPF, DMARC parsers/servers/clients, all written against Go's
standard library rather than third-party protocol libraries).
The person you're working with (their GitHub handle: `ghostersk`) wants a
production-grade, secure, self-hosted mail server they fully understand and
control — that's *why* everything is hand-rolled instead of using
`emersion/go-imap` or similar: fewer dependencies, fewer supply-chain
trust points, full auditability.
## Immediate first steps on a real machine
```bash
# 1. Extract whichever phase tarball is newest (gomail-phase15.tar.gz as of
# this handover — check /mnt/user-data/outputs or wherever it was saved)
tar xzf gomail-phase15.tar.gz
cd gomail
# 2. Use the CLEAN go.mod (no local replace directives — those were only
# needed in the sandbox's network-restricted environment)
# go.mod.production is already correct; if a go.mod with /tmp/ replace
# directives exists instead, replace it:
cp go.mod.production go.mod # only if go.mod still has replace directives
# 3. Normal Go workflow from here — real internet access means this just works
go mod tidy
go build ./cmd/gomail/
# 4. Confirm the Go version matches what's declared
go version # should be able to satisfy "go 1.25.6" in go.mod
```
If `go mod tidy` fails on anything, it's almost certainly because the
`go.mod` still has a stray `replace (...)` block from sandbox development —
delete that block entirely and re-run `go mod tidy`.
**For a real system install** (not just a local build), use `install.sh`
instead of running the binary by hand — it creates a dedicated system
user, sets up `/etc/gomail`/`/var/lib/gomail`/`/var/log/gomail` with
correct permissions, generates the required secrets, and installs
`gomail.service` (a systemd unit with real hardening directives —
verified with `systemd-analyze verify` in the sandbox this was built in).
`README.md` has the full quick-start plus a DNS setup section (MX, SPF,
DKIM, DMARC record examples) for whoever's actually standing this up.
## How to verify the current state actually works
Don't trust that phase N's code is correct just because it's here — the
sandbox proved things via disposable E2E test programs that get deleted
after passing (see "Testing pattern" in the action plan doc). Recreate that
habit:
```bash
# Generate a master key and JWT secret for local testing
go run ./cmd/gomail/ -gen-master-key # prints GOMAIL_MASTER_KEY=...
export GOMAIL_MASTER_KEY=<paste from above>
export GOMAIL_JWT_SECRET=$(openssl rand -hex 32)
export GOMAIL_ADMIN_INIT_PASSWORD="ChangeMe123!"
# First run auto-generates gomail.yaml — edit server.hostname to something
# real before testing against actual mail flow, or leave as-is for
# loopback-only smoke testing.
go run ./cmd/gomail/ -config /tmp/test-gomail.yaml -debug
```
Then write a throwaway `cmd/e2etestX/main.go` that dials the relevant
port(s) as a real client and asserts against real server responses **and**
real database state — this is the pattern that caught a genuine bug in
every single phase so far (see the bug list in the action plan). Delete the
test program once it passes; it's not meant to ship.
## What NOT to do
- **Don't add third-party protocol libraries** (no `emersion/go-imap`, no
`emersion/go-smtp`, no JWT libraries, no CalDAV/CardDAV libraries) unless
explicitly asked. The entire point of this project, per the person's
explicit instruction at the start, is minimal dependencies and full
control. The only allowed exceptions so far: `mattn/go-sqlite3` (CGO
SQLite driver — no way around needing *some* driver), `golang.org/x/crypto`
(bcrypt + hkdf — no stdlib equivalent), `google/uuid`, `gopkg.in/yaml.v3`.
- **Don't skip the negative-path tests.** "It compiles" and "the happy path
returned 200" are not sufficient — every phase's real bug was caught by
testing auth failures, cross-user access attempts, malformed input, or
literal/encoding edge cases.
- **Don't silently narrow scope.** If something from the plan turns out to
be too big for one pass (this happened repeatedly — DAV filtering, IMAP
IDLE, JMAP Email/set), say so explicitly in code comments and in your
response, the way every deferred item in the action plan is flagged. The
person values honesty about gaps over an illusion of completeness.
- **Don't regenerate work that already exists and passed its tests.**
Read the action plan's "what's built" table first. Phases 19 are done;
start at Phase 9.5 (ManageSieve, currently a full gap) or Phase 10
(OAuth2/Gmail/M365) per the person's stated priority.
## Design decisions you should NOT re-litigate
These were explicitly decided across a long planning conversation before
any code was written — don't re-ask or second-guess them:
1. **SQLite via `mattn/go-sqlite3` (CGO)**, not the pure-Go alternative —
chosen for performance/maturity. Postgres/MySQL support planned via
build tags (`-tags postgres`), not yet implemented.
2. **Message storage**: Maildir++ on disk, root path configurable, **every
message encrypted at rest** (AES-256-GCM, HKDF-derived per-record key
from a single master key — see `internal/crypto`). This applies to
messages, contacts, and calendar events uniformly.
3. **Multi-tenancy**: all tenants share the same ports; tenants/domains are
managed via the (not-yet-built) admin portal, not separate listeners.
4. **Auth**: strong password + MFA/passkey are the target for human login;
app passwords (multiple, named, optional expiry) are for mail clients.
Already fully working server-side (`app_passwords` table +
`auth.Authenticate`'s scope checking) — just needs a management UI.
5. **Addressbooks/calendars**: both per-user AND per-tenant (shared) scopes
supported — see `db.OwnerType` (`user` | `tenant`).
6. **CalDAV/CardDAV**: in scope, built (Phase 7), core operations only.
7. **Webmail**: pure Go `html`/JS served via `net/http` + Tailwind CDN, no
frontend build toolchain, no JS framework. Reference inspiration was
`github.com/ghostersk/gowebmail` (the person's own earlier project) for
UI patterns — dark theme, folder sidebar, message list/view, compose.
8. **Push notifications**: SSE only when the browser tab is open, no
WebPush. Already implemented in Phase 8 (`webmail.sseEvents`).
9. **POP3**: included, off by default, admin-toggled in config
(`pop3.enabled: false`). Built in Phase 5.
10. **OAuth**: self-hosted operators register their own Google/Microsoft
apps (Cloud Console / Azure AD), paste Client ID/Secret into config —
no shared GoMail-branded OAuth app. Config keys already exist
(`oauth.google`/`oauth.microsoft` in `config.go`), unused until Phase 10.
11. **JMAP external exposure**: off by default (`jmap.external_enabled:
false`), full spec compliance is still built either way since the
webmail's *internal* use needs it eventually. Currently JMAP is mounted
on the webmail's own address always (Phase 9); a second standalone
listener binds when the config toggle is on.
12. **Linked-account message caching** (for Gmail/M365 accounts once Phase
10 lands): time-based retention, default 90 days, user-adjustable
(days/months/years), operator ceiling via `max_cache_retention` in
config. Schema (`linked_accounts.cache_retention_days`) already exists.
13. **Go version**: real target is 1.25.6. The sandbox this was built in
could only get 1.22/1.23 via apt (no network path to the real Go
toolchain distribution) — this is a **sandbox limitation, not a
project decision**. On a real machine, just use 1.25.6 directly, no
workaround needed.
## Where the two-tier plan documents are
- `gomail-action-plan-v4.md` — the authoritative current-state doc:
phase-by-phase status, deferred items within completed phases, and full
detail on Phases 9.515 (not yet started). **Read this before writing
any code** — it tells you exactly what exists, what's deliberately
incomplete, and why.
- This document (`HANDOVER.md`) — onboarding for a fresh Claude
instance: environment setup, testing philosophy, decisions not to
re-litigate.
Earlier plan versions (v1 through v3, if you find them referenced anywhere)
are superseded — v4 is authoritative for current state; v3 is still useful
for the *reasoning* behind decisions that haven't changed (full Part
A/B/C/D/E breakdown of the original Stalwart-inspired design), but don't
treat its phase-completion claims as current.
## Suggested next session's scope
All 15 phases from the original plan have now been worked through — 13
reached a fully complete, tested state; Phases 10 and 11 are "partially"
or "core" complete with named remaining pieces. There is no phase left
that hasn't been touched. What's left is: verifying the protocol-correct
implementations against real external services, and finishing the
named remaining pieces within otherwise-complete phases.
**First thing next session — verify against real external services.**
Three separate pieces of this project were built and proven correct
against genuinely-behaving fake servers (not rubber-stamp mocks — real
protocol verification: real JWS signatures, real RFC 7638 thumbprints,
real EICAR byte-level detection, etc.) but never against the real thing,
because this sandbox has no public domain, no real OAuth app credentials,
and no real ClamAV/rspamd/llama.cpp installed:
- **OAuth2** (Phase 10): register a real Google Cloud OAuth app (or ask
the person for one) and link a real Gmail account end-to-end.
- **ACME** (Phase 13): run one live issuance against **Let's Encrypt
staging** (`https://acme-staging-v02.api.letsencrypt.org/directory` —
set via `tls.acme_directory_url`; never test against production first)
with a real domain you control.
- **ClamAV/Rspamd/LLM** (Phase 14): if the operator has any of these
installed, point `pipeline.clamav_socket` / `pipeline.rspamd_url` /
`pipeline.llm_url` at them and send one real EICAR-string test message.
**Remaining named pieces within already-complete phases** (full detail in
the action plan):
1. Phase 10: native Gmail/Graph API for calendar/contacts, webmail
account-switcher/unified-inbox UI, real userinfo lookup instead of the
`?email=` stand-in at OAuth callback time.
2. Phase 11: aliases CRUD, per-tenant pipeline settings UI, live log
viewer, and actually enforcing `admin_ip_allowlist` at the HTTP layer
(currently inert).
3. Phase 13: DANE, MTA-STS, TLS-RPT, and DNS-01 challenge support are all
fully unstarted, not just untested.
4. Phase 12: passkeys/WebAuthn are fully unstarted (needs CBOR decoding,
not in Go's stdlib — a sub-project on the scale of Phase 13's JWS/ACME
work), QR rendering for TOTP setup isn't built, no webmail SPA page yet.
5. Phase 14: Gmail API push / Microsoft Graph delta webhooks to replace
IMAP polling — not started. No admin UI toggle for ClamAV/Rspamd/LLM —
config-file only.
6. Phase 15: `staticcheck` and `shellcheck` couldn't run in this sandbox
(toolchain/network restrictions, not a decision) — run both on a real
machine, it's a quick win. No log rotation, metrics endpoint, or backup
tooling for the SQLite database / Maildir tree.
**Two bugs worth remembering as general patterns**, both caught by
actually testing rather than assuming code was correct:
- A protocol test's fake server hung its client with what looked exactly
like a real product bug — an i/o timeout. The actual cause: the fake
server read an initial command with a generic buffered `Read()` into an
oversized buffer, which silently over-read into the *next* protocol
bytes (TCP doesn't preserve write-call boundaries), desyncing the whole
parser downstream. Fixed with `bufio.Reader` + exact-length
`io.ReadFull`. Applies to any future raw-TCP protocol work, product or
test code alike.
- A function that called `db.Query` then `db.Exec` in the same call
genuinely deadlocked in production code (not a test), because this
database is capped to a single open SQLite connection
(`SetMaxOpenConns(1)`) and the `Query`'s `rows` weren't closed before
the `Exec` tried to grab that same connection. If you add a new
function mixing `Query` and `Exec`, close `rows` explicitly first —
don't rely on a deferred close.
Don't feel obligated to do any of this in the order listed if the person
asks for something else first — this is a suggestion based on stated
priority, not a hard requirement.
## One more thing
This was built through genuinely iterative, tested development — not
"write code, assume it works." Every phase had real bugs caught by real
testing, documented honestly rather than glossed over. If you find
yourself tempted to skip writing a test because "this part is simple" —
don't. The simplest-looking parts of this codebase (a regex missing a
DOTALL flag, a struct missing JSON tags, a boolean check covering only one
of two equivalent cases) are exactly where the real bugs were.
+177
View File
@@ -0,0 +1,177 @@
# GoMail
A complete self-hosted email server in a single Go binary — replacing
postfix + dovecot + roundcube + spamassassin + radicale with one process,
hand-rolled almost entirely on the Go standard library (SMTP, IMAP, POP3,
JMAP, CalDAV/CardDAV, ManageSieve, DKIM, SPF/DMARC verification, OAuth2,
ACME, JWT — all implemented from scratch, not wrapped around third-party
protocol libraries).
## What's included
- **Mail transport**: SMTP (inbound MTA + authenticated submission), full
security pipeline (SPF, DKIM, DMARC, header/URL heuristics, optional
ClamAV/Rspamd/LLM stages), outbound queue with retry and bounce handling
- **Mail access**: IMAP4rev1, POP3 (off by default), JMAP (RFC 8620/8621
subset)
- **Filtering**: Sieve scripts (RFC 5228 subset) managed via ManageSieve
(RFC 5804)
- **Calendar & contacts**: CalDAV/CardDAV, per-user and per-tenant
- **Webmail**: built-in dark-themed web client — folders, compose, search,
quarantine review, MFA/app-password/recovery settings
- **Admin portal**: tenants, domains (with DKIM key generation/rotation),
users, list rules, outbound queue management, global quarantine
- **Multi-account**: link external Gmail/Microsoft 365/generic IMAP
accounts via OAuth2
- **Security**: TOTP MFA with backup codes, app passwords, TLS via real
ACME (Let's Encrypt-compatible) certificate issuance and auto-renewal,
per-IP rate limiting on every listener
- **Everything encrypted at rest**: messages, contacts, calendar events,
DKIM/TLS/OAuth2 credentials — AES-256-GCM with a per-record key derived
via HKDF from a single master key
See `gomail-action-plan-v4.md` (if included alongside this README) for a
detailed phase-by-phase account of what's built, what's tested, and what's
intentionally deferred.
## Quick start
```bash
# 1. Generate a master key (required — GoMail refuses to start without one)
gomail -gen-master-key
# prints: GOMAIL_MASTER_KEY=<64 hex chars>
# 2. Set required environment variables
export GOMAIL_MASTER_KEY=<paste from step 1>
export GOMAIL_JWT_SECRET=$(openssl rand -hex 32)
export GOMAIL_ADMIN_INIT_PASSWORD='choose-a-strong-password'
# 3. First run auto-generates a default config at the path you specify
gomail -config /etc/gomail/gomail.yaml
# 4. Edit /etc/gomail/gomail.yaml before running for real:
# - server.hostname: your actual mail server hostname
# - tls.mode: "acme" with tls.acme_domains set (see below), or "file"
# - tls.acme_email: an address you actually monitor (Let's Encrypt
# account contact, used for expiry warnings)
```
For a full system install (dedicated user, systemd unit, directory
permissions), use `install.sh` instead of running the binary directly —
see that script's header comment for exactly what it does and doesn't do.
## TLS / ACME
GoMail obtains real Let's Encrypt-compatible certificates automatically
when `tls.mode: acme` and `tls.acme_domains` are set:
```yaml
tls:
mode: acme
acme_email: postmaster@yourdomain.com
acme_domains: ["mail.yourdomain.com"]
# acme_directory_url defaults to real Let's Encrypt production —
# override to Let's Encrypt staging while testing (see below)
```
This requires port 80 to be reachable from the internet for HTTP-01
challenge validation — GoMail runs its own minimal HTTP server on `:80`
for this, it doesn't need a separate web server in front of it for the
challenge itself.
**Before pointing this at Let's Encrypt production, test against
staging** — set `tls.acme_directory_url` to
`https://acme-staging-v02.api.letsencrypt.org/directory` — to avoid
hitting production rate limits while testing. Staging certificates aren't
trusted by real browsers/clients, so switch back to the production
directory (or just remove the override) once you've confirmed issuance
works.
Without `tls.acme_domains` set, GoMail falls back to a self-signed
certificate — fine for local testing, **not suitable for production**.
## DNS setup
Once GoMail is running, publish these DNS records for `yourdomain.com`
(adjust names/values to match your actual domain and what GoMail logs at
bootstrap — the exact DKIM public key is generated per-installation and
logged once at first startup, and also viewable via the admin portal's
Domains page).
### MX record
Point mail delivery at your GoMail server:
| Type | Name | Value | Priority |
|---|---|---|---|
| MX | `yourdomain.com` | `mail.yourdomain.com` | 10 |
Plus an A/AAAA record for `mail.yourdomain.com` pointing at the server's
IP.
### SPF record
Authorizes your server to send mail for the domain:
| Type | Name | Value |
|---|---|---|
| TXT | `yourdomain.com` | `v=spf1 mx ~all` |
`mx` covers "any host listed in this domain's MX records may send mail" —
sufficient for a single GoMail instance handling its own outbound
delivery. `~all` (soft fail) is a reasonable starting point; tighten to
`-all` (hard fail) once you're confident no other source legitimately
sends mail as this domain.
### DKIM record
GoMail generates a DKIM key pair automatically for each domain at
bootstrap (or when created via the admin portal) and logs the exact TXT
record to publish — it looks like this:
| Type | Name | Value |
|---|---|---|
| TXT | `mail._domainkey.yourdomain.com` | `v=DKIM1; k=rsa; p=<base64 public key>` |
The selector (`mail` by default) and the actual key value are
domain-specific — copy them from the startup log or the admin portal
rather than this example. Rotating the key (also available in the admin
portal) requires updating this record to match the new key.
### DMARC record
Tells receiving servers what to do with mail that fails SPF/DKIM
alignment, and where to send aggregate reports:
| Type | Name | Value |
|---|---|---|
| TXT | `_dmarc.yourdomain.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@yourdomain.com` |
Start with `p=quarantine` (suspicious mail gets flagged, not silently
dropped) rather than `p=reject` until you've confirmed legitimate mail
flows aren't being caught by DMARC alignment failures.
### MTA-STS and TLS-RPT
**Not yet implemented** by GoMail (see the action plan's Phase 13
deferred-items list) — no records to publish for these yet. This section
will be filled in once that work lands.
## Configuration reference
Full config lives in `gomail.yaml` (auto-generated with defaults on first
run) plus a small number of required environment variables for secrets
(`GOMAIL_MASTER_KEY`, `GOMAIL_JWT_SECRET`, optionally
`GOMAIL_ADMIN_INIT_PASSWORD`) — secrets are deliberately never stored in
the YAML file itself. See `internal/config/config.go` for the full set of
available fields and their defaults; every field has a sensible default
except the two required secrets above.
## Building from source
```bash
go build -o gomail ./cmd/gomail
```
Requires CGO (for `mattn/go-sqlite3`) — a working C compiler must be
available. No other build-time dependencies beyond what `go mod
download` fetches.
## License
(Not yet specified — add your chosen license here before public release.)
+382
View File
@@ -0,0 +1,382 @@
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gomail/internal/acme"
"gomail/internal/admin"
"gomail/internal/config"
"gomail/internal/crypto"
"gomail/internal/dav"
"gomail/internal/db"
"gomail/internal/imap"
"gomail/internal/jmap"
"gomail/internal/mailstore"
"gomail/internal/managesieve"
"gomail/internal/oauth2"
"gomail/internal/pipeline"
"gomail/internal/ratelimit"
"gomail/internal/pop3"
"gomail/internal/queue"
"gomail/internal/smtp"
"gomail/internal/tlsutil"
"gomail/internal/webmail"
)
var version = "1.0.0-phase15"
// ipAllowlistMiddleware rejects any request whose client IP (resolved the
// same way ratelimit.HTTPMiddleware does) isn't in allowlist. An empty
// allowlist is a no-op (matches this project's convention elsewhere of an
// unset/zero config value meaning "don't restrict" — see
// ratelimit.New's rate=0 handling).
func ipAllowlistMiddleware(allowlist []string, realIPHeader string, next http.Handler) http.Handler {
if len(allowlist) == 0 {
return next
}
allowed := make(map[string]bool, len(allowlist))
for _, ip := range allowlist {
allowed[ip] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !allowed[ratelimit.ClientIP(r, realIPHeader)] {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
var (
cfgPath = flag.String("config", "gomail.yaml", "path to config file")
showVersion = flag.Bool("version", false, "print version and exit")
genKey = flag.Bool("gen-master-key", false, "generate a new master key and exit")
debug = flag.Bool("debug", false, "enable debug logging")
)
flag.Parse()
if *showVersion {
fmt.Printf("gomail %s\n", version)
os.Exit(0)
}
if *genKey {
key, err := crypto.GenerateMasterKeyHex()
if err != nil {
fmt.Fprintf(os.Stderr, "error generating key: %v\n", err)
os.Exit(1)
}
fmt.Printf("GOMAIL_MASTER_KEY=%s\n", key)
os.Exit(0)
}
logLevel := slog.LevelInfo
if *debug {
logLevel = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})))
slog.Info("gomail starting", "version", version)
cfg, err := config.Load(*cfgPath)
if err != nil {
slog.Error("config error", "err", err)
fmt.Fprintln(os.Stderr, "\nHint: generate a master key with: gomail -gen-master-key")
os.Exit(1)
}
slog.Info("config loaded", "hostname", cfg.Server.Hostname, "db_driver", cfg.Database.Driver)
mk, err := crypto.LoadMasterKey(cfg.Security.MasterKey, cfg.Security.MasterKeyPrev)
if err != nil {
slog.Error("master key error", "err", err)
os.Exit(1)
}
slog.Info("master key loaded", "rotation_active", mk.Previous != nil)
database, err := db.Open(cfg.Database.Driver, cfg.Database.DSN)
if err != nil {
slog.Error("database open failed", "err", err)
os.Exit(1)
}
defer database.Close()
if err := database.Migrate(); err != nil {
slog.Error("migration failed", "err", err)
os.Exit(1)
}
if err := database.Bootstrap(cfg.Server.Hostname, cfg.Security.AdminInitPassword, cfg.Security.BcryptCost, mk); err != nil {
slog.Error("bootstrap failed", "err", err)
os.Exit(1)
}
store := mailstore.New(cfg.Storage.MaildirRoot, mk, database)
var tlsConf *tls.Config
var acmeManager *tlsutil.ACMEManager
var acmeChallengeServer *http.Server
if cfg.TLS.Mode == "acme" && len(cfg.TLS.ACMEDomains) > 0 {
// Real ACME issuance — the challenge responder needs to be reachable
// on plain :80 for the CA's HTTP-01 validator to connect to, exactly
// as it would for a real Let's Encrypt issuance.
responder := acme.NewChallengeResponder()
challengeMux := http.NewServeMux()
challengeMux.Handle("/.well-known/acme-challenge/", responder)
acmeChallengeServer = &http.Server{Addr: ":80", Handler: challengeMux}
go func() {
if err := acmeChallengeServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("ACME challenge responder failed to start on :80 — HTTP-01 issuance will fail until this is fixed", "err", err)
}
}()
acmeManager = tlsutil.NewACMEManager(database, mk, cfg.TLS.ACMEDirectoryURL, cfg.TLS.ACMEEmail, responder)
tlsConf = acmeManager.TLSConfig()
slog.Info("TLS: real ACME issuance configured", "directory", cfg.TLS.ACMEDirectoryURL, "domains", cfg.TLS.ACMEDomains)
} else {
var err error
tlsConf, err = tlsutil.LoadOrGenerate(
cfg.TLS.Mode, cfg.Server.Hostname, cfg.TLS.CertFile, cfg.TLS.KeyFile,
tlsutil.ParseMinVersion(cfg.TLS.MinVersion),
)
if err != nil {
slog.Error("TLS setup failed", "err", err)
os.Exit(1)
}
}
orch := pipeline.NewOrchestrator(cfg, pipeline.StagesFromConfig(cfg))
if cfg.Pipeline.ClamAVSocket != "" {
slog.Info("pipeline: ClamAV stage enabled", "addr", cfg.Pipeline.ClamAVSocket)
}
if cfg.Pipeline.RspamdURL != "" {
slog.Info("pipeline: Rspamd stage enabled", "url", cfg.Pipeline.RspamdURL)
}
if cfg.Pipeline.LLMURL != "" {
slog.Info("pipeline: LLM stage enabled", "url", cfg.Pipeline.LLMURL, "model", cfg.Pipeline.LLMModel)
}
smtpServer := smtp.NewServer(cfg, database, store, tlsConf, orch)
// Outbound queue worker — MX delivery with DKIM signing per sending domain.
// 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}).
WithKeyLookup(
func(fromDomain string) ([]byte, string, bool) {
dom, err := database.LookupDomainByName(fromDomain)
if err != nil || dom == nil || len(dom.DKIMPrivateKeyEnc) == 0 {
return nil, "", false
}
plainKey, err := crypto.Decrypt(mk, dom.ID, "dkim-key", dom.DKIMPrivateKeyEnc)
if err != nil {
slog.Warn("failed to decrypt DKIM key, sending unsigned", "domain", fromDomain, "err", err)
return nil, "", false
}
return plainKey, dom.DKIMSelector, true
},
)
ctx, cancel := context.WithCancel(context.Background())
go func() {
if err := smtpServer.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
slog.Error("SMTP server error", "err", err)
os.Exit(1)
}
}()
go queueWorker.Run()
if acmeManager != nil {
renewalDomains := append([]string{}, cfg.TLS.ACMEDomains...)
go acmeManager.StartRenewalLoop(ctx, renewalDomains, 24*time.Hour)
slog.Info("ACME renewal loop started", "check_interval", "24h", "domains", renewalDomains)
}
imapServer := imap.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.IMAPConnPerMin, cfg.RateLimits.IMAPAuthFailures)
go func() {
if err := imapServer.ListenAndServe(ctx, cfg.Server.IMAPAddr, cfg.Server.IMAPSAddr); err != nil && ctx.Err() == nil {
slog.Error("IMAP server error", "err", err)
os.Exit(1)
}
}()
var pop3Server *pop3.Server
if cfg.POP3.Enabled {
pop3Server = pop3.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.POP3AuthFailures)
go func() {
if err := pop3Server.ListenAndServe(ctx, cfg.POP3.POP3Addr, cfg.POP3.POP3SAddr); err != nil && ctx.Err() == nil {
slog.Error("POP3 server error", "err", err)
os.Exit(1)
}
}()
slog.Info("POP3 enabled", "addr", cfg.POP3.POP3Addr, "tls_addr", cfg.POP3.POP3SAddr)
} else {
slog.Info("POP3 disabled (pop3.enabled: false in config)")
}
sieveServer := managesieve.NewServer(database, tlsConf, cfg.Server.Hostname)
go func() {
if err := sieveServer.ListenAndServe(ctx, cfg.Server.ManageSieveAddr); err != nil && ctx.Err() == nil {
slog.Error("ManageSieve server error", "err", err)
os.Exit(1)
}
}()
slog.Info("ManageSieve listening", "addr", cfg.Server.ManageSieveAddr)
// Shared across every HTTP-based listener (DAV, webmail, admin, JMAP) —
// one limiter, one per-IP budget across all of them combined, since
// they're all reachable by the same set of clients and a client hitting
// its limit on one shouldn't get a fresh budget by switching to another.
httpLimiter := ratelimit.New(cfg.RateLimits.HTTPReqPerMin)
davHandler := dav.NewHandler(database, mk)
davMux := http.NewServeMux()
davMux.Handle("/dav/", davHandler)
davServer := &http.Server{Addr: cfg.Server.DAVAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, davMux), TLSConfig: tlsConf}
go func() {
var err error
if tlsConf != nil {
err = davServer.ListenAndServeTLS("", "")
} else {
err = davServer.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("DAV server error", "err", err)
os.Exit(1)
}
}()
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
oauthConfigs := buildOAuthConfigs(cfg)
webmailHandler := webmail.NewHandler(database, store, mk, cfg.Security.JWTSecret, oauthConfigs)
webmailMux := http.NewServeMux()
webmailHandler.RegisterRoutes(webmailMux)
// JMAP is always mounted on the webmail address (internal use — the
// webmail SPA itself still calls the Phase 8 REST API, not this, but
// JMAP is independently available here for testing/tooling). Per
// config, it's ADDITIONALLY exposed on its own external address when
// jmap.external_enabled is true — same handler, just a second listener,
// matching the plan's "toggle changes binding, not implementation".
jmapHandler := jmap.NewHandler(database, store, cfg.Server.Hostname)
jmapHandler.RegisterRoutes(webmailMux)
staticFS, _ := fs.Sub(webmail.StaticFS, "static")
webmailMux.Handle("/", http.FileServer(http.FS(staticFS)))
webmailServer := &http.Server{Addr: cfg.Server.WebmailAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, webmailMux)}
go func() {
if err := webmailServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("webmail server error", "err", err)
os.Exit(1)
}
}()
slog.Info("webmail listening", "addr", cfg.Server.WebmailAddr)
slog.Info("JMAP listening (internal, mounted on webmail address)", "session_url", cfg.Server.WebmailAddr+"/.well-known/jmap")
var jmapExternalServer *http.Server
if cfg.JMAP.ExternalEnabled {
jmapMux := http.NewServeMux()
jmapHandler.RegisterRoutes(jmapMux)
jmapExternalServer = &http.Server{Addr: cfg.JMAP.ExternalAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, jmapMux)}
go func() {
if err := jmapExternalServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("external JMAP server error", "err", err)
}
}()
slog.Info("JMAP externally exposed", "addr", cfg.JMAP.ExternalAddr)
} else {
slog.Info("JMAP external exposure disabled (jmap.external_enabled: false in config)")
}
adminHandler := admin.NewHandler(database, mk, cfg.Security.JWTSecret)
adminMux := http.NewServeMux()
adminHandler.RegisterRoutes(adminMux)
adminStaticFS, _ := fs.Sub(admin.StaticFS, "static")
adminMux.Handle("/", http.FileServer(http.FS(adminStaticFS)))
adminHTTPHandler := httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, adminMux)
adminHTTPHandler = ipAllowlistMiddleware(cfg.Server.AdminIPAllowlist, cfg.Server.RealIPHeader, adminHTTPHandler)
adminServer := &http.Server{Addr: cfg.Server.AdminAddr, Handler: adminHTTPHandler}
go func() {
if err := adminServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
slog.Error("admin server error", "err", err)
os.Exit(1)
}
}()
slog.Info("admin portal listening", "addr", cfg.Server.AdminAddr)
slog.Info("gomail is running",
"smtp", cfg.Server.SMTPAddr,
"submission", cfg.Server.SubmissionAddr,
"smtps", cfg.Server.SMTPSAddr,
"imap", cfg.Server.IMAPAddr,
"imaps", cfg.Server.IMAPSAddr)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
slog.Info("shutdown signal received", "signal", sig)
cancel()
queueWorker.Stop()
smtpServer.Shutdown(30 * time.Second)
imapServer.Shutdown(30 * time.Second)
if pop3Server != nil {
pop3Server.Shutdown(30 * time.Second)
}
davServer.Close()
webmailServer.Close()
if jmapExternalServer != nil {
jmapExternalServer.Close()
}
sieveServer.Shutdown(30 * time.Second)
adminServer.Close()
if acmeChallengeServer != nil {
acmeChallengeServer.Close()
}
slog.Info("shutdown complete")
}
// buildOAuthConfigs turns the operator's configured Client ID/Secret pairs
// into ready-to-use oauth2.Config values, keyed by provider name. A
// provider absent from the map (or with Enabled: false) means webmail's
// "Add Google/Microsoft Account" simply isn't offered — matching the
// plan's "self-hosted operators register their own app" decision; there is
// no GoMail-shared fallback.
func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
configs := map[string]*oauth2.Config{}
if cfg.OAuth.Google.Enabled {
authURL, tokenURL, err := oauth2.WellKnownEndpoints("google")
if err == nil {
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"},
}
}
}
if cfg.OAuth.Microsoft.Enabled {
authURL, tokenURL, err := oauth2.WellKnownEndpoints("microsoft")
if err == nil {
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"},
}
}
}
return configs
}
+10
View File
@@ -0,0 +1,10 @@
module gomail
go 1.26.4
require (
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.49
golang.org/x/crypto v0.54.0
gopkg.in/yaml.v3 v3.0.1
)
+10
View File
@@ -0,0 +1,10 @@
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=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+672
View File
@@ -0,0 +1,672 @@
# GoMail — Action Plan v4 (post-Phase 9)
**Status: Phases 19 complete and verified. Phases 1015 remain.**
This supersedes v3 for anything marked complete below. Where v3's design
held up under actual implementation, it's summarized, not repeated — see
v3 for full rationale on decisions that didn't change (encryption scheme,
POP3 off-by-default, OAuth self-registration, time-based linked-account
caching, etc.).
---
## What's built (Phases 19)
| Phase | Package(s) | What it does | Status |
|---|---|---|---|
| 1 | `config`, `crypto`, `db` | YAML config + env secrets, HKDF-per-record encryption, SQLite schema/migrations, bootstrap | ✅ verified |
| 2 | `smtp`, `mailstore`, `tlsutil` | Inbound MTA (:25/:587/:465), STARTTLS, SASL PLAIN/LOGIN, encrypted Maildir delivery | ✅ verified |
| 3 | `dkim`, `queue` | DKIM sign/verify (stdlib crypto only), outbound retry queue, MX delivery via `net/smtp`, bounce DSN | ✅ verified |
| 4 | `pipeline` | SPF/DKIM/DMARC/header/URL checks, quarantine storage, verdict scoring | ✅ verified against live DNS |
| 5 | `imap`, `pop3`, `auth` | Full IMAP core command set, POP3 (off by default), shared credential verification | ✅ verified |
| 6 | `accounts`, `imapclient` | `MailProvider` interface, local + generic-IMAP backends, hand-rolled IMAP client | ✅ verified (dogfoods own server) |
| 7 | `vcard`, `ical`, `dav` | CalDAV/CardDAV server (PROPFIND/REPORT/PUT/GET/DELETE), encrypted contacts/events | ✅ verified |
| 8 | `webtoken`, `webmail` | Hand-rolled JWT, REST API + dark Tailwind SPA (folders/messages/compose/quarantine) | ✅ verified |
| 9 | `jmap` | JMAP Core/Mail subset (session, Core/echo, Mailbox/get, Email/query, Email/get) | ✅ verified |
| 9.5 | `sieve`, `managesieve` | Sieve interpreter (RFC 5228 subset) + ManageSieve server (RFC 5804), wired into inbound delivery | ✅ verified |
| 10 | `oauth2`, extended `accounts`/`imapclient`/`webmail` | Hand-rolled OAuth2 (auth code grant + refresh), XOAUTH2 IMAP login, webmail account-linking endpoints (CSRF-protected) | ✅ verified against fake OAuth2 server + fake XOAUTH2 IMAP stub — see note below |
| 11 | `admin` | Admin portal: RBAC-gated REST API (global_admin/tenant_admin) + dark Tailwind SPA — tenants, domains (+DKIM keygen/rotation), users, list rules, outbound queue, global quarantine, dashboard stats | ✅ verified — full RBAC enforcement, real DKIM key generation/rotation checked cryptographically |
| 13 | `acme`, `tlsutil` (ACMEManager) | Real ACME v2 client (RFC 8555): JWS/ES256 signing, HTTP-01 challenge, SNI-aware cert serving, encrypted storage, auto-renewal loop. Pulled forward from phase order — see rationale below. | ✅ verified against a from-scratch fake ACME server with real JWS/thumbprint verification — see note below |
| 12 | `totp`, extended `webmail`/`db` | Auth hardening: TOTP MFA (RFC 6238) with two-step login, backup codes, app-password self-service UI, recovery-email password reset. Completed after Phase 13 since 13 was pulled forward — see Phase 13's entry above. | ✅ verified — TOTP checked against 5 real RFC 6238 published test vectors, full two-step login/backup-code/app-password/reset flows over real HTTP |
| 14 | extended `pipeline` (ClamAV/Rspamd/LLM stages) | Optional external security services: real clamd INSTREAM protocol, real rspamd `/checkv2` HTTP API, OpenAI-compatible LLM classification (llama.cpp server) — all off by default, only added to the pipeline when configured | ✅ verified — genuine EICAR malware detection (byte-level, not canned), real rspamd score pass-through, LLM score capping, graceful negative-path handling for unreachable services |
| 15 | `ratelimit`, fuzz tests, `install.sh`/`gomail.service`/`README.md` | Hardening + deploy: per-IP token-bucket rate limiting on every listener, fuzz tests on the 4 riskiest hand-written parsers, `go vet` pass, systemd install script + hardened unit, full README with DNS setup guide | ✅ verified — rate limiting proven under the race detector and over real sockets/HTTP, ~1.5M fuzz executions with zero panics, systemd unit verified with `systemd-analyze verify` |
**~14,400 lines of Go**, one third-party runtime dependency beyond
`mattn/go-sqlite3`/`golang.org/x/crypto`/`google/uuid`/`gopkg.in/yaml.v3`
everything else (SMTP, IMAP, POP3, JMAP, CalDAV/CardDAV, JWT, DKIM, SPF,
DMARC) is hand-rolled on the Go standard library, per the original
dependency-minimal requirement.
### Deferred within completed phases (honest gaps, not hidden)
- **DAV**: REPORT filter/time-range queries return the full collection, not
a filtered subset. MKCALENDAR/MKCOL not implemented — default collection
auto-created per user instead.
- **IMAP**: no IDLE, CONDSTORE/QRESYNC, SORT/THREAD, or mailbox
CREATE/DELETE/RENAME. Core command set only (see Phase 5 code comments).
- **JMAP**: Email/set (flags/delete via JMAP), Email/import (send via
JMAP), push (EventSource), and Mailbox/set are not implemented — the
webmail SPA still uses Phase 8's direct REST API for all mutations.
ManageSieve (RFC 5804) was paired with JMAP in the original plan and
**was not started this phase at all** — full gap, see Phase 9.5 below.
- **accounts.IMAPProvider.Move**: returns "not implemented" — needs IMAP
APPEND, which isn't in the imapclient package yet.
- **TLS**: self-signed cert generation only (`tlsutil`), no ACME. Every
linked IMAP/SMTP connection uses `InsecureSkipVerify: true` as a known,
commented gap — real cert trust (pinning or ACME) is Phase 13.
---
## Remaining phases
### Phase 9.5 — ManageSieve + Sieve interpreter — ✅ COMPLETE
`internal/sieve`: hand-written recursive-descent interpreter covering
`if`/`elsif`/`else`, `header :contains`/`:is` tests, `fileinto`/`discard`/
`keep`/`stop` actions. Verified: correct short-circuit on `stop`, correct
RFC 5228 §4.4 "explicit keep after discard still delivers" semantics,
malformed scripts rejected at parse time.
`internal/managesieve`: RFC 5804 server on `:4190` — CAPABILITY, STARTTLS
(auth refused before TLS, verified), AUTHENTICATE PLAIN, PUTSCRIPT (rejects
syntactically invalid scripts at upload time via `sieve.Parse`), GETSCRIPT,
LISTSCRIPTS, SETACTIVE (enforces exactly one active script per user),
DELETESCRIPT, LOGOUT.
Wired into `internal/smtp/session.go`'s local-delivery path: after the
security pipeline clears a message (clean/flagged), the recipient's active
Sieve script (if any) runs against the message headers and determines the
destination folder or discard — verified end-to-end with a real SMTP
delivery routed by an uploaded rule.
**Deferred within this phase**: only `header` tests are supported (no
`address`, `envelope`, `size`, `exists` tests); no `anyof`/`allof` boolean
combinators (only single-condition if/elsif); no Sieve extensions
(`vacation`, `reject`, `notify`, `imap4flags`); webmail visual rule builder
not built — raw script upload via the ManageSieve protocol is the only
interface (testable with `sieve-connect` or similar real clients).
### Phase 10 — Multi-account webmail: OAuth2 + Gmail/M365 — ✅ PARTIALLY COMPLETE
**What's done and verified:**
- `internal/oauth2`: hand-rolled OAuth2 authorization-code grant + refresh
(RFC 6749 §4.1, §6), ~150 lines, stdlib `net/http`+`encoding/json` only.
`WellKnownEndpoints("google"|"microsoft")` returns the real fixed
endpoint URLs — not operator-configurable, matching how real integrations
work (only Client ID/Secret are operator-supplied).
- `imapclient.LoginXOAUTH2`: SASL XOAUTH2 mechanism for connecting to
Gmail/M365 with a bearer token instead of a password.
- `accounts.IMAPProvider` extended: branches on `AuthType` — password
accounts unchanged, OAuth2 accounts decrypt a stored token, transparently
refresh it if expired (persisting the new token to avoid re-refreshing
on every call), and authenticate via XOAUTH2.
- `accounts.LinkOAuth2Account` / `wellKnownIMAPHost`: stores encrypted
OAuth2 tokens, points Gmail/M365 accounts at their real documented IMAP
hosts (`imap.gmail.com:993`, `outlook.office365.com:993`).
- `webmail` API: `/api/accounts` (list), `/api/accounts/{id}` (unlink),
`/api/accounts/oauth/{provider}/start` (builds auth URL, stores
CSRF state server-side keyed to the authenticated user),
`/api/accounts/oauth/{provider}/callback` (validates state — rejects
unknown AND replayed state, verified — exchanges code, links account).
- **Verified** with a from-scratch fake OAuth2 authorization server
(`/authorize` + `/token`, both grant types) and a fake XOAUTH2-accepting
IMAP stub — full round trip: start → redirect → callback → encrypted
token storage → real XOAUTH2 IMAP login → forced token expiry → transparent
refresh → persisted new token. This is real protocol-correctness
verification; it does **not** touch real Google/Microsoft infrastructure,
since no real app credentials exist in the dev environment this was
built in. **The next session should register a real Google Cloud OAuth
app (or ask the person for one) and do one live end-to-end test against
actual Gmail before considering this phase fully closed** — the protocol
layer is proven, but "does real Gmail's token endpoint actually behave
the way RFC 6749 says it should" has not been checked against the real
service.
**Deferred within this phase:**
- Native Gmail API / Microsoft Graph API providers (`GmailProvider`,
`M365Provider` as distinct from the IMAP+OAuth2 path) — per the locked
decision, mail goes through `IMAPProvider` with OAuth2 credentials for
now; native API push (Gmail watch+Pub/Sub, Graph delta webhooks) is
Phase 14 per the original plan.
- Calendar/contacts via native Graph API / Gmail Calendar+People API — not
started. No IMAP fallback exists for these, so this is still fully
greenfield work reusing the OAuth2 token store just built.
- Webmail UI: account switcher, unified "All Inboxes" view, per-account
"send as" in compose, federated search — the backend
(`/api/accounts`) exists but the SPA has no UI for any of this yet.
- Real userinfo/profile API call to learn the linked account's actual
email address at callback time — currently accepts an `?email=` query
param as a stand-in (noted in code); a real implementation calls
Google's `userinfo` endpoint or Microsoft Graph's `/me` with the fresh
access token.
- `IMAPProvider.Move` still returns "not implemented" (needs IMAP APPEND,
unchanged from Phase 6).
### Phase 11 — Admin portal — ✅ COMPLETE (core CRUD)
**What's done and verified:**
- `internal/admin`: REST API + embedded dark Tailwind SPA, JWT sessions
(same `webtoken` scheme as webmail), role checked at auth time AND
re-checked against the live DB row on every request (a demoted admin's
existing token stops working immediately, not just after expiry).
- **Tenants**: list/create, global_admin only (verified: tenant_admin gets
403).
- **Domains**: list/create/delete, real DKIM key generation on create
(verified: stored key decrypts and parses as a genuine RSA private key,
not just "a create call succeeded"), DKIM rotation (verified: key
material genuinely changes, not a no-op). global_admin defaults to their
own tenant if none specified; tenant_admin forced to their own tenant
regardless of what they send.
- **Users**: list/create/suspend/activate/reset-password/delete.
tenant_admin scoped to their own tenant and blocked from creating or
modifying admin-role accounts (verified: 403 on a privilege-escalation
attempt).
- **List rules**: list/create/delete, tenant-scoped.
- **Outbound queue**: list, retry-now (verified: `next_attempt_at`
genuinely rescheduled to immediate, not just a 200 response), cancel.
- **Global quarantine**: list, discard (verified: status genuinely
transitions to `deleted` in the DB). Per-user *release* (as opposed to
discard) already existed in webmail from Phase 8 — this is the
admin-wide review/cleanup view, not a duplicate of that.
- **Dashboard**: user/domain counts, 24h message count, queue depth,
quarantine-held count.
**Two real bugs caught by testing, both fixed:**
1. `global_admin` has no *forced* tenant (unlike `tenant_admin`), but every
domain still needs one — domain creation 400'd with "tenant_id is
required" until a fallback to the admin's own tenant was added.
2. User creation requires a valid `domain_id` (a mailbox must belong to a
domain) — neither the test nor the admin SPA's "Add User" form supplied
one at first. Fixed in both: the SPA now has a domain ID field, and the
Domains page shows each domain's ID (click to copy) so there's an actual
way to get that value into the form.
**Deferred within this phase:**
- Aliases CRUD (table exists, no admin UI yet).
- Pipeline settings UI (per-tenant score thresholds / check toggles) — the
`tenants.settings_json` column exists but nothing reads or writes it yet;
pipeline behavior is still entirely driven by the global `config.Pipeline`
values.
- TLS cert status view, DMARC/TLS-RPT report viewer — both depend on
Phase 13 (ACME/DANE/MTA-STS) existing first.
- Live log viewer (SSE) — webmail's `sseEvents` pattern from Phase 8 is
directly reusable here, just not wired up yet.
- Admin IP allowlist (`admin_ip_allowlist` in config) is not enforced at
the HTTP layer — the admin server binds to `127.0.0.1` by default, which
covers the common case, but the config value itself is currently inert.
### Phase 13 — TLS + ACME + DANE/MTA-STS — ✅ CORE COMPLETE (pulled forward)
Pulled ahead of strict phase order per the reasoning that closing the
`InsecureSkipVerify` gap improves the security posture of everything
already built more than most net-new features would — see the handover
doc for the fuller rationale.
**What's done and verified:**
- `internal/acme`: hand-rolled ACME v2 client (RFC 8555) — no third-party
ACME/JOSE library. `jws.go` implements JWS signing (ES256, flattened
JSON serialization per RFC 7515) and RFC 7638 JWK thumbprint computation
from scratch. `client.go` implements the full protocol: directory fetch,
nonce management, account registration, order creation, authorization
polling, HTTP-01 challenge response, CSR building, finalize, and
certificate download. `challenge.go` is the HTTP-01 responder
(`/.well-known/acme-challenge/{token}`). `obtain.go` is the high-level
orchestration function tying it together.
- `internal/tlsutil/acme_manager.go`: `ACMEManager` provides SNI-aware
certificate serving (`tls.Config.GetCertificate`), encrypted-at-rest
storage (same HKDF-per-record scheme as messages/contacts/DKIM keys —
new `tls_certs` table, migration 0010), in-memory caching, and a
background renewal loop (checks daily by default, renews within 30 days
of expiry).
- Wired into `cmd/gomail/main.go`: when `tls.mode: acme` and
`tls.acme_domains` are configured, a real `ACMEManager` drives cert
serving instead of the self-signed fallback, and a plain `:80` listener
serves the HTTP-01 challenge responder. Falls back to self-signed with a
now-accurate warning message when ACME mode is set but no domains are
configured, or when mode is `off`.
- **Verified** with a from-scratch fake ACME server (`cmd/e2etest13`,
deleted after passing per the established pattern) that does *real*
protocol-level verification, not a rubber stamp: ES256 JWS signature
verification against the account's actual public key, RFC 7638
thumbprint recomputation from that key, and a genuine HTTP callback to
the challenge responder to check the key authorization matches — exactly
what a real CA does. Confirmed: full obtain flow succeeds; the issued
certificate works in an actual `tls.Dial` handshake with the correct
`CommonName`; the certificate is encrypted at rest (checked the raw DB
column, then decrypted and confirmed it's a real PEM cert); a second
call hits the cache instead of re-issuing; a tampered JWS signature is
correctly rejected; and — importantly — finalizing an order without
completing domain validation is correctly refused with 403, proving the
fake CA (and by extension, the real protocol logic our client drives)
won't issue a certificate without genuine authorization.
- Two stale/inaccurate log messages caught and fixed while wiring this in:
the self-signed fallback's warning previously claimed "ACME is not yet
implemented (Phase 13)" — no longer true, so the message is now
context-aware (distinguishes "acme mode but no domains configured" from
"mode is off entirely"). A comment in `accounts.IMAPProvider` about
`InsecureSkipVerify` was similarly updated to reflect that the gap is
now narrower (GoMail's own server can get a real cert; a *linked*
external account's server is still outside our control regardless).
**Deferred within this phase** (genuinely not started, not just
untested):
- DANE (TLSA record lookup + verification for outbound delivery) — not
implemented.
- MTA-STS (fetch/cache `.well-known/mta-sts.txt`, enforce TLS to declared
MX hosts) — not implemented.
- TLS-RPT (aggregate report generation/sending) — not implemented.
- ACME DNS-01 challenge type (only HTTP-01 is implemented) — DNS-01 is
necessary for wildcard certs, which GoMail doesn't currently need, but
worth naming as a real gap rather than assuming HTTP-01 is sufficient
forever.
- No live test against a real CA (Let's Encrypt staging or production) —
same category of limitation as Phase 10's OAuth2: no real public
domain/DNS control in this sandbox to satisfy HTTP-01 validation from
the actual internet. **The protocol implementation is proven correct
against a genuinely-verifying fake server; a live run against Let's
Encrypt staging is the natural first action for whoever continues this,
same recommendation as Phase 10's OAuth caveat.**
- Every `InsecureSkipVerify: true` in the codebase (linked IMAP accounts,
the ACME manager's own... actually the ACME manager doesn't skip
verification, only the *generic IMAP/OAuth2 linked account* paths in
`internal/accounts` still do, since those connect to servers outside
GoMail's control) is not yet an argument for removal — search the
codebase for that string to find remaining call sites.
### Phase 12 — Auth hardening — ✅ CORE COMPLETE (TOTP + app passwords + reset)
Completed after Phase 13 in this session's actual order (13 was pulled
forward for its security-posture impact; this closed the gap in numeric
order afterward).
**What's done and verified:**
- `internal/totp`: hand-rolled RFC 6238 TOTP (HOTP RFC 4226 underneath) —
no third-party OTP library. **Verified against 5 of RFC 6238's own
published Appendix B test vectors** (not just internal self-consistency):
our 6-digit output exactly matches the last 6 digits of each published
8-digit reference vector, which is mathematically guaranteed to hold only
if the HMAC-SHA1 counter derivation and truncation are byte-for-byte
correct. Also verified: ±30s clock-skew tolerance works and correctly has
a boundary (120s does NOT validate), and the `otpauth://` provisioning
URI is well-formed.
- Two-step login: `POST /api/auth/login` returns `{mfa_required: true,
mfa_token: <5-min pre-auth token>}` instead of a session when
`user.MFAEnabled` — `POST /api/auth/mfa-verify` redeems that token plus
either a valid TOTP code or an unused backup code for a real session.
The pre-auth token is a genuinely distinct, narrowly-scoped token type
(`webtoken.Claims.Purpose = "mfa_pending"`), not just a normal session
token handed out early.
- MFA setup/confirm/disable: `POST /api/me/mfa/setup` generates a secret
and stores it encrypted but **not yet enabled** — `POST
/api/me/mfa/confirm` requires one valid code before MFA actually takes
effect and backup codes are issued, so an abandoned setup never locks
anyone out. 8 backup codes (SHA-256 hashed, one-time use, verified) are
shown to the user exactly once, matching the app-password pattern.
`POST /api/me/mfa/disable` re-requires the password (a session token
alone isn't enough to turn MFA off).
- App passwords: full self-service CRUD
(`GET/POST /api/me/app-passwords`, `DELETE
/api/me/app-passwords/{id}`) — the server-side scope-checking machinery
already existed from Phase 5, this phase added the UI-facing API.
Verified: the raw token is returned exactly once at creation and never
re-appears in the listing response.
- Password reset: `POST /api/auth/forgot-password` +
`POST /api/auth/reset-password`, gated on a user-configured
`recovery_email` (new `users.recovery_email` column) rather than the
account's own mailbox — deliberately avoids the chicken-and-egg problem
of emailing a reset link to a mailbox the person is locked out of.
Verified: a genuine outbound-queue entry is created addressed to the
recovery email (real delivery infrastructure, not a stub), and the
endpoint returns the identical response whether or not the account
exists (no account-existence leak via response differences).
**Two real bugs found and fixed while testing, both worth remembering:**
1. `db.GetUser` was built in Phase 11 for admin's minimal display needs
(id/email/role/active) and never extended when Phase 12 code started
depending on it for MFA fields — `mfa_confirm` failed with "no pending
MFA setup" even though setup had just succeeded, because the fetch
silently dropped `totp_secret_enc`. Fixed by making `GetUser` the one
canonical full-row fetch rather than fragmenting into
admin-flavored/auth-flavored variants.
2. **A genuine SQLite connection-pool deadlock** in `ConsumeBackupCode`:
it ran a `db.Query` (find the matching backup code) followed by a
`db.Exec` (mark it used) in the same function, but this database is
capped to a single open connection (`SetMaxOpenConns(1)`, see
`internal/db/db.go`) — calling `Exec` while the `Query`'s `rows` was
still open (only deferred-closed, not yet executed) caused the request
to hang forever waiting for a connection that could only free up
*after* the function returned. Fixed by closing `rows` explicitly
before the `Exec`. **The codebase was then scanned for the same
pattern** (any function mixing `Query` and `Exec`) and this was
confirmed isolated — worth doing that scan again after any future
change that mixes both in one function, given SQLite's single-connection
constraint makes this an easy trap to fall into.
**Deferred within this phase:**
- **Passkey/WebAuthn — not started at all**, and deliberately so: it
requires CBOR decoding for attestation/assertion objects, which is not
in Go's standard library and would be a meaningfully sized sub-project
of its own (on the order of the JWS/ACME work in Phase 13), with little
shared surface with anything else in this codebase. `users.
passkey_credentials_json` exists in the schema as a placeholder for
when this is picked up.
- QR code rendering for TOTP setup is not implemented — `totp.
ProvisioningURI` returns the `otpauth://` URI as plain text, which every
mainstream authenticator app accepts as manual entry; actual QR
rendering (Reed-Solomon error correction, matrix placement) is real
standalone work, noted in `totp.go`'s doc comment rather than silently
skipped.
- No webmail SPA pages for any of this yet — MFA setup/confirm, app
password management, and recovery-email configuration are all
functional, tested REST endpoints with no frontend UI. The existing
`internal/webmail/static/index.html` pattern is directly reusable, just
not built out for these yet.
### Phase 14 — Optional external services — ✅ CORE COMPLETE (ClamAV/Rspamd/LLM)
**What's done and verified:**
- `internal/pipeline/stage_clamav.go`: `ClamAVStage` speaks clamd's real
documented INSTREAM binary protocol by hand — no third-party clamd
client library. Send `"zINSTREAM\0"`, then the message in 4-byte-
big-endian-length-prefixed chunks terminated by a zero-length chunk,
then read the single-line response. Accepts `unix:` or `tcp:` prefixed
addresses. A detected match is always a hard block (score 100) rather
than a scored contribution, since malware detection isn't a "maybe."
**Verified with a genuine EICAR test-string detection** — the fake
clamd test server actually reassembles the streamed chunks and inspects
the bytes for the real industry-standard EICAR signature, the same way
real clamd does, rather than returning a canned response; a clean
message correctly passes, and an unreachable clamd is handled as
`CheckError` rather than crashing or hanging.
- `internal/pipeline/stage_rspamd.go`: `RspamdStage` POSTs the raw
RFC 5322 message to rspamd's real documented `/checkv2` HTTP endpoint
and maps its JSON response (`score`, `action`, `symbols`) onto this
pipeline's result model — `reject` → `CheckFail`, `add header`/
`rewrite subject`/`greylist` → `CheckWarn`, anything else → `CheckPass`.
rspamd's own score is passed through directly rather than being
rescaled, since both scales are already meant to be compared against
configurable thresholds the same way. Verified: score pass-through is
exact, action-to-result mapping is correct for all three cases tested.
- `internal/pipeline/stage_llm.go`: `LLMStage` calls the OpenAI-compatible
`/v1/chat/completions` endpoint that llama.cpp's server (and most other
local-inference servers) expose — no llama.cpp-specific protocol
needed. The model is asked for a single 0-100 integer; parsing extracts
the first run of digits rather than requiring an exact match, since
local models don't always follow format instructions exactly (verified
with a deliberately messy response — extra whitespace and trailing
commentary — that still parses correctly). LLM output is capped at 30
points of total score contribution regardless of what the model
returns, since non-deterministic model output shouldn't singlehandedly
quarantine mail the way a deterministic SPF/DKIM/DMARC failure can —
verified the cap applies correctly (raw score 95/100 → capped
contribution 28.5, not 95).
- `pipeline.StagesFromConfig`: builds `DefaultStages()` (the always-on
deterministic set) plus any of the three optional stages whose config
field (`clamav_socket` / `rspamd_url` / `llm_url`) is non-empty — an
unconfigured optional service is genuinely **absent** from the pipeline,
not merely present-but-disabled, so a misconfigured or unreachable
service that was never meant to be used can't accidentally affect
delivery. Verified: 5 stages with nothing configured, 8 with all three
configured. Wired into `cmd/gomail/main.go` in place of the old
`DefaultStages()` call.
**One real bug found and fixed while testing — in the test's fake clamd
server, not the product code**: the fake server's initial read of the
`"zINSTREAM\0"` command used a generic buffered `conn.Read()` call with a
32-byte buffer, which could over-read into the *next* protocol bytes (TCP
doesn't preserve write-call boundaries, so a client's separate `Write()`
calls can arrive coalesced in one read). That silently desynced the fake
server's chunk-length parsing loop, which then hung waiting for bytes that
had already been consumed — manifesting as an i/o timeout on the *client*
side, initially indistinguishable from a real product bug. Fixed by
switching the fake server to a `bufio.Reader` with exact-length
`io.ReadFull` reads for both the command and every subsequent chunk.
Worth remembering as a general pattern for any future protocol work over
raw TCP: never assume one `Read()` call aligns with the sender's `Write()`
call boundaries.
**Deferred within this phase:**
- Gmail API push (watch+Pub/Sub) and Microsoft Graph delta webhooks, to
replace Phase 10's IMAP polling for linked accounts — not started.
- No live test against real ClamAV, rspamd, or a real llama.cpp server —
same category of limitation as Phase 10's OAuth2 and Phase 13's ACME:
the protocol implementations are proven correct against genuinely-
behaving fake servers, but never against the real services. If the
operator has any of these installed, pointing `clamav_socket` /
`rspamd_url` / `llm_url` at them and sending one real test message
(the EICAR string is safe and appropriate for this) is the natural
first live-test.
- No admin UI toggle for these — they're config-file only
(`pipeline.clamav_socket`, `pipeline.rspamd_url`, `pipeline.llm_url`/
`llm_model` in `config.yaml`), consistent with how they were already
scaffolded as plain fields back in Phase 1.
### Phase 15 — Hardening + deploy — ✅ CORE COMPLETE (last phase in the plan)
**What's done and verified:**
- `internal/ratelimit`: hand-rolled per-key token-bucket limiter (no
third-party rate-limiting library) — a token bucket rather than a fixed
window deliberately, to avoid the classic "burst allowed at both edges
of a reset boundary" flaw a naive counter has. `rate=0` cleanly disables
limiting for a given listener rather than blocking everything, which is
how an unset config value opts out. **Verified under Go's race
detector** with 500 concurrent goroutines against a rate=100 limiter —
exactly 100 allowed, zero drift, proving the mutex genuinely serializes
access rather than merely looking correct single-threaded. Then proven
over real sockets and real HTTP: a real TCP listener genuinely
rejecting connections past the limit, a real `httptest` server
returning genuine 429s past the limit, and a specific check that
`X-Forwarded-For` trust correctly keys the limiter on the forwarded IP
rather than the raw socket peer (only trusted when
`server.real_ip_header` is explicitly configured — untrusted otherwise,
since blindly trusting it would let any client spoof their rate-limit
identity).
- Wired into every listener: SMTP (per-IP connection limit at accept
time, plus a separate limiter on AUTH attempts specifically, checked
before any credential parsing happens), IMAP (per-IP connection limit
at accept time), and a single **shared** limiter across DAV, webmail,
admin, and external JMAP — deliberately one combined per-IP budget
across all of them, not one each, since a client hitting its limit on
one HTTP surface shouldn't get a fresh budget by switching to another.
- Fuzz tests (`go test -fuzz`, real `*_fuzz_test.go` files — the first
standard Go test files in this repo; everything before this used
disposable `cmd/e2etestN` directories) on the four hand-written parsers
most exposed to untrusted network input: `vcard.Parse`, `ical.Parse`,
`sieve.Parse` (explicitly flagged as the highest-stakes target, since
every ManageSieve `PUTSCRIPT` runs through it), and IMAP's `tokenize`
(runs on every line a connected client sends, before authentication
necessarily succeeds). Each actually run for 15s, not just written:
~400K590K executions per target, ~1.5M executions total, zero panics.
- `go vet ./...` — clean across the entire codebase.
**`staticcheck` was attempted but is not runnable in this sandbox** — it
requires a newer Go toolchain than this network-restricted environment
can fetch (same class of limitation documented elsewhere in this plan
for the real Go 1.25.6 toolchain). This is a real, disclosed gap: `go
vet` provides real but narrower coverage than `staticcheck` would.
Running `staticcheck ./...` on a real machine with normal internet
access is a legitimate quick win for whoever continues this.
- `install.sh`: creates a dedicated system user (no login shell), sets up
`/etc/gomail`, `/var/lib/gomail`, `/var/log/gomail` with correct
ownership, builds and installs the binary, generates
`GOMAIL_MASTER_KEY`/`GOMAIL_JWT_SECRET` into a mode-600 env file if none
exist, installs and enables (but does not start) the systemd unit.
Syntax-checked with `bash -n` (clean); `shellcheck` isn't available in
this sandbox either, so deeper static analysis of the script itself is
another real machine follow-up.
- `gomail.service`: systemd unit with genuine hardening — capability-based
privileged-port binding (`AmbientCapabilities=CAP_NET_BIND_SERVICE`)
instead of running as root, plus `ProtectSystem=strict`,
`ProtectHome`, `PrivateTmp`, `PrivateDevices`, kernel/clock/hostname
protection, namespace/SUID/realtime restrictions,
`MemoryDenyWriteExecute` (with an inline comment flagging it as the
first thing to try removing if the CGO-based binary fails to start
under it — `mattn/go-sqlite3` requires CGO), and a syscall filter.
**Actually verified with `systemd-analyze verify`** (available in this
sandbox, unlike `staticcheck`/`shellcheck`) — returned clean against a
dummy executable standing in for the real binary.
- `README.md`: quick start, TLS/ACME guidance (including the
staging-before-production reminder from Phase 13's own caveat), and a
full DNS setup section — MX, SPF, DKIM (with the exact record shape
GoMail logs at bootstrap), DMARC, all with real example values and
guidance on starting permissive (`~all`, `p=quarantine`) before
tightening. MTA-STS/TLS-RPT sections are present but honestly marked
not-yet-implemented rather than filled with placeholder content.
**Deferred within this phase:**
- `staticcheck` and `shellcheck` — both blocked by sandbox network/
toolchain restrictions, not by any decision; run both on a real machine.
- No fuzz corpus regression files exist yet (expected — Go only persists
a corpus entry to `testdata/fuzz/` on an actual crash, and none of the
15s runs found one; longer fuzzing runs on a real machine, especially
with `-fuzztime` measured in hours rather than seconds, stand a better
chance of finding something a 15-second burst didn't).
- No fuzz targets for the SMTP command parser or JMAP JSON handling
specifically — the four targets chosen were judged highest-value for
the time available; SMTP's command parsing is somewhat covered
indirectly by the SMTP E2E tests from earlier phases but not fuzzed
directly, and JMAP's JSON handling rides on `encoding/json`, which is
already extensively fuzzed upstream in the Go project itself.
- No log rotation configuration, no monitoring/metrics endpoint, no
backup tooling for the SQLite database or Maildir tree — genuinely
unstarted, not scoped in the original Phase 15 description either.
---
## Project status: all 15 phases from the original plan have now been
## touched, 13 of them completed and verified
Phase 10 (native Gmail/Graph API, webmail account-switcher UI) and Phase
11 (aliases CRUD, per-tenant pipeline settings UI) remain "partially" or
"core" complete with named remaining pieces — see their entries above.
Every other phase reached a genuinely complete, tested state. This does
**not** mean the project is finished in an absolute sense — every phase's
entry above lists real deferred items, and the live-service verification
caveats (OAuth2 against real Google, ACME against real Let's Encrypt
staging) are still open — but the plan as originally scoped has been
worked through in full, not just partially sampled.
---
## Architecture notes that matter for continuation
### Dependency management in a network-restricted sandbox
The Anthropic sandbox this was built in blocks `proxy.golang.org` and
`sum.golang.org` — only `github.com` (plus npm/pypi, irrelevant here) is
reachable. Every session that needed a new dependency had to:
```bash
git clone --depth=1 --branch vX.Y.Z https://github.com/OWNER/REPO.git /tmp/REPO
```
then add a `replace` directive in `go.mod` pointing at `/tmp/REPO`. **This
does not apply on a real machine with normal internet access** — `go.mod`
ships with real version requires and no replace directives; `go mod tidy`
will just work. The `go.mod.production` file in each phase's tarball is
already the clean version with no local paths.
### Go version
Sandbox only had Go 1.22/1.23 available via `apt`; the user's real target
is **1.25.6**. Every phase's shippable `go.mod` declares `go 1.25.6`; all
sandbox development/testing happened against 1.22 syntax (no
version-specific features used, so this is a non-issue functionally) with
the directive temporarily lowered for local builds, then restored before
packaging. **On the real 1.25.6 toolchain, nothing needs to change.**
### Sandbox environment quirks discovered (informational, not GoMail bugs)
- The sandbox's default shell is `/bin/sh`, not bash — brace expansion
(`mkdir -p {a,b}`) silently creates a literal directory named `{a,b}`
instead of two directories. Always use explicit `bash -c` or separate
`mkdir` calls.
- The sandbox container has reset entirely mid-session at least once
(Go toolchain, `/tmp`, and the whole working directory vanished) and
resets `/etc/hosts` between *every* tool call. Backgrounded long-running
processes (`&` + later `kill`) don't reliably survive across tool call
boundaries either. **The working pattern that survived all of this**:
write E2E tests as a single self-contained `go run` invocation that
starts the server in-process (as a goroutine) and exercises it as a
client in the same process — never rely on a detached background
process still being alive in a later tool call.
- Because of the above, **always keep a tarball of the last-known-good
state in `/mnt/user-data/outputs`** — that survived every reset and was
the actual recovery mechanism used once.
### Testing pattern used throughout
Every phase has (had, before cleanup) a `cmd/e2etestN/main.go` that:
1. Boots the real server(s) for that phase as goroutines in the same process
2. Acts as a real client over the real wire protocol (raw TCP for
SMTP/IMAP/POP3, real `net/http` for DAV/webmail/JMAP)
3. Asserts against actual database state, not just protocol response codes
4. Is deleted after the phase's tests pass — **not shipped** in the
tarballs, since it's sandbox-only scaffolding, not part of the product
**When continuing this project, recreate this pattern per phase** — it
caught a genuine bug in nearly every phase (see "Bugs actually found"
below) that a build-only check would have missed entirely.
### Bugs actually found by this testing pattern (worth remembering the *pattern*, not the specific fixes)
- NULL-scan panics from `sql.NullString`-shaped columns not being wrapped
correctly (Phase 2, Phase 3)
- A whole SMTP port (`:465` implicit TLS) silently not receiving the
outbound-relay code path because a check only looked for one of two
equivalent session kinds (Phase 2/3)
- Foreign-key ordering: child rows inserted before their parent row
existed (Phase 4)
- Regex `.` not matching embedded `\r\n` from IMAP literals — an entire
class of FETCH responses silently parsed as empty (Phase 6)
- Wrong wire command entirely (`FETCH UID 1` sent instead of `UID FETCH
1`) — server correctly returned nothing, client incorrectly assumed
"not found" (Phase 6)
- Missing `json` struct tags meant to the wire API would have shipped
`TotalCount` instead of `total_count` (Phase 8) — caught before it ever
ran, not after
**Takeaway for whoever continues this: don't trust "it compiles" or even
"the happy-path test passed once." Write the negative-path tests (wrong
auth, cross-user access, malformed input) every time — they found real
bugs in every single phase.**
---
## File map (as of end of Phase 15 — the full plan)
```
gomail/
├── cmd/gomail/main.go # wires everything together
├── go.mod / go.mod.production # sandbox-dev vs real-deploy versions
├── install.sh # system user, directories, secrets, systemd install
├── gomail.service # hardened systemd unit — verified with systemd-analyze
├── README.md # quick start, TLS/ACME guidance, DNS setup guide
├── internal/
│ ├── accounts/ # MailProvider interface + local/IMAP backends
│ ├── auth/ # shared password + app-password verification
│ ├── config/ # YAML + env config loader
│ ├── crypto/ # HKDF-per-record AES-256-GCM encryption
│ ├── db/ # schema, migrations, all queries
│ ├── dav/ # CalDAV/CardDAV HTTP server
│ ├── dkim/ # DKIM sign + verify (stdlib crypto)
│ ├── ical/ # minimal RFC 5545 parser/builder
│ ├── imap/ # IMAP server (core command set)
│ ├── imapclient/ # hand-rolled IMAP client
│ ├── jmap/ # JMAP Core/Mail subset
│ ├── acme/ # ACME v2 client (RFC 8555) — JWS signing, full protocol flow
│ ├── admin/ # admin portal REST API + embedded dark Tailwind SPA
│ ├── mailstore/ # encrypted Maildir storage
│ ├── managesieve/ # RFC 5804 ManageSieve server
│ ├── oauth2/ # hand-rolled OAuth2 client (auth code grant + refresh)
│ ├── pipeline/ # SPF/DKIM/DMARC/header/URL security checks
│ ├── pop3/ # POP3 server (off by default)
│ ├── ratelimit/ # per-key token-bucket rate limiter (SMTP/IMAP/HTTP)
│ ├── sieve/ # RFC 5228 Sieve interpreter (lexer/parser/exec)
│ ├── queue/ # outbound retry queue + MX delivery
│ ├── smtp/ # SMTP server (inbound MTA + submission)
│ ├── tlsutil/ # self-signed cert generation + ACMEManager (SNI-aware serving, renewal)
│ ├── totp/ # RFC 6238 TOTP MFA — verified against real published test vectors
│ ├── vcard/ # minimal RFC 6350 parser/builder
│ ├── webmail/ # REST API + embedded Tailwind SPA
│ └── webtoken/ # hand-rolled JWT (HS256, plus scoped Purpose tokens for MFA/reset)
```
Native Gmail/Graph API providers (as opposed to the IMAP+OAuth2 path
already built) are not yet in `internal/accounts` — see Phase 10's
deferred-items list above. `internal/dane`, `internal/mtasts`, and
`internal/tlsrpt` don't exist yet — see Phase 13's deferred-items list.
No `internal/webauthn` (passkeys) — see Phase 12's deferred-items list;
`internal/totp` exists and is fully wired. That's the complete list of
genuinely unstarted packages as of the end of Phase 15 — every other
phase in the original plan has at least a core-complete, tested
implementation.
+59
View File
@@ -0,0 +1,59 @@
[Unit]
Description=GoMail — self-hosted email server
Documentation=https://gomail
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=gomail
Group=gomail
EnvironmentFile=/etc/gomail/gomail.env
ExecStart=/usr/local/bin/gomail -config /etc/gomail/gomail.yaml
Restart=on-failure
RestartSec=5s
# GoMail binds privileged ports (25, 80, 443, 993, etc.) — grant only the
# specific capability needed to bind them as a non-root user, rather than
# running the whole process as root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# ── Sandboxing ────────────────────────────────────────────────────────────────
# Everything below restricts what a compromised gomail process could do,
# independent of what the AmbientCapabilities line above grants for binding
# ports. Review `systemd-analyze security gomail.service` after install to
# see the resulting exposure score.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/gomail /var/log/gomail
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
# NOTE: this binary uses CGO (mattn/go-sqlite3 requires it). This directive
# is a standard hardening default and works on most systems, but if the
# service fails to start with a memory-protection-related error, this is
# the first line to try removing — some CGO/glibc combinations need
# executable memory in ways MemoryDenyWriteExecute forbids.
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount @debug
# Reasonable resource ceilings — adjust for your actual traffic; these are
# starting points, not hard requirements.
LimitNOFILE=65536
TasksMax=512
[Install]
WantedBy=multi-user.target
+1
View File
@@ -0,0 +1 @@
{"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"}
+1
View File
@@ -0,0 +1 @@
/home/haku/.local/share/uv/tools/graphifyy/bin/python
+1
View File
@@ -0,0 +1 @@
/home/haku/projects/webmail
+305
View File
@@ -0,0 +1,305 @@
# Graph Report - . (2026-08-09)
## Corpus Check
- 78 files · ~64,934 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
## 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
## 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
## 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 (64 total, 16 thin omitted)
### Community 0 - "TOTP & Web Token Auth"
Cohesion: 0.10
Nodes (34): User, decodeSecret(), Generate(), GenerateSecret(), Time, hotp(), ProvisioningURI(), Validate() (+26 more)
### Community 1 - "ACME/JWS 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 3 - "IMAP Command Parser"
Cohesion: 0.06
Nodes (24): MailboxEntry, state, addFlag(), expandFetchItems(), extractHeaders(), flagsToIMAP(), session, indexOf() (+16 more)
### Community 4 - "Mail Provider Abstraction"
Cohesion: 0.09
Nodes (25): Folder, FullMessage, GoMailProvider, IMAPCredential, IMAPProvider, ListOpts, MessageHeader, OAuth2Credential (+17 more)
### Community 5 - "Server Config & Bootstrap"
Cohesion: 0.09
Nodes (30): buildOAuthConfigs(), Config, main(), Config, DatabaseConfig, JMAPConfig, LinkedAccountsConfig, NotifyConfig (+22 more)
### Community 6 - "Sieve Filter Interpreter"
Cohesion: 0.12
Nodes (20): evalTest(), execStatements(), Execute(), lookupHeader(), newLexer(), Parse(), FuzzParse(), F (+12 more)
### Community 8 - "IMAP Server Loop"
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 10 - "JMAP Auth & Sessions"
Cohesion: 0.12
Nodes (20): Scope, Authenticate(), checkAppPassword(), DB, Context, DB, Request, ResponseWriter (+12 more)
### Community 11 - "SMTP Session Header Parsing"
Cohesion: 0.16
Nodes (15): extractHeader(), extractHeaderMap(), extractMessageID(), extractSubject(), Conn, Context, IP, ReadWriter (+7 more)
### Community 12 - "Admin API Handlers"
Cohesion: 0.20
Nodes (14): filterDomainsByTenant(), Handler, DB, HandlerFunc, Request, ResponseWriter, ServeMux, NewHandler() (+6 more)
### Community 13 - "IMAP Client"
Cohesion: 0.15
Nodes (12): Client, FetchedMessage, FolderInfo, SelectedInfo, Dial(), Config, Conn, Duration (+4 more)
### Community 14 - "DKIM Key Signing"
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 16 - "SMTP Server Networking"
Cohesion: 0.19
Nodes (14): connHost(), Addr, Config, Conn, Context, DB, Duration, Limiter (+6 more)
### Community 17 - "Linked Account & Alias Queries"
Cohesion: 0.14
Nodes (11): Alias, AppPassword, CheckResult, LinkedAccount, LinkedAccountAuthType, MessageCheck, MFABackupCode, ReleaseToken (+3 more)
### Community 18 - "TCP Server Lifecycle"
Cohesion: 0.20
Nodes (11): Server, connHost(), Addr, Config, Context, DB, Duration, Limiter (+3 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"
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"
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 24 - "iCal Parsing & Fuzzing"
Cohesion: 0.24
Nodes (9): Event, escape(), FuzzParse(), F, Time, Parse(), splitProperty(), unescape() (+1 more)
### Community 25 - "LLM Spam Stage"
Cohesion: 0.29
Nodes (7): extractLeadingDigits(), Context, Duration, chatCompletionRequest, chatCompletionResponse, chatMessage, LLMStage
### Community 26 - "vCard Parsing & Fuzzing"
Cohesion: 0.27
Nodes (8): escape(), FuzzParse(), F, Parse(), splitProperty(), unescape(), unfold(), Card
### Community 27 - "Admin Portal Bugs & CRUD"
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 29 - "SPF Stage"
Cohesion: 0.40
Nodes (7): checkSPF(), evaluateSPF(), Context, IP, matchCIDR(), spfOutcome, SPFStage
### Community 30 - "TCP Boundary & Rate-Limit Findings"
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 33 - "Per-IP Rate Limiter"
Cohesion: 0.33
Nodes (5): Mutex, Limiter, Time, New(), bucket
### Community 34 - "ClamAV Stage"
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 - "Spam Header Injection Stage"
Cohesion: 0.33
Nodes (4): Context, injectSpamHeaders(), HeaderStage, StageResult
### Community 37 - "Iterative Build Discipline Skill"
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"
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 40 - "URL Extraction Stage"
Cohesion: 0.47
Nodes (3): dedupe(), Context, URLStage
### Community 41 - "SQLite Deadlock Findings"
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 47 - "Shared api() Fetch Convention"
Cohesion: 1.00
Nodes (3): Shared api() Fetch Helper Convention, api() fetch helper (admin), api() fetch helper (webmail)
## 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
- **23 isolated node(s):** `webmail`, `IMAPCredential`, `DB`, `migration`, `response` (+18 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.
## 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 `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._
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_internal_accounts_provider_go", "label": "provider.go", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L1"}, {"id": "accounts_folder", "label": "Folder", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L10"}, {"id": "accounts_messageheader", "label": "MessageHeader", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L18"}, {"id": "accounts_fullmessage", "label": "FullMessage", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L29"}, {"id": "accounts_outgoingmessage", "label": "OutgoingMessage", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L34"}, {"id": "accounts_listopts", "label": "ListOpts", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L43"}, {"id": "accounts_syncresult", "label": "SyncResult", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L48"}, {"id": "accounts_mailprovider", "label": "MailProvider", "file_type": "code", "source_file": "internal/accounts/provider.go", "source_location": "L58"}], "edges": [{"source": "$graphify-root$_internal_accounts_provider_go", "target": "go_pkg_context", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L8", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_folder", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L10", "weight": 1.0}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_messageheader", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L18", "weight": 1.0}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_fullmessage", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L29", "weight": 1.0}, {"source": "accounts_fullmessage", "target": "accounts_messageheader", "relation": "embeds", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L30", "weight": 1.0}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_outgoingmessage", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L34", "weight": 1.0}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_listopts", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L43", "weight": 1.0}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_syncresult", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L48", "weight": 1.0}, {"source": "accounts_syncresult", "target": "accounts_messageheader", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L50", "weight": 1.0, "context": "field"}, {"source": "$graphify-root$_internal_accounts_provider_go", "target": "accounts_mailprovider", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/accounts/provider.go", "source_location": "L58", "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": "$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
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_internal_admin_embed_go", "label": "embed.go", "file_type": "code", "source_file": "internal/admin/embed.go", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_internal_admin_embed_go", "target": "go_pkg_embed", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/admin/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
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": "$graphify-root$_internal_ical_ical_fuzz_test_go", "label": "ical_fuzz_test.go", "file_type": "code", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L1"}, {"id": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "label": "FuzzParse()", "file_type": "code", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L5"}, {"id": "f", "label": "F", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/ical/ical_fuzz_test.go"}], "edges": [{"source": "$graphify-root$_internal_ical_ical_fuzz_test_go", "target": "go_pkg_testing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_ical_ical_fuzz_test_go", "target": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "target": "f", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L5", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L6"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L7"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L8"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L9"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L10"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L11"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L12"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L13"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Fuzz", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L15"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Fatalf", "is_member_call": true, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L18"}, {"caller_nid": "$graphify-root$_internal_ical_ical_fuzz_test_fuzzparse", "callee": "Parse", "is_member_call": false, "language": "go", "source_file": "internal/ical/ical_fuzz_test.go", "source_location": "L21"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_internal_vcard_vcard_fuzz_test_go", "label": "vcard_fuzz_test.go", "file_type": "code", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L1"}, {"id": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "label": "FuzzParse()", "file_type": "code", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L5"}, {"id": "f", "label": "F", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/vcard/vcard_fuzz_test.go"}], "edges": [{"source": "$graphify-root$_internal_vcard_vcard_fuzz_test_go", "target": "go_pkg_testing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_vcard_vcard_fuzz_test_go", "target": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "target": "f", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L5", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L6"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L7"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L8"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L9"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L10"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L11"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L12"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L13"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L14"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L15"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Fuzz", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L17"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Fatalf", "is_member_call": true, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L24"}, {"caller_nid": "$graphify-root$_internal_vcard_vcard_fuzz_test_fuzzparse", "callee": "Parse", "is_member_call": false, "language": "go", "source_file": "internal/vcard/vcard_fuzz_test.go", "source_location": "L27"}]}
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_smtp_auth_go", "label": "auth.go", "file_type": "code", "source_file": "internal/smtp/auth.go", "source_location": "L1"}, {"id": "$graphify-root$_internal_smtp_auth_authenticate", "label": "authenticate()", "file_type": "code", "source_file": "internal/smtp/auth.go", "source_location": "L9"}, {"id": "db", "label": "DB", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/smtp/auth.go"}, {"id": "user", "label": "User", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/smtp/auth.go"}], "edges": [{"source": "$graphify-root$_internal_smtp_auth_go", "target": "go_pkg_gomail_internal_auth", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/smtp/auth.go", "source_location": "L4", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_smtp_auth_go", "target": "go_pkg_gomail_internal_db", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/smtp/auth.go", "source_location": "L5", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_smtp_auth_go", "target": "$graphify-root$_internal_smtp_auth_authenticate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/smtp/auth.go", "source_location": "L9", "weight": 1.0}, {"source": "$graphify-root$_internal_smtp_auth_authenticate", "target": "db", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/smtp/auth.go", "source_location": "L9", "weight": 1.0, "context": "parameter_type"}, {"source": "$graphify-root$_internal_smtp_auth_authenticate", "target": "user", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/smtp/auth.go", "source_location": "L9", "weight": 1.0, "context": "return_type"}], "raw_calls": [{"caller_nid": "$graphify-root$_internal_smtp_auth_authenticate", "callee": "Authenticate", "is_member_call": false, "language": "go", "source_file": "internal/smtp/auth.go", "source_location": "L10"}]}
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
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": "L26"}, {"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": "L26", "weight": 1.0}, {"source": "$graphify-root$_internal_ratelimit_http_clientip", "target": "request", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/ratelimit/http.go", "source_location": "L26", "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": "L28"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "Split", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L29"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "TrimSpace", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L30"}, {"caller_nid": "$graphify-root$_internal_ratelimit_http_clientip", "callee": "SplitHostPort", "is_member_call": false, "language": "go", "source_file": "internal/ratelimit/http.go", "source_location": "L33"}]}
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_imap_parser_go", "label": "parser.go", "file_type": "code", "source_file": "internal/imap/parser.go", "source_location": "L1"}, {"id": "$graphify-root$_internal_imap_parser_tokenize", "label": "tokenize()", "file_type": "code", "source_file": "internal/imap/parser.go", "source_location": "L11"}, {"id": "$graphify-root$_internal_imap_parser_splitlist", "label": "splitList()", "file_type": "code", "source_file": "internal/imap/parser.go", "source_location": "L66"}, {"id": "$graphify-root$_internal_imap_parser_islist", "label": "isList()", "file_type": "code", "source_file": "internal/imap/parser.go", "source_location": "L75"}], "edges": [{"source": "$graphify-root$_internal_imap_parser_go", "target": "go_pkg_strings", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/imap/parser.go", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_imap_parser_go", "target": "$graphify-root$_internal_imap_parser_tokenize", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/imap/parser.go", "source_location": "L11", "weight": 1.0}, {"source": "$graphify-root$_internal_imap_parser_go", "target": "$graphify-root$_internal_imap_parser_splitlist", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/imap/parser.go", "source_location": "L66", "weight": 1.0}, {"source": "$graphify-root$_internal_imap_parser_go", "target": "$graphify-root$_internal_imap_parser_islist", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/imap/parser.go", "source_location": "L75", "weight": 1.0}, {"source": "$graphify-root$_internal_imap_parser_splitlist", "target": "$graphify-root$_internal_imap_parser_tokenize", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "internal/imap/parser.go", "source_location": "L72", "weight": 1.0}], "raw_calls": [{"caller_nid": "$graphify-root$_internal_imap_parser_tokenize", "callee": "WriteByte", "is_member_call": true, "language": "go", "source_file": "internal/imap/parser.go", "source_location": "L31"}, {"caller_nid": "$graphify-root$_internal_imap_parser_splitlist", "callee": "TrimPrefix", "is_member_call": false, "language": "go", "source_file": "internal/imap/parser.go", "source_location": "L67"}, {"caller_nid": "$graphify-root$_internal_imap_parser_splitlist", "callee": "TrimSuffix", "is_member_call": false, "language": "go", "source_file": "internal/imap/parser.go", "source_location": "L68"}, {"caller_nid": "$graphify-root$_internal_imap_parser_islist", "callee": "HasPrefix", "is_member_call": false, "language": "go", "source_file": "internal/imap/parser.go", "source_location": "L76"}, {"caller_nid": "$graphify-root$_internal_imap_parser_islist", "callee": "HasSuffix", "is_member_call": false, "language": "go", "source_file": "internal/imap/parser.go", "source_location": "L76"}]}
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": "pkg_webmail", "label": "webmail", "file_type": "code", "type": "package", "ecosystem": "go", "source_file": "go.mod", "source_location": "L1"}], "edges": [{"source": "pkg_webmail", "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_webmail", "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_webmail", "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_webmail", "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
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_internal_sieve_sieve_fuzz_test_go", "label": "sieve_fuzz_test.go", "file_type": "code", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L1"}, {"id": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "label": "FuzzParse()", "file_type": "code", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L5"}, {"id": "f", "label": "F", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/sieve/sieve_fuzz_test.go"}], "edges": [{"source": "$graphify-root$_internal_sieve_sieve_fuzz_test_go", "target": "go_pkg_testing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_internal_sieve_sieve_fuzz_test_go", "target": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "target": "f", "relation": "references", "confidence": "EXTRACTED", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L5", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L6"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L7"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L8"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L9"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L10"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L11"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L12"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L13"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L14"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L15"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Add", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L16"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Fuzz", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L18"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Fatalf", "is_member_call": true, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L27"}, {"caller_nid": "$graphify-root$_internal_sieve_sieve_fuzz_test_fuzzparse", "callee": "Parse", "is_member_call": false, "language": "go", "source_file": "internal/sieve/sieve_fuzz_test.go", "source_location": "L30"}]}
+1
View File
@@ -0,0 +1 @@
1786283404.4033368
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "readme_gomail", "label": "GoMail (Self-Hosted Email Server)", "file_type": "document", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_tls_acme", "label": "TLS / ACME Quick Setup", "file_type": "document", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_dns_setup", "label": "DNS Setup (MX/SPF/DKIM/DMARC)", "file_type": "document", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "readme_gomail", "target": "readme_tls_acme", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_gomail", "target": "readme_dns_setup", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_gomail", "target": "gomail_action_plan_v4_overview", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_tls_acme", "target": "gomail_action_plan_v4_phase13", "relation": "references", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_dns_setup", "target": "gomail_action_plan_v4_phase13", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_dns_setup", "target": "gomail_action_plan_v4_phase11", "relation": "references", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "README.md", "source_location": null, "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "internal_webmail_static_index_page", "label": "GoMail Webmail SPA (index.html)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_api", "label": "api() fetch helper (webmail)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_login", "label": "login()/logout() (webmail)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_showapp", "label": "showApp()/boot() (webmail)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_folders", "label": "loadFolders()/selectFolder()", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_messages", "label": "loadMessages()/viewMessage()/deleteMessage()", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_compose", "label": "Compose (openCompose/closeCompose/sendMessage)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_quarantine", "label": "Quarantine Release (showQuarantine/releaseQ)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_esc", "label": "esc() HTML-escape helper (webmail)", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "internal_webmail_static_index_bodyof", "label": "bodyOf() raw MIME body extractor", "file_type": "code", "source_file": "internal/webmail/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "claude_graphify_rules", "label": "graphify Project Rules", "file_type": "document", "source_file": "CLAUDE.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [], "hyperedges": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "_claude_iterative_build_discipline_package", "label": "iterative-build-discipline Packaged Skill (.skill zip)", "file_type": "document", "source_file": ".claude/iterative-build-discipline.skill", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "_claude_iterative_build_discipline_skill_core_principle", "target": "_claude_iterative_build_discipline_package", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".claude/iterative-build-discipline.skill", "source_location": null, "weight": 1.0}], "hyperedges": []}
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": "_claude_go_web_app_no_deps_package", "label": "go-web-app-no-deps Packaged Skill (.skill zip)", "file_type": "document", "source_file": ".claude/go-web-app-no-deps.skill", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "_claude_go_web_app_no_deps_skill_pattern", "target": "_claude_go_web_app_no_deps_package", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".claude/go-web-app-no-deps.skill", "source_location": null, "weight": 1.0}], "hyperedges": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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 one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More