Skip to content
TildeMailv0.2.1

Development

This page is for working on tmail itself: the source layout, how the services start and stop, the tests, and how a release is made. Build instructions are in Install and build.

Terminal window
dotnet build TildeMail.slnx
dotnet test TildeMail.slnx

Read CODE_CONVENTION.md before changing code.

src/TildeMail.Abstractions/ shared contracts and their models, by bucket and then by domain: Chat, Commands, Config, Events,
Hosting, Input, Mail (backend/sender/factory contracts), Plugins and Search (requests);
account configuration models are in Data/Config and Types/Config
src/TildeMail.Core/ the host services, by bucket (Data, Interfaces, Services, Types, Extensions, Internal) and then by
domain: Commands (the lines typed after the colon), Config, Events, Hosting, Input, Jobs,
Localization (the texts of the interface), Plugins, Search, Secrets, State (ui.toml and the services
lock), Text (the helpers that clean text from outside), Themes, Bars (what a bar function gets and
returns); the shared helpers sit in the root Internal and Types
src/TildeMail.Mail/ the mail runtime, with the same buckets and the domains Cache (the mail cache), Compose, Gpg, Mail (IMAP,
Maildir, SMTP and the reading of MIME messages), Rules and Sync (the watching of new mail)
src/TildeMail.Scripting/ Lua engine, modules, sandbox, generated definitions and embedded prelude
src/TildeMail.App/ the tmail executable: Commands (root, ui, check, cache, rules, themes, watch, gpg, send, completion, man),
Composition (DryIoc container, RulesSession, WatchSession, SendSession, stop signals, console text, Serilog),
and the buckets Data, Host, Interfaces, Internal, Services, Types, ViewModels and Views, whose domains are
Completion (the bash, fish and zsh completion scripts), Man (the manual page tmail(1)) and
Tui (the terminal interface, see below)
tests/TildeMail.Tests/ xunit tests, mirroring the source folders; shared fakes in Support/
tests/docker-compose.yml GreenMail for the live tests
scripts/ build-binaries.sh (release binaries), build-deb.sh (Debian packages), nightly.sh (the nightly Debian packages), prepare-release.sh (next version and changelog), build-site.sh (documentation website)
.forgejo/workflows/ ci.yml, site.yml, release.yml, nightly.yml, docs.yml and nuget.yml (Forgejo Actions)

The libraries follow the layout in CODE_CONVENTION.md: the technical bucket first (Data, Interfaces, Services, Internal, Types, Extensions), the domain second, as in src/TildeMail.Core/Interfaces/Jobs/. Namespaces match folders, one type per file, no primary constructors except positional data-carrier records, Serilog through Log.ForContext<T>(), Conventional Commits.

TildeMail.Core references TildeMail.Abstractions, and TildeMail.Mail references both; Abstractions never references Core, Mail or App, and Core never references Mail. Abstractions has only DryIoc as a runtime package dependency. Its small registration extensions delegate to host-owned registries; they contain no loader, parser, database, Lua engine or protocol implementation. Architecture/AbstractionsTests checks the compiled assembly boundary, and the real DLL tests check that SDK-only plugins share host types.

TildeMail.Mail holds the mail runtime, including IMessageReader and OpenedMessage (which keeps internal MIME state) and the mail cache and index; Core holds configuration loading, lifecycle execution, the command and input services and the plugin loader. Core keeps MimeKit as a direct dependency only because ConfigLoader validates the addresses of the accounts with MimeKit.MailboxAddress.TryParse; MailKit, SQLite and ReverseMarkdown are Mail’s. Architecture/MailTests checks the assemblies, the references of the projects and the restored packages. Not every public interface belongs in Abstractions.

TildeMail.Scripting references Core and Mail and consumes their services; Core never references Scripting, Moongate.Scripting or Lua. This is an incremental extraction: the job contracts remain in Core, and the mail, GPG and rule services are in Mail. Moongate.Core still supplies configuration/theme support there. The application composes both libraries. Architecture/ScriptingTests guards the compiled dependency direction.

RegisterJobs(config) registers only the scheduler. The host calls RegisterJobScripting() to publish jobs, alongside RegisterScripting(options) for the engine and standard modules. Register both before engine startup; their relative order does not matter. RegisterStartupRegistry() installs the shared registry without replacing an existing registry or lifecycle configuration. Command presentation stays in Core behind small public domain APIs, and GPG and rule validation are in Mail; Scripting does not get access to Core internals.

The sample references Abstractions and declares Serilog explicitly. The main plugin fixture also references Abstractions; the composition-failure fixture deliberately retains Core because it tests disposal of a resource depending on the host’s AppDirectories.

