Skip to main content

Sending Authentication Emails & SMS Messages

PearDrop's Authentication module provides the IAuthenticationNotifier interface to customize how users are notified during authentication flows (registration, password reset, MFA, etc.). This guide shows how to implement email and SMS notifications.

Quick Setup

Follow these four steps to implement custom authentication notifications:

Step 1: Add Helper Libraries via CLI

# Add email helper (MailHog for dev, SendGrid for prod)
peardrop add email-helper --from-email "noreply@myapp.com" --from-name "My Application"

# Add SMS helper (LocalEmail for dev, Twilio for prod)
peardrop add sms-helper

# Restore packages
dotnet restore

This adds PearDrop.Helpers package with IEmailService and ISmsService interfaces.

Step 2: Create Configuration Classes

Create a configuration class for authentication URLs:

// MyApp.App/Infrastructure/Authentication/AuthenticationUrlsOptions.cs
using Microsoft.Extensions.Configuration;

namespace MyApp.App.Infrastructure.Authentication;

/// <summary>
/// Configuration URLs for authentication notification links.
/// Loaded from appsettings.json under "AuthenticationUrls" section.
/// </summary>
public sealed class AuthenticationUrlsOptions
{
public const string SectionName = "AuthenticationUrls";

/// <summary>Email address for support inquiries</summary>
public string SupportEmail { get; set; } = "support@myapp.com";

/// <summary>Email confirmation link template with {0} placeholder for token</summary>
public string ConfirmEmailUrl { get; set; } = "https://myapp.com/registration/complete-sign-up?token={0}";

/// <summary>Password reset link template with {0} placeholder for token</summary>
public string ResetPasswordUrl { get; set; } = "https://myapp.com/auth/password-reset?token={0}";

/// <summary>Email/phone verification link template with {0} placeholder for token</summary>
public string VerifyPrincipalNameUrl { get; set; } = "https://myapp.com/auth/account-verification?token={0}";

/// <summary>Password reset request page URL (no token, user initiates reset)</summary>
public string RequestPasswordResetUrl { get; set; } = "https://myapp.com/auth/request-password-reset";

/// <summary>Registration page URL (for retry emails)</summary>
public string RegisterUrl { get; set; } = "https://myapp.com/registration/sign-up";
}

Step 3: Implement IAuthenticationNotifier

Create a custom notifier in your application:

// MyApp.App/Infrastructure/Authentication/AuthenticationNotifier.cs
using PearDrop.Authentication.Contracts;
using PearDrop.Authentication.Client.Constants;
using PearDrop.Helpers.Email;
using PearDrop.Helpers.Sms;
using ResultMonad;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace MyApp.App.Infrastructure.Authentication;

