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.
| Sender | When |
|---|---|
ResendEmailSender | provider: resend with an API key: POST https://api.resend.com/emails with {from, to, subject, html, text} |
SmtpEmailSender | provider: smtp with a host: any SMTP server or relay (see below) |
LogEmailSender | provider: 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
| Key | Default | |
|---|---|---|
host | SMTP server; required in Production (elsewhere a missing host logs instead of sending) | |
port | 587 | 465 for ssl |
username / password | required in Production; without a username no authentication is attempted | |
from | altruist:email:from | sender address, optionally Name <address> |
from-name | sender display name | |
security | starttls | starttls: 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-seconds | 30 | per 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.