Main dependencies:

  • MailKit and MimeKit: IMAP, SMTP, MIME.
  • Microsoft.Data.Sqlite: the cache and the Maildir index.
  • Serilog: logs.
  • DryIoc: the service container.
  • Moongate.Core and Moongate.Scripting: TOML helper, Lua binder on LuaCSharp.
  • ConsoleAppFramework: the command line.
  • Terminal.Gui: the views and the terminal of the interface.
  • ReactiveUI, DynamicData and System.Reactive: the view models of the interface.

The production libraries each have a README describing their purpose, dependencies and usage:

Package Responsibility
TildeMail.Abstractions Shared contracts and models, plus plugin registration APIs
TildeMail.Core Configuration, state, jobs, events, commands and host services
TildeMail.Mail Mail backends, MIME reading, composition, GPG, cache, rules and new-mail watching
TildeMail.Scripting Lua engine, modules and generated definitions

From the repository root, generate all four packages with the .NET 10 SDK:

Terminal window
dotnet pack TildeMail.slnx -c Release -o artifacts/nuget

The output contains one .nupkg and one .snupkg per library. Each package includes its own README, assembly and XML API documentation; the symbol package contains portable PDBs. Sibling libraries remain NuGet dependencies, so distribute all four packages together. The application, sample plugin and test fixtures are not packable. An ordinary build does not generate packages; CI runs an explicit pack --no-build step after the Release build.

Version, authors, repository and license metadata come from Directory.Build.props, the same version file used by the release workflow. For a temporary package version, pass -p:PackageVersion=0.1.1-local.1 to the solution pack command; this applies the same version to sibling package dependencies without editing the release version. These commands generate files only. A release pushes the packages to nuget.org itself, in the last job of release.yml, from the tag of the release, with scripts/push-nuget.sh and the repository secret NUGET_API_KEY (a nuget.org API key that may only push TildeMail.*). Without the secret the job warns and pushes nothing, and a package already on nuget.org is skipped. If the push failed, the release stays as it is: run the nuget workflow by hand from the Actions page with the tag, for example v0.1.2.

To consume the files from another project, create a NuGet.config in that consumer’s directory. Replace the local source path with the absolute path of the generated packages:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="tildemail-local" value="/absolute/path/to/TildeMail/artifacts/nuget" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="tildemail-local">
<package pattern="TildeMail.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>

Keep nuget.org enabled for third-party dependencies. In the consumer directory, install the required library using the version printed by pack in place of VERSION_FROM_PACK:

Terminal window
dotnet add package TildeMail.Core --version VERSION_FROM_PACK

Use TildeMail.Abstractions for a native plugin that only needs shared contracts, TildeMail.Mail for a host or a plugin that uses the mail services, or TildeMail.Scripting for Lua modules. DLL plugins must match the host’s shared assembly versions. The package metadata and README inclusion follow the NuGet MSBuild pack properties.

The interface is the Tui domain of src/TildeMail.App/, inside the buckets of the code convention:

ViewModels/Tui/ the state and the logic: Shell, Sidebar, MessageList, Reader, Status, Bar (the top bar and the status line),
CommandLine, Help, WhichKey, ActionRouter, and the *Actions classes (Message, Compose, Cache, SignerKey)
Views/Tui/ thin Terminal.Gui views that draw a view model and hold no logic: Main, Sidebar, MessageList, Reader,
Bar, CommandLine, HelpOverlay, WhichKey, and the pure helpers that lay text out
Host/Tui/ the terminal: TuiHost (the application loop), KeyTranslator, ThemeSchemes, DisplayText, the scheduler
Services/Tui/ UiSession (the services behind the lock), MailGateway, FreshnessService, UiRunner, TuiCommandHost,
the conversation-list adapters for mail and chat
Data/Tui/ the rows and snapshots that the view models expose (MessageRow, SidebarAccount, WhichKeyRow, ConversationListItem)
Interfaces/Tui/ IMailGateway, IMessageComposer, IUiServices
Internal/Tui/ the text builders of the windows (About, Plugins, Trust, Jobs, cache stats) and the Markdown helpers
Types/Tui/ the enums (UiModeType, PaneType, ReaderPhaseType and the like)

Three rules keep it testable:

  • A view model never touches a Terminal.Gui type. It exposes ReactiveUI properties and DynamicData collections; a view only subscribes and draws. CommandLineSurfaceTests checks that the command code references neither the toolkit nor the views, so the other commands load nothing of it.
  • The view models belong to the UI thread. Everything that awaits I/O hops back with IScheduler.HopAsync() before it touches state, including in a finally. Tests run them with a manual scheduler.
  • The services are reached through interfaces (IMailGateway, IUiServices, ITerminalHost) with fakes in tests/TildeMail.Tests/Support/, so a view model test starts no server and no terminal.

