Skip to content
TildeMailv0.2.1

Plugins

TildeMail can load explicitly enabled .NET plugins into the tmail process. Plugins are trusted code: they run with the same permissions as TildeMail and are not sandboxed like Lua scripts.

The sample project targets .NET 10, references the matching TildeMail.Abstractions project, and enables dynamic loading. From the repository root, publish it into the user plugin directory:

Terminal window
dotnet publish samples/TildeMail.Sample.Plugin/TildeMail.Sample.Plugin.csproj \
-c Release \
-o "$HOME/.config/tildemail/plugins/TildeMail.Sample.Plugin"

The bundle directory must contain TildeMail.Sample.Plugin.dll, its .deps.json file, and the published dependencies. The loader uses the assembly dependency metadata to resolve managed and native bundle dependencies.

Add this top-level table to ~/.config/tildemail/config.toml:

[plugins]
enabled = ["TildeMail.Sample.Plugin"]

Put [plugins] outside any [[account]] table. Plugins are disabled by default. The enabled value is the bundle directory and DLL name, not the plugin metadata ID. Remove the entry from enabled to stop loading the plugin. Restart tmail after installing a bundle or changing this setting; there is no hot reload.

When started by the terminal interface or tmail watch, the sample logs Sample plugin started and, at shutdown, Sample plugin stopped. Other command paths do not load plugins.

The sample registers sample.hello, which displays Hello from the sample plugin., and <leader>p invokes it. Run plugins.show in the terminal interface to confirm the plugin’s loaded metadata.

The greeting comes from [sample] in config.toml. If that section is absent, loading the sample appends its validated default without rewriting the rest of the file:

[sample]
greeting = "Hello from the sample plugin."

Reference the TildeMail.Abstractions version that matches the host and enable dynamic loading in the plugin project:

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/TildeMail.Abstractions/TildeMail.Abstractions.csproj" />
</ItemGroup>

The SDK has only DryIoc as a runtime package dependency. Add explicit package references for anything else the plugin uses: the sample declares Serilog for its lifecycle log messages. Plugin projects need no reference to Core for backend contracts, command/keybinding registration, events or startup services.

Plugins built before the extraction must be rebuilt: the shared types now live under TildeMail.Abstractions.*, and the old namespace and assembly identities are not forwarded. Use the matching host revision when rebuilding.

An assembly can expose one or more public, concrete ITildeMailPlugin entry classes. Each needs a public parameterless constructor and metadata with a non-empty ID, name, and version. IDs must be unique among the enabled plugins (case-insensitive). Register should only add services and plugin contributions to the host container:

using DryIoc;
using TildeMail.Abstractions.Data.Plugins;
using TildeMail.Abstractions.Extensions.Hosting;
using TildeMail.Abstractions.Interfaces.Plugins;
using TildeMail.Abstractions.Types.Hosting;
using TildeMail.Sample.Plugin.Services;
namespace TildeMail.Sample.Plugin;
public sealed class SamplePlugin : ITildeMailPlugin
{
public PluginMetadata Metadata { get; } = new("tildemail.sample", "Sample plugin", new Version(1, 0, 0));
public void Register(IContainer container)
{
container.AddStartupService<SampleStartupService>(ServicePriorityType.Jobs);
}
}

Internal plugins and required dependencies

Section titled “Internal plugins and required dependencies”

Hosts can register plugins compiled into the application without publishing a DLL bundle. Import TildeMail.Core.Extensions.Plugins and use the same ITildeMailPlugin contract:

container.RegisterPlugin<InternalPlugin>();
container.RegisterPlugin(new ConfiguredPlugin(settings));
container.RegisterPlugins(new ConsumerPlugin(), new ProviderPlugin());

These class names represent host-defined plugin types. The generic overload requires a public parameterless constructor; the instance overload accepts constructor arguments supplied by the host. Registration is immediate. Set up required host registries, including configuration and lifecycle, first; register internal plugins before loading DLL bundles and before resolving or starting application services. Command and keybinding contribution storage is installed automatically.

Internal plugins and DLL plugins use one PluginRegistry per container. IDs are case-insensitive and unique across both. PluginRegistry.Plugins, PluginLoader.Plugins after loading, and plugins.show include both sources in registration order, including when no DLL bundles are enabled. Existing snapshots remain read-only and unchanged by later successful registrations.

