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:
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 know | Read |
|---|---|
| The options, their checks, and how each send connects | Registering |
| What a send returns, and what it throws | Sending from a Notification Handler |
| What a rollback and a failed delivery do to a mail | What the Kit guarantees |
| What the sender does not offer | Limits |
Next steps
Messaging
The Notification Handlers a mail is sent from, and their retries.
Testing
The fixture that runs the application against its containers.
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 oneIEmailSender, a singleton.AddPouryaKitMailKit(configure)sets the options in code instead, such asAddPouryaKitMailKit(mail => mail.Security = SecureSocketOptions.None)for a local test server. A Host that registers anotherIEmailSender, 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 everyIEmailSenderafter it, naming the contract: the registration adds a composition check. A Module references onlyPouryaKit.Emailand never names MailKit.PouryaKitMailKitOptions:HostandFromare required;Portis 587 by default;Securityis MailKit'sSecureSocketOptions,StartTlsby default, which refuses a server that offers no TLS (setNoneonly for a local test server such as Papercut);UserNameandPasswordare set together, or neither to send unauthenticated.- The Host fails when it starts, naming the option, when
Hostis blank (PouryaKitMailKitOptions.Host names no SMTP server; …), the port is outside 1 to 65535,Fromis not a mailbox address with a domain,Securityis not a definedSecureSocketOptionsvalue (a number such as99binds, and MailKit would connect in plain SMTP for it), or only one ofUserNameandPasswordis 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.SendAsynchas 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
);
}SendAsyncsucceeds once the mail server has accepted the mail.- It fails with
EmailErrors.InvalidAddress(Email.InvalidAddress, targetTo), sending nothing, when the recipient is not one mailbox address with a domain, such asada@example.comorAda <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, imagechangemakerstudiosus/papercut-smtp:7.8.0, configured withSecurityNoneand the container's SMTP port), and read what arrived from Papercut's HTTP API (GET /api/messages, thenGET /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.