UiRunner is the only place that puts the pieces together. It builds the container, takes the services lock through UiSession, replaces ITerminalHost with TuiHost and runs the screen. TuiHost runs the application loop; when a program needs the terminal (the editor, the pinentry of gpg), it disposes the screen, runs the program and creates the screen again. It also owns SIGTERM, SIGHUP and SIGINT while the screen is up, and ends the process by itself if the terminal has gone away and the screen does not close within five seconds.

The interface reads every key through the Core input layer (KeyTranslator turns a toolkit key into a chord, the dispatcher answers with an action or a Lua function) and swallows the rest, so Esc and Ctrl-Z keep the meaning the bindings give them.

AppContainer.Build registers the services that need no configuration: the hosting services, the event hub, the configuration loader, the value expander, a plain mail backend factory, gpg (IGpgService, PgpMimeBuilder, IMessageReader), the notifier, the editor launcher, and the theme loader and resolver.

AppContainer.RegisterAccounts runs once the file is loaded. It adds the cache factory, the caching mail backend factory (which replaces the plain one), the job scheduler, the script engine with its modules and the new-mail watcher.

Resolving a service starts nothing: connecting, watching, scheduling and loading scripts are explicit steps of the caller. tmail watch takes those steps in the order described under tmail watch.

The services that run for the whole life of the program implement IStartupService (StartAsync(ct), StopAsync(ct)) and are registered with AddStartupService<...>(priority), after Moongate’s startup services. IServiceLifecycle starts them in ascending priority and stops them in reverse, once:

Priority Service Start Stop
Backends (0) mail backend factory nothing closes the connections, last of all, after the watcher that used them
Events (10) event hub starts delivering (events published before wait) drains what is waiting, up to 5 seconds
Scripting (20) script engine runs init.lua, subscribes to the hub runs the Lua shutdown handlers for up to 5 seconds, then disposes
Jobs (30) job scheduler starts the workers stops them, waiting up to jobs.stop_timeout_seconds (the lifecycle waits two seconds longer)
Watching (40) new-mail watcher watches the folders of the accounts that list watch stops the watches, then disposes the watcher

tmail watch also registers a small start guard at the Scripting priority, which stops the start when init.lua failed.

How the lifecycle behaves:

  • A failed start. A service whose start fails, or a start that is cancelled, makes the lifecycle stop what started (the failed service included) and throw the original exception.
  • Stopping. Stopping also cancels a start that is still running, so a hung start cannot hold the shutdown. Every started service gets its stop call, in order, even when the token of the stop is already cancelled; the token only asks it to hurry.
  • Time limits. A stop that throws, hangs or ignores cancellation costs at most the service’s own StopBudget (10 seconds by default, ServiceLifecycleOptions.StopTimeout) plus one second of grace, and never keeps the next service from stopping. All the failures are returned.
  • Resolution. Services are resolved when the lifecycle starts, not when they are registered. A service registered under several contracts starts once, and a lifecycle that was stopped does not start again.
  • Order matters. Because the scheduler stops before the engine, a Lua shutdown handler cannot queue a job.

To add a service: implement IStartupService, keep the constructor free of work, and register it with a priority from ServicePriorityType.

Tests run with dotnet test TildeMail.slnx.

Three xunit collections matter when you add tests:

  • Processes (ProcessTests.CollectionName) for every class that starts a child process, because two at once can fail with ETXTBSY;
  • LiveMail (LiveMailServer.CollectionName) for the live IMAP and SMTP tests, which share one GreenMail stack;
  • LogCapture for tests that swap the static Serilog logger through LogCapture; they never run in parallel with the rest.

Tests that need gpg use [GpgFact] and are skipped when it is not on PATH. Tests that need tmux use [ShellFact("tmux")] and are skipped without it. Live tests use [LiveImapFact] and are skipped without the TMAIL_TEST_IMAP_* variables.

The Tui*EndToEndTests classes run the built program in a real terminal. TmuxSession (in Support/) starts a detached tmux session of a given size on a private socket, so it never touches your own tmux, with HOME set to a throw-away folder that TuiHome fills with a configuration, two Maildir accounts and, when a test asks, an init.lua. A test sends keys (Keys, Type), reads the screen (Capture, CaptureWithEscapes), waits for text (WaitForAsync), resizes the terminal and sends signals. They need tmux and flock on PATH:

Terminal window
dotnet test tests/TildeMail.Tests --filter "FullyQualifiedName~EndToEndTests"

The whole class takes about a minute. When one fails, the message holds the last screen.