A plugin declares required IDs, optionally with an inclusive minimum version:

public PluginMetadata Metadata { get; } = new("example.consumer", "Consumer", new Version(1, 0))
{
Dependencies = [new PluginDependency("example.provider", new Version(1, 2))]
};

Import TildeMail.Abstractions.Data.Plugins. The three-argument metadata constructor remains valid; dependencies default to an empty list. The list is copied on assignment. A dependency must already be registered or appear in the same batch. RegisterPlugins(...) is one batch; every enabled DLL bundle is part of one loader batch. Thus a DLL plugin can depend on an internal plugin, and bundle configuration order need not put providers first. An internal plugin registered separately cannot depend on a DLL loaded later.

The entire batch is checked before any Register callback: missing dependencies, insufficient versions, repeated IDs, self-dependencies and cycles fail with the relevant plugin ID and bundle name when applicable. Versions use System.Version ordering, so 1.0 is lower than 1.0.0; use consistent component counts. A stable depth-first traversal visits plugins and dependency lists in their supplied order, registering every provider before its consumers. Metadata is captured before callbacks.

A callback failure is terminal: the registry refuses further registrations because the container may contain partial contributions. Reentrant registration is rejected. Dispose and rebuild the host after a failed load. Dependency declarations do not install bundles, isolate untrusted code, start services or alter lifecycle priority. Shared service contracts still belong in host-shared assemblies; declaring a plugin dependency does not make another bundle’s private types available.

Import TildeMail.Abstractions.Extensions.Config and register a class with a public parameterless constructor and public properties. AddConfig<T> returns the same singleton that services receive through constructor injection. Implement IConfigSection when the values need validation:

using TildeMail.Abstractions.Interfaces.Config;
public sealed class GreetingConfig : IConfigSection
{
public string Greeting { get; set; } = "Hello from the plugin.";
public void Validate()
{
ArgumentException.ThrowIfNullOrWhiteSpace(Greeting);
}
}

Inside the plugin’s Register method:

// Import TildeMail.Abstractions.Extensions.Config in the plugin file.
var config = container.AddConfig<GreetingConfig>("greeting");

This reads [greeting]. Property names use lower snake case (ServerName becomes server_name); nested classes can describe nested tables. Section names are case-sensitive, begin with an ASCII letter and contain only letters, digits, _ or -. The argument identifies one top-level table, not a dotted path. Host keys (account, cache, jobs, keys, plugins, theme, language, log_level), duplicate section names, and already registered configuration types are rejected.

Present sections are deserialized and validated without being rewritten. Missing sections use the class defaults, validate them and then append them. Existing bytes, comments, encoding and line endings are preserved. If the file is unwritable, missing, or changed since loading, the host logs a warning and keeps validated defaults in memory. It never replaces an edited file or recreates a deleted one. An invalid section stops plugin loading before service startup; parser and validator exception details are withheld because they may contain secrets.

Values, including ${VARIABLE} and #{command} placeholders, remain literal in plugin sections. AddConfig does not resolve credentials, start services or reload settings. Constructors, property accessors and Validate should only operate on configuration data. Restart to apply edits.

The normal app installs IConfigSections before loading plugins. A standalone host imports TildeMail.Core.Extensions.Config and calls container.RegisterConfigSections(config) with the loaded TildeMailConfig, or container.RegisterConfigSections(path, toml) with its own already-read snapshot. The loaded-config overload retains an existing gateway and does nothing for a programmatic config with no source snapshot; use the explicit overload for that case. An unconfigured host gets a clear RegisterConfigSections error. Plugins themselves only need Abstractions.

After plugin registration, IConfigSections.Warnings checks the original document against both host and registered plugin schemas; the app writes these warnings to its log. TildeMailConfig.Warnings remains the initial-load result. tmail check stays read-only and never executes plugins, so it cannot validate their schemas and may report their tables as unknown. Certificate and language-file warnings are unchanged.

The schema audit understands typed properties and nested configuration classes. Dictionary-valued properties can deserialize correctly but currently produce an unknown-table warning; this warning does not prevent loading.

Plugins can add native commands and default key bindings from Register. Import the extension namespace, and import the command types when returning a CommandOutcome:

