Apricot Framework

Usage

Building a message, sending it, and reading the result.

Building a message

var message = new EmailMessage
{
    Subject = "Welcome",
    Body = EmailBody.FromBoth("<b>Hello</b>", "Hello"),
    To = [EmailAddress.Parse("someone@example.com", "Someone")],
    Cc = [EmailAddress.Parse("audit@example.com")],
};

Subject, Body and To are required; everything else has a default. From is optional because the account usually supplies it — set it only when this message should come from somewhere else.

EmailBody has three factories, and a part you do not supply is absent rather than empty: FromHtml, FromText, FromBoth. An HTML-only body is a single HTML part, not an alternative with an empty half.

Addresses

EmailAddress is created only through Parse or TryParse, so an instance is always safe to write into a header. Rejected: anything without exactly one @, an empty local part, a domain that is not a dotted name, a local part over 64 characters, an address over 254, and any control character.

EmailAddress.TryParse("someone@example.com", out var address);   // true
EmailAddress.TryParse("not-an-email", out _);                    // false
EmailAddress.TryParse("a@b.com\r\nBcc: attacker@evil.com", out _);  // false

Warning

That last one matters. MimeKit defends itself against header injection by silently stripping CR and LF, which would deliver a mangled address rather than telling anyone; and its own parser accepts not-an-email as a valid mailbox. Validation here is not duplication.

Attachments

Attachments =
[
    EmailAttachment.FromFile("invoice.pdf", contentType: "application/pdf"),
    EmailAttachment.Inline("header-logo", "logo.png", logoBytes, "image/png"),
]

An inline attachment is referenced from the HTML body as <img src="cid:header-logo" />. FromFile opens the file when the message is sent, not when the attachment is built.

OpenRead must return a fresh, readable stream on every call — it may be called more than once for a single send — and whoever calls it disposes the result.

Reading the result

var result = await mailer.SendAsync(message, cancellationToken);

if (result.Succeeded)
{
    logger.LogInformation("Sent {MessageId}", result.MessageId);
}
ErrorCodeMeaningTransient
AccountNotFoundNothing knows the account nameno
TransportNotFoundThe account names a transport that is not registeredno
InvalidAccountThe account's settings are unusableno
InvalidMessageThe message cannot be sent as writtenno
AuthenticationThe credentials were refusedno
ConnectionCould not reach the serveryes
TimeoutThe server did not answer in timeyes
ThrottledA rate or quota limit, or a 4xx "come back later"yes
RejectedThe server refused this message, with a 5xxno
UnknownThe transport did not classify itno

Warning

Succeeded means the transport accepted the message, not that it was delivered. A server that queues it reports success and may still bounce it later, out of band.

result.Error can quote a server's reply verbatim, and result.Exception carries the underlying failure. Neither appears in ToString(), and Exception is excluded from JSON — a refusal often echoes the account it refused. Log them; do not return them to a caller.

Writing a transport

public sealed class SendGridMailTransport : IMailTransport
{
    public string Name => "sendgrid";

    public async Task<MailSendResult> SendAsync(EmailMessage message, MailAccount account, CancellationToken cancellationToken)
    {
        var apiKey = account.Settings.GetRequiredString("ApiKey");

        // ... and per-send values, if any, from message.Properties
        return MailSendResult.Success(account.Name, this.Name, messageId);
    }
}

Register it with services.AddMailTransport<SendGridMailTransport>() and accounts reach it by setting "Transport": "sendgrid". An implementation must be stateless and thread-safe, because one instance serves every account naming it, and should classify the failures it understands rather than throwing.

EmailMessage.Properties is the counterpart of MailAccount.Settings for values that vary per message rather than per account — a template id, a tag, a scheduled time, an idempotency key. A transport reads the names it knows and ignores the rest.

On this page