English
Conventions
Most rules of the repository are enforced by tooling: the TypeScript compiler, ESLint (eslint.config.js), Biome (biome.json) and tests that scan the code. When a rule cannot be enforced that way, it should be checkable in a few seconds during review. This page lists the rules and where each one is checked.
Language of the code
Identifiers, table and column names, and technical error messages are in English. Every text a user can read goes through translations, in French and in English (see Texts and translations).
One concept has one word everywhere: code, database, API and interface use the same identifiers (member, mailbox, workflow, execution, step, scope, template…). Do not introduce synonyms.
TypeScript
The shared settings are in tsconfig.base.json:
strict, plusnoUncheckedIndexedAccessandexactOptionalPropertyTypes: an indexed access may beundefined, and an optional property cannot receive an explicitundefinedunless its type says so;noImplicitReturns,noImplicitOverride,noFallthroughCasesInSwitch,noUnusedLocals,noUnusedParameters,useUnknownInCatchVariables;verbatimModuleSyntax,erasableSyntaxOnly(noenum, no namespaces: useas constobjects),isolatedModules;- pure ES modules (
"type": "module"), relative imports with their.tsextension.
ESLint adds, as errors:
| Rule | Effect |
|---|---|
@typescript-eslint/no-explicit-any | any is forbidden. Use unknown and narrow it; validate external data with zod. |
@typescript-eslint/no-floating-promises, no-misused-promises | Every promise is awaited or explicitly handled. A deliberate fire-and-forget is written void promise. |
@typescript-eslint/consistent-type-imports | Type-only imports use import type / inline type. |
@typescript-eslint/no-unused-vars | Unused names are errors, except those starting with _. |
no-empty (catch blocks included) | No silent catch: a catch handles the error, or enriches it and rethrows. |
no-console | Only console.warn and console.error. Use the logger. |
Prefer discriminated unions to several booleans (status: 'queued' | 'running' | …).
Package boundaries
Dependencies between packages only go downwards. eslint.config.js declares, for each package, the exhaustive list of @manko/* packages it may import; any other import is an error.
| Package | May import |
|---|---|
workflow | nothing from the product |
api-types | workflow |
credentials | workflow |
nodes | workflow |
connectors | workflow, credentials |
mirror | workflow, connectors |
engine | workflow, nodes |
tools | workflow |
server | every package above |
ui | workflow, api-types, nodes |
apps/docs | workflow, api-types, nodes |
Also refused by ESLint:
- deep imports into another package (
@manko/<package>/<path>): a package exposes only its entry point; - reaching another package by relative path (
../../other/src/…); - any dependency on a package of the n8n ecosystem (
n8n,n8n-*,@n8n/*). Copying n8n code is forbidden too, including grammars, translation strings and schemas.
A node never imports a connector or a provider SDK: external effects reach it through the services of its context. The same principle applies elsewhere: business logic receives interfaces (mailer, LLM service, HTTP client, clock, queue) by injection, which makes test runs and tests without network possible.
Configuration and environment variables
Only the typed configuration module, packages/server/src/config, reads process.env. ESLint refuses process.env anywhere else in the product code. Test files and the documentation generator are exempt. Each variable is documented in .env.example and in Environment variables.
Libraries confined to one module
Some libraries are imported from a single module, and ESLint refuses them elsewhere:
| Library | Only module allowed |
|---|---|
dompurify, jsdom | packages/server/src/webmail-read/sanitize.ts (server), packages/ui/src/features/webmail/model/emailBody.ts (interface) |
css-tree | packages/server/src/webmail-read/css.ts |
Every new path that displays external content (email bodies) goes through these sanitising modules.
Formatting
Biome is the only formatter. pnpm format reformats, pnpm lint checks. The settings (biome.json): spaces, indent 2, line width 100, LF line endings, single quotes, semicolons, trailing commas everywhere, parentheses around arrow parameters, imports organised automatically. Biome's own linter is disabled: lint rules come from ESLint. In the interface, ESLint uses only the essential rules of the Vue plugin so that it never conflicts with Biome.
Style is never discussed in review: the formatter decides.
Errors and retries
The distinction between transient and permanent errors drives retries, so choose it explicitly at each throw on an external call.
| Where | Classes |
|---|---|
Nodes (packages/nodes/src/errors.ts) | PermanentNodeError, RetryableNodeError, with a stable code, an English message, cause and details. |
Engine (packages/engine/src/errors.ts) | TransientError, PermanentError, RateLimitedError (rescheduled without consuming an attempt). |
The engine reads the classification through the failureKind property; an unclassified error is treated as transient. Chain the original error with cause, and never put email content or secrets in details or messages.
API error format
Every API error has the same shape, defined by apiErrorSchema in packages/api-types/src/errors.ts:
json
{ "code": "workflow.graph_outdated", "message": "technical message in English", "details": {} }codeis stable and part of the contract: lowercase, digits,_and.(^[a-z][a-z0-9_.]*$). It is never renamed without a new API version.messageis technical, in English, for logs and debugging. The server sends no interface text.detailsis optional structured context.
The interface translates the code: the human message lives under errors.<code> in packages/ui/src/i18n/locales/fr.json and en.json, with a generic fallback. Every request and response body has a zod schema in @manko/api-types, shared by the server (validation) and the interface (types).
Texts and translations
No hard-coded text, and French and English delivered together in the same change.
| Text | Where it goes |
|---|---|
Interface (labels, placeholders, title, aria-label, error messages) | packages/ui/src/i18n/locales/fr.json and en.json |
| Node labels, descriptions, options | In the node definition, as { fr, en } |
| Step summaries written by nodes at run time | packages/nodes/src/i18n/fr.ts and en.ts |
| Texts the server writes for a person (approval email, confirmation page, daily summary) | packages/server/src/i18n/fr.ts and en.ts |
| Integration texts (label, guide, fields) | In the integration declaration, as { fr, en } |
Message syntax: {name} interpolates, and {count|singular|plural} agrees with the plural rule of the language ("0 fichier" in French, "0 files" in English). Write one key per whole sentence, never two halves glued together; a concatenation becomes a parameterised key. Dates, durations, numbers and sizes are formatted with Intl and the current locale.
Tests enforce it:
packages/ui/src/i18n/hardcoded.test.tsscans the interface sources and fails on template text, unbound attributes (title="…") or French literals in code;packages/ui/src/i18n/i18n.test.tschecks thatfr.jsonanden.jsonhave exactly the same keys, and refuses an English value identical to the French one outside a list of exceptions (proper nouns, codes);packages/nodes/src/i18n/hardcoded.test.ts,packages/nodes/src/i18n/i18n.test.tsandpackages/server/src/i18n/i18n.test.tsdo the same for nodes and server texts.
Whole-file exemptions of the interface scan are reserved for files that already carry both languages in a local catalog checked by the type checker (for example the messages of the node forms); a French string without its English counterpart is never exempted.
Translation files are shared by many changes: add your keys under your own namespace and edit the files, never rewrite them whole.
Prompts sent to language models are written in English in the code. They are instructions to the model, not interface text.
Product name and domains
The displayed name of the product comes from the BRAND_NAME configuration variable, and the instance address from PUBLIC_BASE_URL. Never write the product name or a domain in the code. The documentation writes the Mankomail token, replaced at build time, and its generator refuses any page that contains the product's code name.
Database and migrations
- Queries go through Kysely, only inside repositories (one per aggregate, methods named after use cases). Raw SQL templates are reserved for repositories.
- Migrations are raw SQL files in
packages/server/migrations, namedNNNN_name.sqland applied in number order by the server's migration runner, under an advisory lock. A migration is never modified once merged: its SHA-256 checksum is stored and checked. There are no down migrations; a migration must stay compatible with the previous version of the code (expand, then contract). - Every multi-table write uses an explicit transaction. Critical invariants are carried by the schema (unique constraints, foreign keys), not by code.
- Every external effect and every run creation goes through its unique key. A uniqueness conflict on that key is a normal result (no-op), not an error.
Security rules
- Secrets and credentials are never logged (the logger redacts known paths), never put in step data, never put in a URL.
- Random tokens are stored as a fingerprint computed with
hashToken, and secrets are compared withconstantTimeEquals, both inpackages/server/src/security/tokens.ts. Do not reimplement them. - Email content reaches a language model only through the LLM service, and prompts separate data from instructions.
Nodes
A node follows the defineNode contract and nothing else: no direct database access, no connector import, only the services of its context. Its policy.effect is honest, every external effect receives context.idempotencyKey, every network call honours context.abortSignal. The catalog keeps one version of each node, changed in place. The details are in Writing a node.
Commits and pull requests
- Commit messages follow the Conventional Commits form:
type: subjectortype(scope): subject, with types such asfeat,fix,refactor,docs,test. No commit hook or commit linter is installed: the form is checked in review. - Keep branches short-lived and pull requests small (under about 400 lines outside tests and generated files); split larger changes.
- A fixed bug comes with a test that reproduces it, in the same change.
- A change of documented behaviour updates the documentation in the same change, including this site (node sources, integration guides, pages).
- Before opening a pull request, run
pnpm typecheck,pnpm lintand the relevant tests. CI then runs typecheck, lint, the full test suite with PostgreSQL and MinIO, the build and a smoke test of the container stack (see Contributing). - Review checks invariants first: idempotency, transactions, scope, declared effects.