The snippet uses ShowGreeting, RefreshAsync, and SearchAsync as methods supplied by the plugin; they are illustrative placeholders, not TildeMail APIs.

using TildeMail.Abstractions.Data.Commands;
using TildeMail.Abstractions.Extensions.Plugins;
using TildeMail.Abstractions.Types.Input;
public void Register(IContainer container)
{
container.RegisterCommand("sample.hello", () => ShowGreeting(), "Show a greeting");
container.RegisterCommand("sample.refresh", async () => await RefreshAsync(), "Refresh data");
container.RegisterCommand(
"sample.search",
async (arguments, cancellationToken) =>
{
await SearchAsync(arguments, cancellationToken);
return CommandOutcome.Ok("Search complete");
},
"Search plugin data"
);
container.RegisterKeyBinding("<leader>p", "sample.hello");
container.RegisterKeyBinding("<leader>x", () => ShowGreeting(), description: "Show a greeting");
container.RegisterKeyBinding(
"<leader>r", async () => await RefreshAsync(), KeyContextType.List, "Refresh data"
);
}

RegisterCommand accepts an Action, a Func<Task>, or a Func<IReadOnlyList<string>, CancellationToken, Task<CommandOutcome>>. The first two forms take no command-line arguments. The argument-aware form receives arguments entered with the : command; a named key binding invokes the command with an empty argument list, but can still use its cancellation token and return a custom outcome. Key binding callbacks accept an Action or Func<Task>. The optional key context is Global by default and can be Sidebar, List, or Reader; a description can be supplied for help and which-key display. Numeric count prefixes affect input actions only; native command and callback bindings run once.

The normal host installs IPluginContributions and IStartupRegistry before calling plugins. When constructing a standalone test host, call RegisterPluginContributions() from TildeMail.Core.Extensions.Plugins and RegisterHosting() from TildeMail.Core.Extensions.Hosting first, or supply implementations of those contracts. Calling the SDK registration helpers on an unconfigured container reports the missing host setup. Plugins themselves only use the SDK extensions; they do not initialize or replace the host registries.

Register these contributions during plugin registration. Registration records delegates and metadata only: it does not invoke handlers or resolve or start application services. Resolve services inside a handler when it runs.

Command names are 1–64 characters, made of lowercase dotted segments. Each segment starts with a lowercase letter and may continue with lowercase letters, digits, _, or - (for example, sample.hello). Duplicate native names and names reserved for built-in commands or input actions are rejected. Built-ins and actions take precedence over native commands, which take precedence over Lua commands; native names are available in command help and completion. Lua commands with the same name are shadowed by the native command.

Plugin key bindings are defaults. Within each context, preset bindings are applied first, plugin defaults next, and Lua bindings last. A Lua binding can replace or unbind a plugin default until key bindings reload; reload restores preset and plugin defaults before applying Lua configuration again. The configured leader key works in plugin notation, including <leader>p. Conflicting plugin defaults are rejected. Lua keys.bind can also target a registered native command by name, such as keys.bind('list', '<leader>p', 'sample.hello').

A named key binding can be registered before its command. If the command is still missing when the key is invoked, TildeMail reports the usual clear unknown command failure. Native command registrations do not execute during plugin loading.

The built-in plugins.show command takes no arguments and opens a panel with each loaded plugin’s ID, name, and version. If none are loaded, the panel says No plugins are loaded. It is available even when plugins are disabled.

Implement background work in an IStartupService registered with AddStartupService. The host starts services in priority order and stops them in reverse, using the existing lifecycle. Constructors and Register must not start work or create subscriptions. Async resources owned by a startup service should be released in StopAsync; container disposal does not await services that implement only IAsyncDisposable.

The terminal interface starts plugin services only when it can take TildeMail’s services lock. In reading mode, plugin services do not start. tmail watch uses the same lifecycle and lock. Registration errors, missing bundles, or invalid plugin assemblies abort the start of the terminal interface and of tmail watch; other CLI paths remain usable so the configuration or bundle can be repaired.

Bundle names may contain ASCII letters, digits, dots, hyphens, and underscores; . and .. are rejected, as are case-insensitive duplicate names.

