Skip to content
TildeMailv0.2.1

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).

  • 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.

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;

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.

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.

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.

  • One .cs file must contain at most one primary type (class, record, or enum).
  • 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 record and record struct declarations 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.

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:

Terminal window
jb cleanupcode TildeMail.slnx --profile="CSharp formatting only" --include="**/*.cs" --no-build
jb cleanupcode TildeMail.slnx --profile="XML documentation only" --no-build

The first applies indentation, spacing, wrapping and XML documentation layout without code cleanup or member reordering; the second applies the XML documentation layout alone.

Inside a type, use this order:

  1. const fields
  2. private readonly fields (prefixed _)
  3. Non-readonly fields
  4. Properties
  5. Constructor(s)
  6. Public methods
  7. Protected methods
  8. Private methods
  9. Dispose/finalization methods (always last)

All private readonly fields must start with _:

private readonly IMailBackendFactory _backends;
private readonly TimeProvider _time;

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.

  • Interfaces live only under Interfaces namespaces, whatever their visibility.
  • Every interface, and every member it declares, carries XML docs (///) written in English.
  • Interface names must use I prefix and clear domain naming.

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.

  • Enums must live under a Types namespace, 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 HasFlag cannot answer true for an unset value.
Types/Jobs/JobStateType.cs
namespace TildeMail.Core.Types.Jobs;
public enum JobStateType { ... }
// Types/Config/ConnectionSecurityType.cs
namespace TildeMail.Abstractions.Types.Config;
public enum ConnectionSecurityType { ... }
  • Empty strings have no imposed form. Neither "" nor string.Empty is the standard: leave whichever a file already uses and do not convert in either direction.
  • Use Serilog statically via Log.ForContext<T>(). Do not inject ILogger<T> via DI.
  • Declare the logger as a private readonly field 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; where Microsoft.Extensions.Logging is 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.
  • Services are registered as singletons in DryIoc (AppContainer in TildeMail.App, and the *ContainerExtensions of 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.ThrowIfNull and friends.
  • A singleton’s constructor runs only when something resolves it. Start work (threads, timers, watchers, subscriptions) in an explicit Start/StartAsync and undo it in Stop/Dispose, never in the constructor.
  • A service that owns threads, timers or connections implements IDisposable or IAsyncDisposable and stops its work there.
  • A Lua module is a class under TildeMail.Scripting/Modules marked [ScriptModule], with [ScriptFunction] methods and [ScriptConstant] fields. It is registered with AddScriptModule<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 IScriptResettable so a reload clears it.
  • definitions.lua is generated from the module metadata; do not edit it by hand.
tests/TildeMail.Tests/<Domain>/<SubjectName>Tests.cs
namespace 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.

  • 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.
  • 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.

Projects expose internals to the test project only:

<InternalsVisibleTo Include="TildeMail.Tests"/>

TildeMail.Abstractions, TildeMail.Core, TildeMail.Scripting and TildeMail.App grant it.

  1. 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.
  2. Branch from develop, named feat/<short-name>, fix/<short-name> or chore/<short-name>, carrying that one change and nothing else.
  3. Open a pull request into develop that links the issue (Closes #N). Work reaches develop through 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.

  • 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-By trailer, and no generated-with line in a commit, pull request or issue.
  • 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.

Nullability

  • Use nullable reference types consistently.
  • Avoid null-forgiving (!) unless explicitly justified.

Async naming

  • Async methods must end with Async.
  • Include CancellationToken on I/O-bound public async methods.

ConfigureAwait(false)

  • Use it in TildeMail.Core, TildeMail.Scripting and TildeMail.Abstractions: its code may be waited on synchronously (Dispose, Lua), and a caller with a SynchronizationContext (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<> or IReadOnlyDictionary<> where mutation by callers is not intended.

No magic numbers

  • Replace protocol and timing literals with named constants.

Using directives

  • System namespaces 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.