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.
Quick start
Section titled “Quick start”dotnet build TildeMail.slnxdotnet test TildeMail.slnxRead CODE_CONVENTION.md before changing code.
Source layout
Section titled “Source layout”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/Configsrc/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 Typessrc/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 preludesrc/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 testsscripts/ 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.
Dependency boundary
Section titled “Dependency boundary”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.
NuGet packages
Section titled “NuGet packages”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:
dotnet pack TildeMail.slnx -c Release -o artifacts/nugetThe 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:
dotnet add package TildeMail.Core --version VERSION_FROM_PACKUse 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 terminal interface
Section titled “The terminal interface”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 outHost/Tui/ the terminal: TuiHost (the application loop), KeyTranslator, ThemeSchemes, DisplayText, the schedulerServices/Tui/ UiSession (the services behind the lock), MailGateway, FreshnessService, UiRunner, TuiCommandHost, the conversation-list adapters for mail and chatData/Tui/ the rows and snapshots that the view models expose (MessageRow, SidebarAccount, WhichKeyRow, ConversationListItem)Interfaces/Tui/ IMailGateway, IMessageComposer, IUiServicesInternal/Tui/ the text builders of the windows (About, Plugins, Trust, Jobs, cache stats) and the Markdown helpersTypes/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.
CommandLineSurfaceTestschecks 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 afinally. Tests run them with a manual scheduler. - The services are reached through interfaces (
IMailGateway,IUiServices,ITerminalHost) with fakes intests/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.
Composition
Section titled “Composition”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.
Service lifecycle
Section titled “Service lifecycle”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
shutdownhandler 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 withETXTBSY;LiveMail(LiveMailServer.CollectionName) for the live IMAP and SMTP tests, which share one GreenMail stack;LogCapturefor tests that swap the static Serilog logger throughLogCapture; 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.
End-to-end tests of the interface
Section titled “End-to-end tests of the interface”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:
dotnet test tests/TildeMail.Tests --filter "FullyQualifiedName~EndToEndTests"The whole class takes about a minute. When one fails, the message holds the last screen.
Live IMAP and SMTP tests
Section titled “Live IMAP and SMTP tests”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:
docker compose -f tests/docker-compose.yml up -d
export TMAIL_TEST_IMAP_HOST=127.0.0.1export TMAIL_TEST_IMAP_USER=tmailexport TMAIL_TEST_IMAP_PASSWORD=secretexport TMAIL_TEST_IMAP_PORT=3143
dotnet test TildeMail.slnx
docker compose -f tests/docker-compose.yml downThese 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 thedockerCLI. - GreenMail keeps everything in memory: each test seeds its own messages, and
downthrows 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.
Continuous integration and releases
Section titled “Continuous integration and releases”Forgejo Actions workflows live in .forgejo/workflows/:
ci.ymlbuilds and testsTildeMail.slnxon every push and pull request todevelopandmain. The live IMAP tests need the GreenMail container and are skipped there.release.ymlruns whenmainis 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
Section titled “Third-party notices”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).
Cutting a release
Section titled “Cutting a release”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).
How the version is chosen
Section titled “How the version is chosen”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 |
What the release job does
Section titled “What the release job does”- Rewrites the four version properties in
Directory.Build.props, regeneratesCHANGELOG.md, commitschore(release): vX.Y.Z [skip ci], tags it and pushes both tomain. - Runs
scripts/build-binaries.shandscripts/build-deb.sh. - Creates the release with the changelog entry as its notes, the archives, the
.debfiles and aSHA256SUMScovering all of them. The release stays a draft until every file is attached. - Uploads the
.debfiles to the Codeberg Debian registry (distributionstable, componentmain). - Merges
mainback intodevelop, 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.
Repository setup
Section titled “Repository setup”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.