The loader shares the host’s TildeMail.Abstractions, TildeMail.Core, TildeMail.Mail, TildeMail.Scripting, DryIoc, Serilog, and their transitive non-framework dependencies. Shared assemblies must match the host’s version, culture, and public-key token. Framework references use the default CLR compatibility policy rather than requiring the same runtime version; a System.* dependency not available from the host can resolve privately from the bundle. Other managed and native dependencies resolve from the published bundle through AssemblyDependencyResolver. On shutdown, the loader requests unload after the container is disposed but does not force garbage collection. Plugin dependencies control registration order; they do not share private assemblies between bundle load contexts. Matrix, XMPP and IRC integrations are not included.

A plugin that uses the mail services (for example IMessageReader, or MimeKit and MailKit types in what it hands to them) references TildeMail.Mail. The normal application host shares the Mail assembly and its transitive dependencies (MailKit, MimeKit, SQLite, ReverseMarkdown), so the plugin resolves the host’s own services and its types are the host’s types. A plugin that asks for an incompatible version of one of them is rejected before it registers.

A standalone host constructing PluginLoader must pass typeof(IMessageReader).Assembly (the Mail assembly) as an additional shared assembly when it registers the mail services. Core’s loader does not reference Mail itself: without it a plugin built against Mail loads a private copy, whose types are not the host’s, and is rejected when it asks the container for a mail service.

The mail runtime left Core: the namespaces TildeMail.Core.<Bucket>.Mail, .Compose, .Gpg, .Cache, .Rules and .Sync are now TildeMail.Mail.<Bucket>.<Domain> (for example TildeMail.Core.Interfaces.Mail.IMessageReader is TildeMail.Mail.Interfaces.Mail.IMessageReader), and DisplayTextExtensions and ControlCharacters are public helpers of Core in TildeMail.Core.Extensions.Text and TildeMail.Core.Services.Text. A plugin that uses them must update its using lines and be rebuilt; the old namespaces and assembly identities are not forwarded.

A scripting-enabled plugin references TildeMail.Scripting and registers its event during Register:

// Import TildeMail.Scripting.Extensions.
container.AddScriptEvent("example_message");

Names use ASCII lower_snake_case: start with a lowercase letter, continue with lowercase letters or digits, and use single underscores between words. Built-in names and duplicate registrations are rejected. Registration works before or after RegisterScripting, but closes when the engine first binds. Restart the host to change the set of names; script reload preserves it and clears old handlers. Registered names also appear in generated definitions.lua.

A running plugin service publishes through the existing IEventHub from Abstractions:

hub.Publish(AppEvent.Of("example_message", ("channel", "general"), ("text", "Hello")));

Import TildeMail.Abstractions.Data.Events and TildeMail.Abstractions.Interfaces.Events. Lua subscribes normally:

events.on("example_message", function(e)
sys.notify(e.channel, e.text)
end)

Publishing remains nonblocking. The host’s event queue delivers the payload to the script engine, which serializes execution and applies its existing instruction budget and error isolation. Registration defines the name, not a typed payload schema; use the field types supported by AppEvent. No new event bus is required.

A plugin that supplies a Lua module also references TildeMail.Scripting and uses TildeMail.Scripting.Extensions.AddScriptModule<T>() during registration. Mark the module with Moongate’s [ScriptModule] and its functions with [ScriptFunction]. The normal application host explicitly shares the Scripting assembly and its transitive dependencies, including Moongate.Scripting and Lua, so module attributes, registries and Lua values retain the host’s type identity.

A standalone host constructing PluginLoader must pass typeof(IScriptEngine).Assembly as an additional shared assembly when supporting Lua modules. Core’s loader does not reference Scripting itself. Native-only plugins continue to reference Abstractions alone.

The extraction changes TildeMail.Core.Scripting.* namespaces to TildeMail.Scripting.*. Rebuild affected plugins against the new project; no type forwarders are provided. Hosts that previously relied on RegisterJobs to publish Lua modules now also call RegisterJobScripting before starting the engine.

Namespaces now put the technical bucket before the domain: TildeMail.Abstractions.Plugins.Interfaces is now TildeMail.Abstractions.Interfaces.Plugins, and TildeMail.Core.Hosting.Extensions is now TildeMail.Core.Extensions.Hosting. The same applies to Data, Services, Types and Internal. A plugin must update its using lines and be rebuilt; the old namespaces are not forwarded.