A mail plugin
This page shows how a plugin adds a mail protocol: a store, which reads a mailbox, or a transport, which sends mail. It ends with the middlewares and decorators, which change what the stores and transports of every account do.
1. Write the factory
Section titled “1. Write the factory”Tilde never creates a store itself. For each [[accounts.mail]] it asks the factory whose Protocol is the protocol of [accounts.mail.store], ignoring case. A factory implements IConnectionFactory<IMailStore>. From MboxMailStoreFactory.cs, without its logger and its summary comments:
public sealed class MboxMailStoreFactory : IConnectionFactory<IMailStore>{ private readonly IEventHubService _hub;
/// <inheritdoc /> public string Protocol => "mbox";
public MboxMailStoreFactory(IEventHubService hub) { _hub = hub; }
/// <inheritdoc /> public IMailStore Create(string accountId, ProtocolSettings settings) { var mbox = MboxSettings.From(settings);
return new MboxMailStore(accountId, mbox, _hub); }}Createreturns a new store that is not connected yet. Tilde connects it later.- The container fills the constructor, so the factory can ask for services such as the event hub.
settingsholds what the account wrote: how to read it is on Configuration sections.tcli checkcallsCreatefor every account, without connecting, and shows the message of an exception thrown there as the result of that part. Name the key in the message, never the value: the value may be a secret.
2. Register it
Section titled “2. Register it”In RegisterAsync, register the factory with AppendNotKeyed, so it sits next to the factories of the other plugins:
container.Register<IConnectionFactory<IMailStore>, MboxMailStoreFactory>( Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.AppendNotKeyed);A transport is the same with IConnectionFactory<IMailTransport> and the protocol of [accounts.mail.transport], as in SmtpPlugin.cs. When no factory has the protocol, the account is unavailable with No plugin handles the mail store protocol '<protocol>' of the account '<id>'., and the other accounts go on.
3. Write the store
Section titled “3. Write the store”The store implements IMailStore, which extends IConnection (State, ConnectAsync, DisconnectAsync, DisposeAsync):
| Method | What it does |
|---|---|
ListFoldersAsync |
lists the folders of the mailbox |
GetFolderAsync |
gets one folder with its unread and total counts |
ListMessagesAsync |
lists a window of a folder, newest first, without bodies; position 0 is the newest |
GetMessageAsync |
loads a whole message, with its body and attachments |
GetRawMessageAsync |
opens the message as it is stored (RFC 822) |
GetAttachmentAsync |
opens one attachment |
CreateFolderAsync, RenameFolderAsync, DeleteFolderAsync |
change the folders |
SetFlagsAsync |
adds and removes flags, and returns the flags the message has after the change |
MoveMessageAsync, CopyMessageAsync |
return the id of the message in the target folder |
AppendMessageAsync |
saves a whole message into a folder |
ExpungeAsync |
removes the messages flagged as deleted |
The contract, from the XML documentation of IMailStore:
- A message id is an opaque string that belongs to one folder. It does not change when the flags change.
- An unknown folder or message throws
KeyNotFoundException. Creating or renaming to a folder that exists, or renaming or deleting the inbox, throwsInvalidOperationException. Moving or copying to the same folder throwsArgumentException. - The store publishes a
ConnectionStateChangedEventon the event hub each time itsStatechanges.MboxMailStoredoes it in itsSetStateAsync. - The store does not publish the events of its own operations, such as a folder created or flags set: a middleware of
Tilde.Corepublishes them after each operation that changed something and worked. What the store learns from the server, such as new mail (MailMessageReceivedEvent), it publishes itself, asImapMailStore.csdoes.
The mbox store reads only: every method that would change a file throws NotSupportedException. A transport implements one method more than IConnection, IMailTransport.SendAsync(MailDraft). When the draft has a PreparedMessage (a message that OpenPGP signed or encrypted), the transport sends those bytes as they are and takes only the sender and the recipients from the draft.
Middlewares
Section titled “Middlewares”A middleware sits around the store, the transport or the chat client of every account. Each method gets the next one in the chain: not calling it blocks the operation, and changing the arguments or the result changes it. Register it in RegisterAsync with AppendNotKeyed, like a factory. This one refuses a message with no subject; I built it against IMailTransportMiddleware:
public sealed class SubjectRequiredMiddleware : IMailTransportMiddleware{ public ValueTask SendAsync(IMailTransport next, MailDraft draft, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(draft.Subject)) { throw new InvalidOperationException("A message needs a subject."); }
return next.SendAsync(draft, cancellationToken); }}container.Register<IMailTransportMiddleware, SubjectRequiredMiddleware>( Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.AppendNotKeyed);A middleware that changes a draft copies it with a with expression. A draft built again field by field loses the fields it does not know, such as the security options, without any error.
The order of a chain, from the outside in (AccountService.cs). The middlewares of the plugins run in the order they were registered, and the first one is the outermost:
- Transport: the OpenPGP key of the account, the middlewares of the plugins, the one that publishes the sent message, the one that refuses a draft that asked for protection and did not get it, then the transport.
- Store: the middlewares of the plugins, the one that publishes the mail events, the decorators, then the store.
IMailStoreMiddleware has one method for each method of IMailStore. The OpenPGP plugin registers PgpTransportMiddleware this way.
Decorators
Section titled “Decorators”An IMailStoreDecorator wraps the whole store of an account in another store, for example to serve it from a cache. Decorate(accountId, store, inner), where store is the settings of the store, returns the new store, which owns inner, or null to leave the store as it is. Decorators apply in the order they were registered. The mail cache is one: see CachePlugin.cs.
Next: a chat plugin, or test the plugin.