Code convention
This document defines coding conventions for the TildeMail project. It is intentionally strict to keep the codebase consistent and readable. It follows the Moongate convention, adapted to this solution (TildeMail.Abstractions, TildeMail.App, TildeMail.Core, TildeMail.Scripting, TildeMail.Tests).
1. General Principles
Section titled “1. General Principles”- Prefer clarity over cleverness.
- Keep domain boundaries explicit.
- Keep files small and focused.
- Avoid hidden magic and implicit behavior.
- Write code that is easy to reason about during debugging.
- Architecture stays KISS.
2. Project Structure and Namespaces
Section titled “2. Project Structure and Namespaces”2.1 Folder-to-Namespace Rule
Section titled “2.1 Folder-to-Namespace Rule”Namespace must match folder path exactly. The general form is the bucket first, then the domain (see 2.2):
src/<Project>/<Bucket>/<Domain>/<File>.cs → namespace <Project>.<Bucket>.<Domain>;tests/<TestProject>/<Domain>/<File>.cs → namespace <TestProject>.<Domain>;For example:
src/TildeMail.Core/Interfaces/Jobs/IJobSchedulerService.cs → namespace TildeMail.Core.Interfaces.Jobs;src/TildeMail.Abstractions/Data/Config/AccountConfig.cs → namespace TildeMail.Abstractions.Data.Config;src/TildeMail.App/Commands/CheckCommand.cs → namespace TildeMail.App.Commands;tests/TildeMail.Tests/Jobs/JobSchedulerServiceTests.cs → namespace TildeMail.Tests.Jobs;2.2 Bucket-First Organization
Section titled “2.2 Bucket-First Organization”The technical bucket comes first, the domain second: Interfaces/Jobs, not Jobs/Interfaces. The buckets are listed in 2.4; the domains are Mail, Compose, Gpg, Cache, Jobs, Config, Secrets, Sync and the like. A domain that nests another keeps that order after the bucket: Data/Mail/Maildir/, namespace TildeMail.Mail.Data.Mail.Maildir.
A domain subfolder that repeats the domain name is dropped: the account model is Data/Config/AccountConfig.cs, not Data/Config/Config/AccountConfig.cs.
2.3 Assembly Boundaries
Section titled “2.3 Assembly Boundaries”TildeMail.Abstractions contains shared contracts, required models and lightweight registration extensions. Its only runtime package dependency is DryIoc. It must not reference Core, App, protocol libraries, storage libraries or scripting engines. Implementations and host setup remain in Core and Mail; App composes them. MIME-backed reader models stay with the reader in TildeMail.Mail, not in Abstractions.
TildeMail.Mail owns the mail runtime: the IMAP and Maildir backends, SMTP delivery, MIME reading, message composition, the GPG integration of the mail, the mail cache and index, the mail rules and the watching of new mail, in the domains Mail, Compose, Gpg, Cache, Rules and Sync of every bucket. It references Core and Abstractions; Core must not reference Mail, MailKit, SQLite or ReverseMarkdown. Core keeps a direct MimeKit reference only for the address validation of the configuration. Text helpers that both libraries need are public in Core (Extensions/Text, Services/Text), not duplicated.
TildeMail.Scripting owns the Lua engine, modules and prelude. It references Core and Mail services; neither may reference Scripting, Moongate.Scripting or Lua. App composes both. Keep internal implementation access limited to the test assembly.
2.4 Mandatory Namespace Buckets
Section titled “2.4 Mandatory Namespace Buckets”| Bucket | Content |
|---|---|
Types |
Enums, type constants (domain-prefixed) |
Data |
DTOs, records, simple data carriers |
Data/Config |
Configuration models |
Data/<Domain>/Internal |
Internal-only data models, e.g. Data/Config/Internal |
Interfaces |
Contracts only, internal ones included |
Services |
Service implementations |
Extensions |
Extension members, grouped by the type they extend |
Attributes |
Custom attributes |
Internal |
Implementation details not part of the public API |
TildeMail.App has three more buckets for the user interface: Host (the terminal host), ViewModels and Views, with the domain Tui below them: src/TildeMail.App/Views/Tui/, namespace TildeMail.App.Views.Tui.
Types sit in a domain subfolder of their bucket; a bucket with only a handful of types may keep them at its root. A domain only gets the buckets it needs; do not create empty ones. An Internal folder inside a bucket stays inside it: Data/Config/Internal, namespace TildeMail.Core.Data.Config.Internal. TildeMail.Scripting/Modules (Lua modules), App/Commands (command line) and App/Composition (container and sessions) are the domain folders that exist beside the buckets, without buckets of their own.
An interface is never placed in Internal, even when it is internal: the visibility is the type’s, the folder is the contract’s.
3. C# File and Type Rules
Section titled “3. C# File and Type Rules”- One
.csfile must contain at most one primary type (class,record, orenum). - File name must match type name.
- Use file-scoped namespaces.
- Methods use block bodies (
{ }), including single-statement methods. Do not use expression-bodied methods (=>). This rule is enforced by.editorconfig(IDE0022 is a warning, and the build is at zero warnings); lambda expressions and property/accessor styles are unchanged. - Do not use primary constructors on classes or ordinary structs. Positional
recordandrecord structdeclarations are allowed for DTOs and simple data carriers; keep I/O, subscriptions and service startup out of them. - Do not use expression-bodied constructors (
public X(...) => ...); constructors must always have a body{ }. - A constructor assigns fields and nothing else: no I/O, no thread start, no subscription.
3.1 Indentation and Formatting
Section titled “3.1 Indentation and Formatting”C# uses spaces with an indentation size and tab width of 4, as defined in .editorconfig. Continuation lines use a single indentation level rather than alignment to the preceding expression, argument or declaration. The ReSharper alignment settings are explicit in .editorconfig and in TildeMail.slnx.DotSettings, so Rider’s formatter, inspections and the jb command-line tools agree regardless of personal preferences.
Use the shared cleanup profiles instead of reformatting by hand:
jb cleanupcode TildeMail.slnx --profile="CSharp formatting only" --include="**/*.cs" --no-buildjb cleanupcode TildeMail.slnx --profile="XML documentation only" --no-buildThe first applies indentation, spacing, wrapping and XML documentation layout without code cleanup or member reordering; the second applies the XML documentation layout alone.
4. Class Layout Order
Section titled “4. Class Layout Order”Inside a type, use this order:
constfieldsprivate readonlyfields (prefixed_)- Non-readonly fields
- Properties
- Constructor(s)
- Public methods
- Protected methods
- Private methods
Dispose/finalization methods (always last)
4.1 Private Readonly Naming
Section titled “4.1 Private Readonly Naming”All private readonly fields must start with _:
private readonly IMailBackendFactory _backends;private readonly TimeProvider _time;4.2 Dispose Position
Section titled “4.2 Dispose Position”If a class implements IDisposable or IAsyncDisposable, Dispose/DisposeAsync must be the last method(s) in the file. This applies to test classes too.
An IDE rearrange pass sorts members by kind and accessibility, which lifts Dispose above the methods it tears down. EditorConfig has no member-ordering property and dotnet format never reorders members, so this rule lives in TildeMail.slnx.DotSettings (Rider’s Rearrange Members reads the file layout from there). Check the order by eye after any automatic rearrange.
5. Interfaces
Section titled “5. Interfaces”- Interfaces live only under
Interfacesnamespaces, whatever their visibility. - Every interface, and every member it declares, carries XML docs (
///) written in English. - Interface names must use
Iprefix and clear domain naming.
5.1 XML Documentation Layout
Section titled “5.1 XML Documentation Layout”Use separate lines for opening tags, documentation text and closing tags, with the text indented one level. This applies to XML documentation throughout the codebase, not only interfaces. Keep self-closing references such as <see cref="IDisposable" />, <paramref name="value" /> and <inheritdoc /> intact.
/// <summary>/// Gets the moment of the next run./// </summary>The formatter splits an element whose content is longer than 60 characters, so write block tags (summary, param, returns) on separate lines yourself; an inline <c> or <see> stays inside its sentence. The text of a command’s <summary> and <param> becomes its --help output, so write it as help text.
6. Enums
Section titled “6. Enums”- Enums must live under a
Typesnamespace, in the subfolder of the domain they belong to (Types/Jobs,Types/Config). - Always include the domain in the enum name.
- A flags enum gives every member an explicit value and declares a zero member, so that
HasFlagcannot answer true for an unset value.
namespace TildeMail.Core.Types.Jobs;public enum JobStateType { ... }
// Types/Config/ConnectionSecurityType.csnamespace TildeMail.Abstractions.Types.Config;public enum ConnectionSecurityType { ... }7. Strings
Section titled “7. Strings”- Empty strings have no imposed form. Neither
""norstring.Emptyis the standard: leave whichever a file already uses and do not convert in either direction.
8. Logging
Section titled “8. Logging”- Use Serilog statically via
Log.ForContext<T>(). Do not injectILogger<T>via DI. - Declare the logger as a
private readonlyfield initialized inline.
private readonly ILogger _logger = Log.ForContext<MyService>();- Where a project namespace shadows a framework type, qualify or alias rather than renaming the namespace;
using ILogger = Serilog.ILogger;whereMicrosoft.Extensions.Loggingis also in scope. - Use static message templates; never use string interpolation for structured logs.
- Keep template shape stable across calls.
- Never log a credential, a password, a token or a message body.
9. Dependency Injection and Lifetime
Section titled “9. Dependency Injection and Lifetime”- Services are registered as singletons in DryIoc (
AppContainerinTildeMail.App, and the*ContainerExtensionsof each domain). - Do not guard constructor dependencies that arrive from the container: let the container fail on a missing registration instead of repeating the check in every service.
- Guard the arguments of the public API surface with
ArgumentNullException.ThrowIfNulland friends. - A singleton’s constructor runs only when something resolves it. Start work (threads, timers, watchers, subscriptions) in an explicit
Start/StartAsyncand undo it inStop/Dispose, never in the constructor. - A service that owns threads, timers or connections implements
IDisposableorIAsyncDisposableand stops its work there.
10. Scripting
Section titled “10. Scripting”- A Lua module is a class under
TildeMail.Scripting/Modulesmarked[ScriptModule], with[ScriptFunction]methods and[ScriptConstant]fields. It is registered withAddScriptModule<T>(). - Lua code never runs outside the script engine: it is a unit that goes through the engine’s serialised, instruction-limited path.
- A module that holds state implements
IScriptResettableso a reload clears it. definitions.luais generated from the module metadata; do not edit it by hand.
11. Test Conventions
Section titled “11. Test Conventions”11.1 Structure
Section titled “11.1 Structure”tests/TildeMail.Tests/<Domain>/<SubjectName>Tests.csnamespace TildeMail.Tests.<Domain>;Examples:
tests/TildeMail.Tests/Jobs/JobSchedulerServiceTests.cs → namespace TildeMail.Tests.Jobs;tests/TildeMail.Tests/Compose/SignatureTextTests.cs → namespace TildeMail.Tests.Compose;tests/TildeMail.Tests/Support/FakeMailBackend.cs → namespace TildeMail.Tests.Support;The test folders mirror the domains (not the buckets) of TildeMail.Core, TildeMail.Mail, TildeMail.Scripting and TildeMail.Abstractions. Nothing lives in the test project root. Tests that need a real server or a real gpg carry the matching skip attribute from Support/ (LiveImapFact, GpgFact) and are named *LiveTests where they talk to a server.
11.2 Naming
Section titled “11.2 Naming”- File:
<SubjectName>Tests.cs - Class:
<SubjectName>Tests - One main test class per file.
- Test method style:
Method_Scenario_ExpectedResult. - Keep tests focused on a single behavior.
11.3 Test Support
Section titled “11.3 Test Support”- Shared fakes, builders and helpers go in
tests/TildeMail.Tests/Support/. - Do not mix reusable test infrastructure into domain test files.
- Tests do not sleep for real time: inject a
TimeProvider(ManualTimeProvider) or a short interval.
11.4 InternalsVisibleTo
Section titled “11.4 InternalsVisibleTo”Projects expose internals to the test project only:
<InternalsVisibleTo Include="TildeMail.Tests"/>TildeMail.Abstractions, TildeMail.Core, TildeMail.Scripting and TildeMail.App grant it.
12. Branching and Commits
Section titled “12. Branching and Commits”12.1 Feature Workflow
Section titled “12.1 Feature Workflow”- Open an issue first. It says what the change does, why it is wanted and how it behaves at its edges. Issues, pull requests and README text are written in the first person (“I want…”, “I’m adding…”) or in a neutral form, in English.
- Branch from
develop, namedfeat/<short-name>,fix/<short-name>orchore/<short-name>, carrying that one change and nothing else. - Open a pull request into
developthat links the issue (Closes #N). Work reachesdevelopthrough the request, never through a direct push. It is merged with a normal merge commit; then the branch is deleted and the issue closed.
Plans and design documents are never committed to the repository.
12.2 Commit Messages
Section titled “12.2 Commit Messages”- Use Conventional Commits (
feat:,fix:,refactor:,test:,docs:,chore:,style:). - Scope commits to the affected subsystem:
feat(compose):,fix(cache):,test(jobs):. - Write every commit in English, subject and body alike.
- Never add AI attribution anywhere: no
Co-Authored-Bytrailer, and no generated-with line in a commit, pull request or issue.
13. Non-Negotiable Hygiene
Section titled “13. Non-Negotiable Hygiene”- No dead code.
- No TODO comments without a tracked follow-up.
- No inconsistent naming across domains.
- Keep warnings under control; do not normalize noisy warnings. The build is at zero warnings, so a new one is a regression.
- No primary constructors on classes or ordinary structs; positional data-carrier records are the exception described in §3.
- No expression-bodied constructors or methods.
14. Additional Conventions
Section titled “14. Additional Conventions”Nullability
- Use nullable reference types consistently.
- Avoid null-forgiving (
!) unless explicitly justified.
Async naming
- Async methods must end with
Async. - Include
CancellationTokenon I/O-bound public async methods.
ConfigureAwait(false)
- Use it in
TildeMail.Core,TildeMail.ScriptingandTildeMail.Abstractions: its code may be waited on synchronously (Dispose, Lua), and a caller with aSynchronizationContext(a test runner, a future terminal interface) must not be blocked by it. - Do not use it in
TildeMail.App.
Exception handling
- Do not swallow exceptions silently.
- A user-facing failure (bad config, missing file, refused address) is a specific exception with a message that says what to do; it never carries a secret.
Collection exposure
- Expose
IReadOnlyList<>orIReadOnlyDictionary<>where mutation by callers is not intended.
No magic numbers
- Replace protocol and timing literals with named constants.
Using directives
Systemnamespaces first, then every other namespace in alphabetical order. Third-party and project namespaces are not separated.- Add using aliases when a name is ambiguous across two libraries in scope.