PouryaKit
Capabilities

Email

Send a plain-text mail from a Notification Handler through one contract, on the Host's MailKit sender configured once from validated options.

PouryaKit.Email gives a Module one contract, IEmailSender, that sends a plain-text mail to one recipient and returns a Result. PouryaKit.Email.MailKit is the one provider: the Host's sender on MailKit, configured from options the Host checks before it serves. A Module references only the contract and never names MailKit.

Mail goes out from a Notification Handler, after the Operation that raised the Domain Event committed. So a rolled-back Operation sends no mail, and a committed one's mail is attempted by a durable delivery that is retried and, in the end, set aside as a dead letter with its reason.

A mail may be sent twice

Mail is not transactional. When the delivery that sent a mail fails afterwards, its retry sends the mail again. Send last in the handler, and give each mail its own Notification Handler, so a failure elsewhere does not repeat it.

When to use it

  • An Operation's outcome is mailed to someone: a welcome, a receipt, a notice.
  • You configure the Host's SMTP server and sender address.
  • You need to know what a rolled-back Operation or a failed delivery does to a mail.
  • You test mail against a Papercut container.

How it works

Register the sender in the Host

AddPouryaKitMailKit() registers the Host's one IEmailSender, and its options come from configuration. A Host that registers a second sender fails when its container is built, and invalid options stop it when it starts.

Send from a Notification Handler

Inject IEmailSender and send an EmailMessage. The send succeeds once the mail server accepted the mail; a recipient that is not one mailbox address with a domain fails with Email.InvalidAddress, sending nothing.

Let the delivery retry

A server that cannot be reached, refuses the login or refuses the mail throws, and the delivery is retried, then set aside as a dead letter you can replay once the cause is fixed.

In the Kit's tests

A Notification Handler of the test Module mails a note once the Operation that wrote it committed:

StubEmail.cs
internal sealed record NoteMailedDomainEvent(string To, string Text) : DomainEvent;

internal sealed class MailNoteNotificationHandler(IEmailSender email)
    : INotificationHandler<NoteMailedDomainEvent>
{
    public Task<Result> Handle(
        NoteMailedDomainEvent domainEvent,
        CancellationToken cancellationToken
    ) =>
        email.SendAsync(
            new EmailMessage(
                domainEvent.To,
                domainEvent.Text,
                $"The note {domainEvent.Text} is written."
            ),
            cancellationToken
        );
}

Guarantees and failures

You want to knowRead
The options, their checks, and how each send connectsRegistering
What a send returns, and what it throwsSending from a Notification Handler
What a rollback and a failed delivery do to a mailWhat the Kit guarantees
What the sender does not offerLimits

Next steps

Reference

PouryaKit.Email: one contract, IEmailSender, that sends a plain-text mail to one recipient and returns a Result. It references PouryaKit.Domain only. PouryaKit.Email.MailKit: the one provider, the Host's sender on MailKit, configured once from validated options. Namespaces PouryaKit.Email and PouryaKit.Email.MailKit.

Registering

On the Host, which references PouryaKit.Email.MailKit:

using PouryaKit.Email.MailKit;

builder.Services.AddPouryaKitMailKit();
builder.Services.Configure<PouryaKitMailKitOptions>(builder.Configuration.GetSection("PouryaKit:Email"));
"PouryaKit": {
  "Email": {
    "Host": "smtp.example.com",
    "Port": 587,
    "UserName": "shop",
    "Password": "from a secret store",
    "From": "Shop <shop@example.com>"
  }
}
  • AddPouryaKitMailKit() registers the Host's one IEmailSender, a singleton. AddPouryaKitMailKit(configure) sets the options in code instead, such as AddPouryaKitMailKit(mail => mail.Security = SecureSocketOptions.None) for a local test server. A Host that registers another IEmailSender, before or after the call, a second call included, fails when its container is built, naming each registration's type, or the factory that builds it, and so does one that removes every IEmailSender after it, naming the contract: the registration adds a composition check. A Module references only PouryaKit.Email and never names MailKit.
  • PouryaKitMailKitOptions: Host and From are required; Port is 587 by default; Security is MailKit's SecureSocketOptions, StartTls by default, which refuses a server that offers no TLS (set None only for a local test server such as Papercut); UserName and Password are set together, or neither to send unauthenticated.
  • The Host fails when it starts, naming the option, when Host is blank (PouryaKitMailKitOptions.Host names no SMTP server; …), the port is outside 1 to 65535, From is not a mailbox address with a domain, Security is not a defined SecureSocketOptions value (a number such as 99 binds, and MailKit would connect in plain SMTP for it), or only one of UserName and Password is set.
  • Each send connects to the server, authenticates when a user name is set, sends and disconnects, on a client of its own: concurrent sends never wait for each other, and no idle connection is held for the server to drop. Once SmtpClient.SendAsync has returned (the server's acceptance read), MailKit's own disconnect swallows a QUIT that fails or is cancelled, so the send stays successful; a failure or cancellation before that return, the window between the server's 250 and MailKit reading it included, and any other exception after it fall under the sent-twice limit below.

Sending from a Notification Handler

Send mail from a Notification Handler, never from a command's handler or a Domain Event Handler, which run before the commit (messaging.md):

using PouryaKit.Domain;
using PouryaKit.Email;
using PouryaKit.Messaging;

internal sealed class WelcomeCustomerNotificationHandler(IEmailSender email)
    : INotificationHandler<CustomerRegisteredDomainEvent>
{
    public Task<Result> Handle(CustomerRegisteredDomainEvent domainEvent, CancellationToken cancellationToken) =>
        email.SendAsync(
            new EmailMessage(domainEvent.Email, "Welcome", $"Welcome, {domainEvent.Name}."),
            cancellationToken
        );
}
  • SendAsync succeeds once the mail server has accepted the mail.
  • It fails with EmailErrors.InvalidAddress (Email.InvalidAddress, target To), sending nothing, when the recipient is not one mailbox address with a domain, such as ada@example.com or Ada <ada@example.com>.
  • It throws MailKit's exception when the server cannot be reached, refuses the login or refuses the mail. Either way the delivery is retried and, once its retries are spent, set aside as a dead letter with the reason (messaging.md).

What the Kit guarantees

  • A rolled-back Operation sends no mail. Its Domain Events never reach the Outbox, so no Notification Handler runs for them.
  • A committed Operation's mail is attempted by a durable delivery: its Notification Handler's delivery is retried with the Host's bounded retries and then set aside as a dead letter. The server's acceptance is not guaranteed, and a send that succeeded may repeat (below).
  • The options are checked before the Host serves, not at the first send.

Limits

  • A mail may be sent twice. Mail is not transactional: when the delivery that sent a mail fails afterwards, such as the handler's own later failure or a crash before its Receipt commits, its retry sends the mail again. Do the send last in its handler, and give each mail its own Notification Handler, so a failure elsewhere does not repeat it. The Kit has no transactional mail and no exactly-once external effect.
  • One recipient, one plain-text body, the Host's one sender address. No attachments, HTML body, templates or bulk sending.
  • One provider per Host, chosen by the package the Host references; no switch at run time.

Testing

  • The Proving Sample's tests run the application on a database of their own with its mail sent to a Papercut SMTP container (Testcontainers.Papercut, image changemakerstudiosus/papercut-smtp:7.8.0, configured with Security None and the container's SMTP port), and read what arrived from Papercut's HTTP API (GET /api/messages, then GET /api/messages/{id} for its recipients and body).
  • A test Host that composes a Module with a Notification Handler that sends mail registers a sender too: the Kit's container is validated when it is built.

On this page