The tests in ImapLiveTests, ImapKeywordLiveTests, SmtpLiveTests, ImapWatchLiveTests, ImapReconnectLiveTests and CachedMailBackendLiveTests run against a real server. They are skipped unless these environment variables are set:

Variable
TMAIL_TEST_IMAP_HOST required
TMAIL_TEST_IMAP_USER required
TMAIL_TEST_IMAP_PASSWORD required
TMAIL_TEST_IMAP_PORT optional, default 3143

I use GreenMail in Docker, which listens on 127.0.0.1 only and creates the user tmail / secret:

Terminal window
docker compose -f tests/docker-compose.yml up -d
export TMAIL_TEST_IMAP_HOST=127.0.0.1
export TMAIL_TEST_IMAP_USER=tmail
export TMAIL_TEST_IMAP_PASSWORD=secret
export TMAIL_TEST_IMAP_PORT=3143
dotnet test TildeMail.slnx
docker compose -f tests/docker-compose.yml down

These tests create folders and append messages on the server, and some of them run docker compose restart on the tests/docker-compose.yml stack. Point the variables only at that GreenMail container, never at a real mailbox.

  • The compose file publishes IMAP on 3143, SMTP on 3025, IMAPS on 3993 and SMTPS on 3465, so those ports must be free. SMTP tests always use port 3025 on the same host.
  • The reconnect and watch tests restart the container, and a cache test stops and starts it, through docker compose, so they need the docker CLI.
  • GreenMail keeps everything in memory: each test seeds its own messages, and down throws the mailbox away.
  • GreenMail has no STARTTLS and a self-signed certificate, so the tests use plain connections (port 3143) and only check that the TLS ports reject the certificate.

Forgejo Actions workflows live in .forgejo/workflows/:

  • ci.yml builds and tests TildeMail.slnx on every push and pull request to develop and main. The live IMAP tests need the GreenMail container and are skipped there.
  • release.yml runs when main is pushed, and by hand from the Actions page. Two runs never overlap.

A third workflow, docs.yml, publishes the documentation website by hand, and the last job of release.yml publishes it after each release: see Writing documentation.

THIRD-PARTY-NOTICES.md lists every NuGet package the solution ships with (test projects left out), with its version, copyright, license and project URL, then the text of each license. It is generated, not written: run scripts/generate-third-party-notices.sh after you add, remove or update a package, and commit the result. The script also writes THIRD-PARTY-NOTICES.txt, the same content as plain text, and copies it to src/TildeMail.App/Assets/, where it is an embedded resource: the About window reads it with ResourceUtils and shows it after the license. It needs jq and nuget-license (dotnet tool install -g nuget-license --version 4.0.18).

ci.yml runs scripts/generate-third-party-notices.sh --check after the build and fails when one of the three files is out of date. The license texts are in scripts/third-party/licenses/<SPDX id>.txt. A package under a license that has no text there stops the script: add the text (from https://spdx.org/licenses/) once you have decided the license is acceptable. The release archives and the Debian packages carry the file (/usr/share/doc/tmail/THIRD-PARTY-NOTICES.md).

Merge develop into main with a normal merge commit and push. Nothing else is done by hand: do not edit CHANGELOG.md or the version in Directory.Build.props.

To see what the next release would be, run scripts/prepare-release.sh --dry-run (it needs git-cliff).

The release is semantic. git-cliff, configured in cliff.toml, works out the next version from the Conventional Commits since the last v* tag, through scripts/prepare-release.sh:

Commits since the last tag Bump
a breaking change (! after any type, or a BREAKING CHANGE: footer) major, or minor while the version is 0.x
a feat minor
a fix or perf patch
none of these no release; the job ends green
  1. Rewrites the four version properties in Directory.Build.props, regenerates CHANGELOG.md, commits chore(release): vX.Y.Z [skip ci], tags it and pushes both to main.
  2. Runs scripts/build-binaries.sh and scripts/build-deb.sh.
  3. Creates the release with the changelog entry as its notes, the archives, the .deb files and a SHA256SUMS covering all of them. The release stays a draft until every file is attached.
  4. Uploads the .deb files to the Codeberg Debian registry (distribution stable, component main).
  5. Merges main back into develop, so that the changelog and the version are there too. If that merge or its push fails, the job warns and leaves it to you.

If a run fails after the tag was pushed, re-run that job from the Actions page, or start the release workflow again on main: either way it finds the tag, builds and publishes that same commit, and replaces a half-filled draft release. A release that is already published is left alone.

The repository needs Actions and Packages enabled, a runner with the label docker, and a secret RELEASE_TOKEN: an access token with write:repository and write:package. main and develop must accept pushes from that account.