Skip to content

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, plus noUncheckedIndexedAccess and exactOptionalPropertyTypes: an indexed access may be undefined, and an optional property cannot receive an explicit undefined unless its type says so;
  • noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, noUnusedLocals, noUnusedParameters, useUnknownInCatchVariables;
  • verbatimModuleSyntax, erasableSyntaxOnly (no enum, no namespaces: use as const objects), isolatedModules;
  • pure ES modules ("type": "module"), relative imports with their .ts extension.

ESLint adds, as errors:

RuleEffect
@typescript-eslint/no-explicit-anyany is forbidden. Use unknown and narrow it; validate external data with zod.
@typescript-eslint/no-floating-promises, no-misused-promisesEvery promise is awaited or explicitly handled. A deliberate fire-and-forget is written void promise.
@typescript-eslint/consistent-type-importsType-only imports use import type / inline type.
@typescript-eslint/no-unused-varsUnused 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-consoleOnly 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.

PackageMay import
workflownothing from the product
api-typesworkflow
credentialsworkflow
nodesworkflow
connectorsworkflow, credentials
mirrorworkflow, connectors
engineworkflow, nodes
toolsworkflow
serverevery package above
uiworkflow, api-types, nodes
apps/docsworkflow, 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:

LibraryOnly module allowed
dompurify, jsdompackages/server/src/webmail-read/sanitize.ts (server), packages/ui/src/features/webmail/model/emailBody.ts (interface)
css-treepackages/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.

WhereClasses
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": {} }
  • code is stable and part of the contract: lowercase, digits, _ and . (^[a-z][a-z0-9_.]*$). It is never renamed without a new API version.
  • message is technical, in English, for logs and debugging. The server sends no interface text.
  • details is 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.

TextWhere it goes
Interface (labels, placeholders, title, aria-label, error messages)packages/ui/src/i18n/locales/fr.json and en.json
Node labels, descriptions, optionsIn the node definition, as { fr, en }
Step summaries written by nodes at run timepackages/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.ts scans the interface sources and fails on template text, unbound attributes (title="…") or French literals in code;
  • packages/ui/src/i18n/i18n.test.ts checks that fr.json and en.json have 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.ts and packages/server/src/i18n/i18n.test.ts do 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, named NNNN_name.sql and 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 with constantTimeEquals, both in packages/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: subject or type(scope): subject, with types such as feat, 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 lint and 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.