/// <summary>
/// Sends email and SMS notifications for authentication events.
/// Implements PearDrop's IAuthenticationNotifier interface.
/// All URLs configured via IOptions&lt;AuthenticationUrlsOptions&gt; for dev/prod switching.
/// </summary>
public class AuthenticationNotifier(
IEmailService emailService,
ISmsService smsService,
IOptions<AuthenticationUrlsOptions> urlsOptions,
ILogger<AuthenticationNotifier> logger) : IAuthenticationNotifier
{
private readonly AuthenticationUrlsOptions urls = urlsOptions.Value;
/// <summary>
/// Sends welcome email when user registers.
/// </summary>
public async Task<Result> SendWelcomeNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
string token,
CancellationToken cancellationToken = default)
{
try
{
var userName = !string.IsNullOrWhiteSpace(firstName) ? firstName : "User";
var confirmUrl = string.Format(this.urls.ConfirmEmailUrl, Uri.EscapeDataString(token));

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Welcome to My Application - Confirm Your Email",
bodyContent: $"""
<h2>Welcome, {userName}!</h2>
<p>Thank you for registering with My Application.</p>
<p>Please confirm your email address by clicking the link below:</p>
<p><a href="{confirmUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">Confirm Email</a></p>
<p style="font-size: 12px; color: #666;">Or copy this link:<br/>{confirmUrl}</p>
<p>This link expires in 24 hours.</p>
<p>If you didn't create this account, you can safely ignore this email.</p>
""",
tags: new[] { "authentication", "welcome", "confirmation" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Welcome email sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

logger.LogWarning("Failed to send welcome email to {EmailAddress}", emailAddress);
return Result.Fail("Failed to send welcome email");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending welcome email to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends MFA code via email.
/// </summary>
public async Task<Result> SendMfaNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
string token,
CancellationToken cancellationToken = default)
{
try
{
var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Your Two-Factor Authentication Code",
bodyContent: $"""
<h2>Two-Factor Authentication</h2>
<p>Your authentication code is:</p>
<div style="font-size: 28px; font-weight: bold; letter-spacing: 2px; text-align: center; background-color: #f8f9fa; padding: 20px; border-radius: 4px; font-family: monospace;">
{token}
</div>
<p>This code expires in 10 minutes.</p>
<p><strong>Never share this code with anyone.</strong></p>
<p style="font-size: 12px; color: #666;">If you didn't request this code, your account may be at risk. Please change your password immediately.</p>
""",
tags: new[] { "authentication", "mfa", "2fa" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("MFA email sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send MFA email");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending MFA email to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends password reset link via email.
/// </summary>
public async Task<Result> SendPasswordResetNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
string token,
CancellationToken cancellationToken = default)
{
try
{
var resetUrl = string.Format(this.urls.ResetPasswordUrl, Uri.EscapeDataString(token));

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Reset Your Password",
bodyContent: $"""
<h2>Password Reset Request</h2>
<p>We received a request to reset your password. Click the link below to proceed:</p>
<p><a href="{resetUrl}" style="background-color: #dc3545; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">Reset Password</a></p>
<p style="font-size: 12px; color: #666;">Or copy this link:<br/>{resetUrl}</p>
<p>This link expires in 1 hour.</p>
<p><strong>If you didn't request this, please ignore this email.</strong> Your password won't change until you create a new one.</p>
""",
tags: new[] { "authentication", "password-reset" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Password reset email sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send password reset email");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending password reset email to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends MFA code via SMS (2FA).
/// </summary>
public async Task<Result> SendMfaSmsNotificationMessage(
string? firstName,
string? lastName,
string phoneNumber,
string token,
CancellationToken cancellationToken = default)
{
try
{
var message = $"Your authentication code is: {token}\n\nDo not share this code with anyone. It expires in 10 minutes.";

var success = await smsService.SendSmsAsync(
toPhoneNumber: phoneNumber,
message: message,
tags: new[] { "authentication", "mfa", "sms" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("MFA SMS sent to {PhoneNumber}", phoneNumber);
return Result.Ok();
}

return Result.Fail("Failed to send MFA SMS");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending MFA SMS to {PhoneNumber}", phoneNumber);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends notifications when account is disabled.
/// </summary>
public async Task<Result> SendAccountDisabledNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
CancellationToken cancellationToken = default)
{
try
{
var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Your Account Has Been Disabled",
bodyContent: $"""
<h2>Account Disabled</h2>
<p>Your account has been disabled. If you believe this is a mistake, please contact support.</p>
<p><strong>Contact Support:</strong> {this.urls.SupportEmail}</p>
""",
tags: new[] { "authentication", "account-disabled" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Account disabled notification sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send account disabled notification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending account disabled notification to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends notifications when account is re-enabled.
/// </summary>
public async Task<Result> SendAccountEnabledNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
string token,
CancellationToken cancellationToken = default)
{
try
{
var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Your Account Has Been Re-enabled",
bodyContent: """
<h2>Account Re-enabled</h2>
<p>Your account has been re-enabled. You can now log in.</p>
<p>If you did not request this change, please contact support immediately.</p>
""",
tags: new[] { "authentication", "account-enabled" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Account enabled notification sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send account enabled notification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending account enabled notification to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends password change confirmation.
/// </summary>
public async Task<Result> SendPasswordChangedNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
CancellationToken cancellationToken = default)
{
try
{
var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Your Password Has Been Changed",
bodyContent: $"""
<h2>Password Changed</h2>
<p>Your password was successfully changed.</p>
<p>If you did not make this change, please reset your password immediately.</p>
<p><a href="{this.urls.RequestPasswordResetUrl}">Reset Password</a></p>
""",
tags: new[] { "authentication", "password-changed" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Password changed notification sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send password changed notification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending password changed notification to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends account locked warning via email.
/// </summary>
public async Task<Result> SendAccountLockedNotificationMessage(
string? firstName,
string? lastName,
string emailAddress,
CancellationToken cancellationToken = default)
{
try
{
var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Your Account Has Been Locked",
bodyContent: """
<h2 style="color: #dc3545;">⚠️ Account Locked</h2>
<p>Your account has been temporarily locked due to multiple failed login attempts.</p>
<p>For security reasons, this protects your account from unauthorized access.</p>
<p><strong>What you can do:</strong></p>
<ul>
<li>Wait 30 minutes and try logging in again</li>
<li>Reset your password if you've forgotten it</li>
<li>Contact support if you need further assistance</li>
</ul>
<p><strong>Security Tip:</strong> If you didn't attempt these logins, please change your password immediately.</p>
""",
tags: new[] { "authentication", "security", "account-locked" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogWarning("Account locked notification sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send account locked notification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending account locked notification to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends verification code when user adds alternative authentication method (phone as email/principal name).
/// </summary>
public async Task<Result> SendUserPrincipleNameVerificationMessage(
string? firstName,
string? lastName,
string value,
UserPrincipalNameType userPrincipalNameType,
string token,
CancellationToken cancellationToken = default)
{
try
{
var userName = !string.IsNullOrWhiteSpace(firstName) ? firstName : "User";
var methodName = userPrincipalNameType == UserPrincipalNameType.EmailAddress ? "email" : "phone";
var confirmUrl = string.Format(this.urls.VerifyPrincipalNameUrl, Uri.EscapeDataString(token));

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: value,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: $"Verify Your {methodName.Capitalize()} Address",
bodyContent: $"""
<h2>Verify Your {methodName.Capitalize()} Address</h2>
<p>Hi {userName},</p>
<p>You've added this {methodName} address to your account. Please verify it to complete the setup.</p>
<p><a href="{confirmUrl}" style="background-color: #28a745; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">Verify {methodName.Capitalize()}</a></p>
<p style="font-size: 12px; color: #666;">Or copy this link:<br/>{confirmUrl}</p>
<p>This link expires in 24 hours.</p>
<p>If you didn't add this {methodName}, you can safely ignore this email.</p>
""",
tags: new[] { "authentication", "verification", methodName },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Principal name verification sent to {Value} ({Type})", value, userPrincipalNameType);
return Result.Ok();
}

return Result.Fail($"Failed to send {methodName} verification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending principal name verification to {Value}", value);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Notifies user when alternative authentication methods are disabled.
/// </summary>
public async Task<Result> SendUserPrincipleNameDisableMessage(
string? firstName,
string? lastName,
IReadOnlyDictionary<string, UserPrincipalNameType> userPrincipalNames,
CancellationToken cancellationToken = default)
{
try
{
var disabledMethods = string.Join(", ", userPrincipalNames.Keys.Select(k => $"{k} ({userPrincipalNames[k]})"));

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: DateTime.UtcNow.ToString(), // Would use actual email from context
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Alternative Authentication Methods Disabled",
bodyContent: $"""
<h2>Authentication Methods Disabled</h2>
<p>The following authentication methods have been disabled from your account:</p>
<ul>
{string.Join("\n", userPrincipalNames.Keys.Select(k => $"<li>{k} ({userPrincipalNames[k]})</li>"))}
</ul>
<p>You can re-enable them at any time in your account security settings.</p>
<p>If you don't recognize this action, please contact support immediately.</p>
""",
tags: new[] { "authentication", "security", "methods-disabled" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Principal names disabled notification sent to user");
return Result.Ok();
}

return Result.Fail("Failed to send principal names disabled notification");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending principal names disabled notification");
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Alerts admin when user tries to use non-unique principal name (duplicate email/phone).
/// </summary>
public Task<Result> SendUserPrincipleNameNonUniqueVerificationMessage(
string value,
UserPrincipalNameType userPrincipalNameType,
CancellationToken cancellationToken = default)
{
// This is typically an admin notification, log only
logger.LogWarning(
"Attempted registration with non-unique principal name: {Type} {Value}",
userPrincipalNameType,
value);
return Task.FromResult(Result.Ok());
}

/// <summary>
/// Sends confirmation when new principal name (email/phone) is verified.
/// </summary>
public async Task<Result> SendUserNewPrincipleNameVerifiedMessage(
string? firstName,
string? lastName,
string emailAddress,
UserPrincipalNameType userPrincipalNameType,
string value,
CancellationToken cancellationToken = default)
{
try
{
var methodName = userPrincipalNameType == UserPrincipalNameType.EmailAddress ? "email" : "phone";

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: $"Your {methodName.Capitalize()} Address Has Been Verified",
bodyContent: $"""
<h2>{methodName.Capitalize()} Address Verified</h2>
<p>Your {methodName} address (<strong>{value}</strong>) has been successfully verified and is now active on your account.</p>
<p>You can now use this {methodName} for authentication and account recovery.</p>
<p>If you didn't verify this {methodName}, please contact support immediately.</p>
""",
tags: new[] { "authentication", "verification", "confirmed", methodName },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("Principal name verified confirmation sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send verification confirmation");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending principal name verified confirmation", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends welcome to newly registered user with confirmation link.
/// </summary>
public async Task<Result> SendNewRegistrationNotificationMessage(
string firstName,
string lastName,
string emailAddress,
string tokenValue,
CancellationToken cancellationToken = default)
{
try
{
var userName = !string.IsNullOrWhiteSpace(firstName) ? firstName : "User";
var confirmUrl = string.Format(this.urls.ConfirmEmailUrl, Uri.EscapeDataString(tokenValue));

var success = await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: emailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Welcome! Confirm Your Email",
bodyContent: $"""
<h2>Welcome to My Application, {userName}!</h2>
<p>We're thrilled to have you join us.</p>
<p>To get started, please confirm your email address:</p>
<p><a href="{confirmUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Confirm Email Address</a></p>
<p style="font-size: 12px; color: #666;">Or copy this link:<br/>{confirmUrl}</p>
<p><strong>What's next?</strong></p>
<ul>
<li>Confirm your email address</li>
<li>Complete your profile</li>
<li>Get started with the app!</li>
</ul>
<p>Questions? Contact us at {this.urls.SupportEmail}</p>
<p>This link expires in 24 hours.</p>
""",
tags: new[] { "authentication", "welcome", "registration" },
cancellationToken: cancellationToken);

if (success)
{
logger.LogInformation("New registration welcome sent to {EmailAddress}", emailAddress);
return Result.Ok();
}

return Result.Fail("Failed to send registration welcome");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending registration welcome to {EmailAddress}", emailAddress);
return Result.Fail(ex.Message);
}
}

/// <summary>
/// Sends retry prompt when registration fails or is incomplete.
/// </summary>
public async Task SendRegistrationRetryNotificationMessage(
string firstName,
string lastName,
string contactEmailAddress,
CancellationToken cancellationToken = default)
{
try
{
var userName = !string.IsNullOrWhiteSpace(firstName) ? firstName : "User";

await emailService.SendEmailWithEmbeddedHtmlAndTextAsync(
toEmail: contactEmailAddress,
toDisplayName: $"{firstName} {lastName}".Trim(),
subject: "Complete Your Registration",
bodyContent: $"""
<h2>Hi {userName},</h2>
<p>We noticed you haven't completed your registration yet.</p>
<p>No worries! Here's what you need to do:</p>
<ol>
<li>Click the confirmation link in the welcome email</li>
<li>Complete your profile</li>
<li>Start using My Application</li>
</ol>
<p><a href="{this.urls.RegisterUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">Complete Registration</a></p>
<p>If you didn't receive the welcome email, check your spam folder or contact {this.urls.SupportEmail}.</p>
""",
tags: new[] { "authentication", "registration", "retry" },
cancellationToken: cancellationToken);

logger.LogInformation("Registration retry message sent to {EmailAddress}", contactEmailAddress);
}
catch (Exception ex)
{
logger.LogError(ex, "Exception sending registration retry message to {EmailAddress}", contactEmailAddress);
}
}
}

Step 4: Register in Dependency Injection

Replace the default NullAuthenticationNotifier with your custom implementation in Program.cs:

// Program.cs

// Add the authentication module (registers NullAuthenticationNotifier)
builder.Services.AddPearDropAuthentication(builder.Configuration);

// Configure authentication URLs
builder.Services.Configure<AuthenticationUrlsOptions>(
builder.Configuration.GetSection(AuthenticationUrlsOptions.SectionName));

// Replace the default notifier with your custom implementation
builder.Services.RemoveAll<IAuthenticationNotifier>();
builder.Services.AddScoped<IAuthenticationNotifier, AuthenticationNotifier>();

Note: Use RemoveAll<IAuthenticationNotifier>() to properly replace the framework's default registration.

Configuration

Configure authentication URLs and helper services for both development and production environments.

Authentication URLs

Development (appsettings.Development.json):

{
"AuthenticationUrls": {
"SupportEmail": "developer@localhost",
"ConfirmEmailUrl": "http://localhost:5000/registration/complete-sign-up?token={0}",
"ResetPasswordUrl": "http://localhost:5000/auth/password-reset?token={0}",
"RequestPasswordResetUrl": "http://localhost:5000/auth/request-password-reset",
"VerifyPrincipalNameUrl": "http://localhost:5000/auth/account-verification?token={0}",
"RegisterUrl": "http://localhost:5000/registration/sign-up"
}
}

Production (appsettings.json):

{
"AuthenticationUrls": {
"SupportEmail": "support@myapp.com",
"ConfirmEmailUrl": "https://myapp.com/registration/complete-sign-up?token={0}",
"ResetPasswordUrl": "https://myapp.com/auth/password-reset?token={0}",
"RequestPasswordResetUrl": "https://myapp.com/auth/request-password-reset",
"VerifyPrincipalNameUrl": "https://myapp.com/auth/account-verification?token={0}",
"RegisterUrl": "https://myapp.com/registration/sign-up"
}
}

Environment-Specific Configuration: You can use environment variables to override URLs without modifying appsettings files:

# Windows PowerShell
$env:AuthenticationUrls__SupportEmail = "help@myapp.com"
$env:AuthenticationUrls__ConfirmEmailUrl = "https://staging.myapp.com/registration/complete-sign-up?token={0}"

# Linux/macOS bash
export AuthenticationUrls__SupportEmail="help@myapp.com"
export AuthenticationUrls__ConfirmEmailUrl="https://staging.myapp.com/registration/complete-sign-up?token={0}"

URL Template Flexibility: The {0} placeholder in URLs allows you to add additional query parameters without changing code. For example, to track email source:

{
"AuthenticationUrls": {
"ConfirmEmailUrl": "https://myapp.com/registration/complete-sign-up?token={0}&utm_source=email&utm_medium=auth"
}
}

The code automatically uses string.Format() to replace only the {0} placeholder with the token, leaving other query parameters intact.

Email Service

Development (appsettings.Development.json) - MailHog Local SMTP:

{
"Email": {
"Provider": "Smtp",
"FromEmail": "noreply@myapp.local",
"FromDisplayName": "My App (Dev)",
"Smtp": {
"Host": "localhost",
"Port": 1025,
"Username": null,
"Password": null
}
}
}

Production (appsettings.json) - SendGrid:

{
"Email": {
"Provider": "SendGrid",
"SendGrid": {
"ApiKey": "${SENDGRID_API_KEY}"
},
"FromEmail": "noreply@myapp.com",
"FromDisplayName": "My Application"
}
}

Using environment variables for secrets:

# Set SendGrid API key (never commit to repo)
$env:Email__SendGrid__ApiKey = "your-sendgrid-api-key" # PowerShell
export Email__SendGrid__ApiKey="your-sendgrid-api-key" # Bash

SMS Service

Development (appsettings.Development.json) - LocalEmail Provider (sends as email to MailHog):

{
"Sms": {
"Provider": "LocalEmail",
"LocalEmail": {
"RecipientEmail": "developer@localhost"
}
}
}

Production (appsettings.json) - Twilio Provider:

{
"Sms": {
"Provider": "Twilio",
"Twilio": {
"AccountSid": "${TWILIO_ACCOUNT_SID}",
"AuthToken": "${TWILIO_AUTH_TOKEN}",
"FromPhoneNumber": "+15551234567"
}
}
}

Using environment variables for Twilio credentials:

# Set Twilio credentials (never commit to repo)
$env:Sms__Twilio__AccountSid = "your-twilio-account-sid" # PowerShell
$env:Sms__Twilio__AuthToken = "your-twilio-auth-token"
export Sms__Twilio__AccountSid="your-twilio-account-sid" # Bash
export Sms__Twilio__AuthToken="your-twilio-auth-token"

Testing

Local Testing with MailHog

During development, emails and SMS messages (via LocalEmail) appear in MailHog:

  1. Start MailHog: docker-compose up -d (should already be in your docker-compose.yml)
  2. Trigger authentication flow: Register, reset password, enable MFA
  3. View messages: Open http://localhost:8025

What you'll see:

  • Welcome emails with confirmation links
  • Password reset emails with reset links
  • MFA codes (email and SMS via LocalEmail)
  • All notifications rendered as they would appear to users

Unit Testing

Mock the required dependencies to test your authentication notifier:

[TestFixture]
public class AuthenticationNotifierTests
{
private Mock<IEmailService> mockEmailService;
private Mock<ISmsService> mockSmsService;
private Mock<IOptions<AuthenticationUrlsOptions>> mockUrlsOptions;
private Mock<ILogger<AuthenticationNotifier>> mockLogger;
private AuthenticationNotifier notifier;

[SetUp]
public void Setup()
{
mockEmailService = new Mock<IEmailService>();
mockSmsService = new Mock<ISmsService>();
mockLogger = new Mock<ILogger<AuthenticationNotifier>>();

// Configure URLs options for testing
var urlsOptions = new AuthenticationUrlsOptions
{
SupportEmail = "support@test.local",
ConfirmEmailUrl = "http://localhost:5000/registration/complete-sign-up?token={0}",
ResetPasswordUrl = "http://localhost:5000/auth/password-reset?token={0}",
RequestPasswordResetUrl = "http://localhost:5000/auth/request-password-reset",
VerifyPrincipalNameUrl = "http://localhost:5000/auth/account-verification?token={0}",
RegisterUrl = "http://localhost:5000/registration/sign-up"
};

mockUrlsOptions = new Mock<IOptions<AuthenticationUrlsOptions>>();
mockUrlsOptions.Setup(o => o.Value).Returns(urlsOptions);

notifier = new AuthenticationNotifier(
mockEmailService.Object,
mockSmsService.Object,
mockUrlsOptions.Object,
mockLogger.Object);
}

[Test]
public async Task SendWelcomeNotificationMessage_CallsEmailServiceWithConfiguredUrl()
{
// Arrange
mockEmailService
.Setup(s => s.SendEmailWithEmbeddedHtmlAndTextAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);

// Act
var result = await notifier.SendWelcomeNotificationMessage(
"John",
"Doe",
"john@example.com",
"verification-token");

// Assert
Assert.That(result.IsSuccess, Is.True);

mockEmailService.Verify(
s => s.SendEmailWithEmbeddedHtmlAndTextAsync(
"john@example.com",
"John Doe",
It.IsAny<string>(),
It.Is<string>(content =>
content.Contains("Welcome") &&
content.Contains("http://localhost:5000/registration/complete-sign-up?token=" + Uri.EscapeDataString("verification-token"))),
null,
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}

[Test]
public async Task SendPasswordResetNotificationMessage_UsesConfiguredResetUrl()
{
// Arrange
mockEmailService.Setup(s => s.SendEmailWithEmbeddedHtmlAndTextAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);

// Act
var result = await notifier.SendPasswordResetNotificationMessage(
"Jane",
"Smith",
"jane@example.com",
"reset-token");

// Assert
Assert.That(result.IsSuccess, Is.True);

mockEmailService.Verify(
s => s.SendEmailWithEmbeddedHtmlAndTextAsync(
"jane@example.com",
It.IsAny<string>(),
It.IsAny<string>(),
It.Is<string>(content =>
content.Contains("http://localhost:5000/auth/password-reset?token=" + Uri.EscapeDataString("reset-token"))),
It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
}

Best Practices

Follow these guidelines when implementing authentication notifications:

Configuration Management:

  • Use appsettings.Development.json and appsettings.json to switch between dev/prod URLs without code changes
  • Override URLs via environment variables (e.g., AuthenticationUrls__ConfirmEmailUrl) for CI/CD pipelines
  • Test with staging/pre-production URLs before production deployment
  • Never commit API keys or credentials; use environment variables or Azure Key Vault

Email & SMS Implementation:

  • Use environment-specific templates (different layouts for welcome vs. security alerts)
  • Always use HTTPS URLs in production and include expiration timestamps
  • Implement rate limiting to prevent notification spam on failed auth attempts
  • Gracefully handle email/SMS delivery failures (don't block authentication)
  • Log all notification sends for debugging and auditing
  • Mock IEmailService, ISmsService, and IOptions<AuthenticationUrlsOptions> in unit tests
  • Include user names and contextual information in messages

URL Configuration Checklist:

  • Development URLs use http://localhost:port
  • Production URLs use https:// with valid domain
  • ConfirmEmailUrl and VerifyPrincipalNameUrl match your actual confirmation pages
  • ResetPasswordUrl points to password reset form
  • RegisterUrl points to registration page (for retry emails)
  • RequestPasswordResetUrl points to password reset request page
  • SupportEmail is a monitored inbox
  • All URLs are configured in both appsettings.Development.json and appsettings.json
  • Environment-specific URLs work correctly after deployment

Advanced Patterns

For specialized scenarios, you can extend the basic implementation with these advanced patterns.

Multi-Tenant URL Configuration

Configure tenant-specific URLs using a custom IOptionsFactory:

public class TenantAuthenticationUrlsFactory : IOptionsFactory<AuthenticationUrlsOptions>
{
private readonly IConfiguration configuration;
private readonly IMultiTenantContextAccessor tenantContextAccessor;

public AuthenticationUrlsOptions Create(string name)
{
var tenantId = this.tenantContextAccessor.TenantContext?.TenantId;
var section = tenantId.HasValue
? $"Tenants:{tenantId}:AuthenticationUrls"
: "AuthenticationUrls";

var options = new AuthenticationUrlsOptions();
this.configuration.GetSection(section).Bind(options);
return options;
}
}

Register in DI:

// Replace the default options factory
builder.Services.AddSingleton<IOptionsFactory<AuthenticationUrlsOptions>,
TenantAuthenticationUrlsFactory>();