TeaNode · Documentation · Source
TeaNode is a mail server. Mail is unforgiving: a mistake does not throw an exception, it silently loses somebody's message or gets a domain's reputation burned. That shapes most of what follows.
Start with docs/reference/local-development.md to get a build and a database.
make format # gofmt
make lint # golangci-lint, then the naming checks if you have them
make test # starts a PostgreSQL container automatically
make build
CI runs make lint-ci, which is golangci-lint only. A local check enforces this
project's naming conventions and is local-only, so a contributor without it is
never blocked; run it anyway if you have it.
These are not negotiable, and make lint checks most of them.
ReferenceURI, SessionID,
GetFTPID, DKIMResult. If it starts lowercase, only the first letter of
the acronym is capitalized: referenceUri, sessionId, getFtpId. Register
a new acronym in the lint configuration with a comment saying what it stands
for.command, not cmd. response, not resp.
request, not req. Go package names are the exception and should be short.err is the one blessed short name, and Go
errors should be called err wherever possible.self. Consistently, across the whole codebase.
.golangci.yml disables the linters that would object.delivery in the
database it is not a send in the API.The web dashboard has its own conventions — which shared component to reach
for, where a panel's action goes, when a row action is an icon and when it is
a word — in docs/coding/frontend-design.md. Read it before adding a page or
a panel. A page that builds by hand what a component already does is the
change most likely to be sent back.
Explain why, not what. The what is in the code underneath.
A comment earns its place when it records something the reader cannot recover
from the code: a protocol requirement, a failure that was observed in
production, a deliberate choice among several reasonable ones. // increment the counter above counter++ is noise. // Reject before the DATA command so a spammer pays for the connection is worth having.
Exported identifiers get a doc comment starting with their name.
Break any of these and something downstream fails in a way that is hard to trace back.
.reverse.sql. The migration runner in
internal/db/database_migrate.go reverts unknown migrations using the
reverse SQL it recorded when applying them, and panics when it is missing.
See docs/coding/database-migrations.md.id
in the configuration is generated once and never changed, because stored mail
and deliveries reference it. Editing a pattern must not regenerate an id.internal/config defines its shape. Do not
add a settings table of your own, and do not read an environment variable at
the point of use: the environment says where the database is and seeds a
first run, and everything else an operator sets belongs in internal/config.if settings.Enabled must not construct a
client, open a file or dial anything when disabled.http-01 handler comes first. A certificate authority fetches
/.well-known/acme-challenge/ over plain HTTP with no credentials. It must
not meet authentication, a redirect to HTTPS, or a catch-all route.make check-secrets scans
tracked files. Test fixtures use example.com and example.net.Write the test that would have caught the bug. A test that only proves the code runs is not worth the maintenance.
Tests that need PostgreSQL take it from internal/db/dbtest, which skips when
TEANODE_TEST_DATABASE_HOST is unset, so go test still works without Docker.
A unit test must not reach the network. This was learned the hard way: an
early version of autoacme.Open started its renewal loop during construction,
so merely building a manager in a test contacted Let's Encrypt production.
Construction and starting are now separate for exactly this reason.
Write the message for somebody trying to understand this change a year from now with no memory of the conversation that produced it.
User-visible changes get a changelog entry, and there are two places to put
one. The Changelog block of the pull request description is where most of them
belong: it is reviewed alongside the change it describes, and the release bot
collects it. Writing straight into CHANGELOG.md under Unreleased is the other
way, and is what somebody committing to main does. Internal refactoring that an
operator cannot observe needs neither; say so in the description and label the
pull request no changelog.
That entry is also what releases the change. Every push to main that leaves
something under Unreleased is cut into a release: the version comes from the
entries — Added or Removed means a new minor, anything else a new patch —
and the tag it pushes is what builds the binaries and the container image. A
push with nothing under Unreleased releases nothing, which is most pushes.
To see what would happen before pushing:
.github/scripts/cut-release.sh # says the version, writes nothing
.github/scripts/changelog.sh 0.2.0 # the notes that version would carry
On a laptop that reads only CHANGELOG.md; it says so, and says why. Reading
the pull requests takes GITHUB_REPOSITORY and a token, which the workflow
passes it.
A major version is never inferred. Deciding that a change breaks whoever is running this is a judgment, so it is the "Major release" workflow, run by hand, with the word MAJOR typed into it.
If your change makes a choice a future reader would question, write a decision
record in docs/decisions/ — see the README there. Larger work gets a design
document in docs/planning/active/ first, moved to done/ when it lands.
Do not open a public issue for anything exploitable in mail handling,
authentication or certificate issuance. SECURITY.md says where it goes:
GitHub's private vulnerability reporting, on the Security tab.
Every document is also plain markdown, listed in /llms.txt.