first commit
This commit is contained in:
@@ -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.
@@ -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.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go version *)",
|
||||
"Bash(command -v staticcheck)",
|
||||
"Bash(go build *)",
|
||||
"Bash(go vet *)",
|
||||
"Bash(go test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
tests/
|
||||
@@ -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).
|
||||
@@ -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 1–9 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.5–15 (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.
|
||||
@@ -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.)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,672 @@
|
||||
# GoMail — Action Plan v4 (post-Phase 9)
|
||||
**Status: Phases 1–9 complete and verified. Phases 10–15 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 1–9)
|
||||
|
||||
| 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:
|
||||
~400K–590K 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.
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
/home/haku/.local/share/uv/tools/graphifyy/bin/python
|
||||
@@ -0,0 +1 @@
|
||||
/home/haku/projects/webmail
|
||||
@@ -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._
|
||||
graphify-out/cache/ast/v0.9.37/01867e12a6752ec36131a56a7d8f29a3221a6ddb666c5ba96e31b35f37a4bd39.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/0381ceabb7b4040ef7f7cc7e9733c8b93144b57f738f6f09af2719baf07ca2d7.json
Vendored
+1
@@ -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": []}
|
||||
graphify-out/cache/ast/v0.9.37/06320a96b951333c49dd4ad314fa4041ca18066788b44593f4a0456cae476132.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/06e716a3e10d2f7aa00723d441d32334db171bae806b7a8146a10161829984ce.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/11618daf584c5a4cb03d0e6ae5f72f72c569160f410500bbb6b9ffb7d6b16edd.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/14cacc29a909f205c204cd5b79c416c655f92f16106625507eab05cf6145006f.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_internal_webmail_embed_go", "label": "embed.go", "file_type": "code", "source_file": "internal/webmail/embed.go", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_internal_webmail_embed_go", "target": "go_pkg_embed", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "internal/webmail/embed.go", "source_location": "L3", "weight": 1.0, "context": "import"}], "raw_calls": []}
|
||||
graphify-out/cache/ast/v0.9.37/16e8c264eda92ba65af904a662714b2332e1c247bbdd90dd69d3826fcd156dbd.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/16fb70e973f37f7c2255e6b7e632440bdad16fd1ac8fbe21b9300cb6f83c8a20.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/18ca9555968cf41a0a89e5bcf8d8397cde1a71ebfe06320737e8108cbcb353ed.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/1d2de138d6c082ff1bd00ac243aa89680b02cd5a286070e96708c47ad9f4007d.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/25a9bf3182e841181f89957aabea6e645995dab5b44477988e5bb6c4897ecc6b.json
Vendored
+1
@@ -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": []}
|
||||
graphify-out/cache/ast/v0.9.37/274a9df3a8c4c5af57ec6c0c193ee82fe9ef27904a294fd71ca78317cd35516c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/27b64506fc8c7f63042ed8ccc48d70c12c13eb1a52b8acbca498361c9e29a3be.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/285b9c2dfabca2c4b317cf9485010045dc7ad2a9800a51a670dc6bef420caf8f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/297db964001ce83dab63836d7b5fe7bcb16f29c54a233af0de3e4415582494f1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2d619d30899ac069138265adb14ee34f512210698cd72b18a6b74fe8a5b65f27.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2db770dae16414ef9bc4398e78c4e1adde6582e2527da24060709d275f774ef8.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2e021219be16fe7bf708d556659073cc0771895d20f5844060c6436c62956276.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/30c55af185cf498bee132852f2ea8e650bb15e592be37ee374c94f8a277ff09e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/34fd5fb44819e1fcc4853631671f81e1c59516291dd6732c5f537fb2f1271915.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/3d019ed73cdc47d2e51eb65fccd5edb76acb23bd639d000a995336a3f875399b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/3de00079917802cf9218efd41088c64a399e36b5ceaa76d36e9241b917af8216.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/3ef1f6e881f7482ea2fcc0ee4c0839d9ada82372c0cbb85593ede084237ebc63.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/3f113d5c8723b717d980c77dc6668fbab5eee76bf31e0c51947ee1d80c2d6f66.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/3fd7a56192e3540644eff612b83fbfea8ce4eb0fbd8841e687d5eed988c96fde.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_internal_db_migrations_go", "label": "migrations.go", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L1"}, {"id": "db_migration", "label": "migration", "file_type": "code", "source_file": "internal/db/migrations.go", "source_location": "L7"}], "edges": [{"source": "$graphify-root$_internal_db_migrations_go", "target": "db_migration", "relation": "contains", "confidence": "EXTRACTED", "source_file": "internal/db/migrations.go", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||
graphify-out/cache/ast/v0.9.37/40164abd70b0c9c982e588641034546148f99102c49af7293261dba9f026807e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/52e28e4fca36ffbe3815a50bd4e05fc4de9101273fbbb981f71503db45b5a5c8.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/5354faad5b9e681b4a01fced7c4983a1b3fcc164fffadc7826fc162781d7e6c5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/53b9d444a4779e2fe0df289028ea78e65f5135141e615008b1548d7fa1376ed8.json
Vendored
+1
@@ -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"}]}
|
||||
graphify-out/cache/ast/v0.9.37/540e1c2d50f788862609c1b086a9b6fc770c33db243f99f9cfc177bb17f79cbe.json
Vendored
+1
@@ -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"}]}
|
||||
graphify-out/cache/ast/v0.9.37/558bf4f443b07cd69a76bc1c5292cacb2cdc4a41c1652d97a25b809d9f7c2501.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/57276af576efcf4c8a23d1540226650918baa9b5244840bec68b939ad25cad69.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/5a51b73b432cab266a6bdd6709cc439906627143288b595a12b7dfa8796c57d9.json
Vendored
+1
@@ -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"}]}
|
||||
graphify-out/cache/ast/v0.9.37/5b3bf321b82c6a90785b164f591cae73bed114cfd9570a97863dfd6503daccff.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/638498acd1e1f8d2c68295df39f4cc636bc4ef88ed854d0e6897bba5ca991cca.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/683a8cc417db6a367d0bc8bab56a22a9e79b817616c69b5d6a32e51c0db281dc.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/6b7825dcaea48fa964e97c01c9a66e6758c53ea522e2563d6b7d695ae8df0036.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/75f29d536dad9eac28b5a35ce9786706af5619969d76446de7218d49738c0b84.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/7d1ac7637ddb26cf2b83203450e9bc26a8caff61744895e201b9c934dafb494b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/7d9899cb9d87399fcd7edcf87da399d3c4f66f42284cbbe906cda7f0622dd0c0.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/899561245ce00ebe94247ad43b7037821a6b8db8812c7b5e309acc08981b9f32.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/8d98612c8bdfdd4452fc62bf2715c692beef6c0e84b8c2a1f8169b7c2e502db2.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/8da31ebedb8fc4afa71df7175375ffa28cb50958d88e74463a8c2d77eb2fb66f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/9a41a264151e3fdbde0ae63d898e2cf3203830ce7407a5be84edb8e883db243e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/9c6f1f0579bb90e8842ea0b14d7ed591f4bfffe0331f1dd35a778f26c7a7fa1c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/a4b45cf338bec3d6c37b3cbee16cdc637bfcb50c9f5999df8fdd066f9c4675cf.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/a9d1f121f22790a8bec76fa627918fa33bc43282f5576da2bc4fa3bf7a87fdd9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ab8c9a7d5a56595431658182eaf74fe73ddc6bbaf97f1b3bc010226a135527d5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ad97b40bb86849b49f3781527f0867c6150a9f4151e5c06b7f34a350c2ee9aa5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/afeb1b572ad216159bea04be6e95b1837b1b8f53e32143daa8c487227a9d6b74.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b12e0121376062579a15d278c69ae46970a0370b06b9c4f427db8557b018b935.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b32c873b4d4852727c3f80f77374858facfd70b35ce160bc6b7d66926621c962.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/b4f823f3a797fba2164ac25f638d64dc7f2992903ffbb5a3d48fc7b809aafcf5.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_internal_ratelimit_http_go", "label": "http.go", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L1"}, {"id": "ratelimit_limiter", "label": "Limiter", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L14"}, {"id": "ratelimit_limiter_httpmiddleware", "label": ".HTTPMiddleware()", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "L14"}, {"id": "handler", "label": "Handler", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "$graphify-root$/internal/ratelimit/http.go"}, {"id": "$graphify-root$_internal_ratelimit_http_clientip", "label": "clientIP()", "file_type": "code", "source_file": "internal/ratelimit/http.go", "source_location": "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"}]}
|
||||
graphify-out/cache/ast/v0.9.37/bc20c872a2480d715fc57854044f5f8f9f196f1504e600a1a9b895b3b11e416b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/bde7472273836b5795486ea4163e426c3b59d29606d62f14c75d23d92ea652c1.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/bf7ff45fc680de5cad12647b983ef6cefc092cce18376cff9c466d855176118d.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/c6fedb9529ebcc44e44e30d029806781775b88f996b988d46e5e79555cb9b691.json
Vendored
+1
@@ -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"}]}
|
||||
graphify-out/cache/ast/v0.9.37/c8104b250a1cdd572a1e480c40a1783362e85c6d0bec409c7b1801117f5f3419.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ca5cbb041ad202b6b3aaab499244dc3f0fa51c6ba2d52b5da121dfd26200ecd5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/cc464e990035ce3503956450aef4033b659ee11dd4979dc1caf3f3044c9b4bac.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d11f4ac6165a8964603a8ca74161b28716f4d9c6f1a7fda4f9c79dd30e520be5.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d8d3d8ab1b6673c3adf6f7953dcaa01fd929e189baacdec0a433edcd977cc1fc.json
Vendored
+1
@@ -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}]}
|
||||
graphify-out/cache/ast/v0.9.37/e32f5da38a9a0cc79259315b3e6912c0089c6dd96d4d308fb6098efb95900379.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/e9e09f69049007e7bb0673a914f3592ffcefdbc88b96b906e09e8c4bd00f3716.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ef2cfc3a2244f170933887db0a0dcb840822693d86c62fbfb03d95d33199a283.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/f7cf95bcf96d3d90f4270b0bc467f2bef8907cef32e756c72649bf4ea7b75ca9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/fe62c69630675ede7967fab1272473845c5488f638c5d51288f79c8be243df74.json
Vendored
+1
@@ -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"}]}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
1786283404.4033368
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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": []}
|
||||
+1
@@ -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": []}
|
||||
+1
@@ -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": []}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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": []}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
@@ -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": []}
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
Reference in New Issue
Block a user