Email

Transactional email (verification links, password resets, receipts) through one interface. The provider comes from config: in Development emails are written to the log, in production they go out through Resend or any SMTP server.

altruist:
  email:
    provider: log                          # log (default) | resend | smtp
    from: "My Game <no-reply@mygame.gg>"   # with Resend: an address on a verified domain
    resend-api-key: ""                     # secret: ALTRUIST__EMAIL__RESEND_API_KEY

Sending

using Altruist.Email;

public sealed class VerificationMailer(IEmailSender email, ILogger<VerificationMailer> log)
{
    public Task SendAsync(string to, string link)
    {
        var message = new EmailMessage(to, "Confirm your email",
            Html: $"<p><a href=\"{WebUtility.HtmlEncode(link)}\">Confirm</a></p>",
            Text: $"Confirm your email: {link}");
        // Returns at once: the request never waits for the provider. Failures are reported, never thrown.
        return email.SendInBackground(message, ex => log.LogWarning(ex, "Verification mail to {To} failed", to));
    }
}

IEmailSender.SendAsync sends and throws when delivery failed; SendInBackground(message, onFailure) (or SendInBackground(message, logger)) runs it on the thread pool. Build your own templates: an EmailMessage is the recipient, the subject, the HTML and a plain-text alternative.

SenderWhen
ResendEmailSenderprovider: resend with an API key: POST https://api.resend.com/emails with {from, to, subject, html, text}
SmtpEmailSenderprovider: smtp with a host: any SMTP server or relay (see below)
LogEmailSenderprovider: log, or resend without a key / smtp without a host outside Production (a warning is logged): the email, links included, goes to the log

Which one: log for development and tests, resend when you use Resend (HTTP, no SMTP ports), smtp for everything else (your mail host, Amazon SES, Postmark, Mailgun, SendGrid SMTP endpoints, a company relay).

Register your own IEmailSender to use another provider; tests can register one that records the messages.

SMTP

altruist:
  email:
    provider: smtp
    from: "My Game <no-reply@mygame.gg>"   # or smtp:from
    smtp:
      host: ${SMTP_HOST}
      port: ${SMTP_PORT:-587}              # 587 (default) with starttls, 465 with ssl
      username: ${SMTP_USER}
      password: ${SMTP_PASS}               # secret: from the environment
      # from: ${SMTP_FROM}                 # sender; falls back to altruist:email:from
      # from-name: My Game                 # display name; overrides a name in the sender
      security: starttls                   # starttls (default) | ssl (implicit TLS) | none (Development only)
      timeout-seconds: 30                  # per network operation
KeyDefault
hostSMTP server; required in Production (elsewhere a missing host logs instead of sending)
port587465 for ssl
username / passwordrequired in Production; without a username no authentication is attempted
fromaltruist:email:fromsender address, optionally Name <address>
from-namesender display name
securitystarttlsstarttls: STARTTLS is mandatory (a server without it fails the send, never a clear-text fallback); ssl: TLS from the first byte; none: unencrypted, only accepted in Development (Mailpit, MailHog)
timeout-seconds30per connect / command

Every value can be an environment placeholder (${NAME}, ${NAME:-default}), so the app maps its own variable names.

Each email is one connection (connect, TLS, authenticate, send, quit) through MailKit and goes out as multipart/alternative with the plain-text and HTML bodies of the EmailMessage. Only the recipient's domain and the subject are logged (Debug); never the password, the full address or the body.

Startup check. With provider: smtp the configuration is validated when the app starts and an unfit one stops it with an InvalidOperationException naming the keys: in Production host, a sender (not the default no-reply@localhost), username and password must be set; security: none is rejected outside Development; an unknown security, a non-numeric port or an invalid sender is rejected everywhere. EmailOptions.Validate(environment) runs the same check by hand.

Addresses

var normalized = EmailAddress.Normalize(input);     // trimmed, lower-cased; null for blank input
if (normalized is null || !EmailAddress.IsValid(normalized))
    throw new HttpApiException(400, "invalid_email", "Enter a valid email address.", "email");

IsValid checks the normalized form: at most 254 characters (EmailAddress.MaxLength) with a local part of at most 64, a dot-atom local part and a dotted domain with an alphabetic top-level label (no user@localhost, no line breaks). Store and compare the normalized form.