nRefresh() => false;
}
public sealed class OAuth2Strategy : IAuthStrategy
{
public string ProviderName => "OAuth2";
private readonly string _username;
private readonly string _accessToken;
public OAuth2Strategy(string username, string accessToken)
{
_username = username;
_accessToken = accessToken;
}
public Task AuthenticateAsync(SmtpClient client, CancellationToken ct)
{
var oauth2 = new SaslMechanismOAuth2(_username, _accessToken);
return client.AuthenticateAsync(oauth2, ct);
}
public bool RequiresTokenRefresh() => true;
}
### Step 2: Model Provider Endpoints
Consumer providers share similar transport parameters but diverge on hostnames, TLS expectations, and username formatting rules. Centralize this configuration to avoid scattered magic strings.
```csharp
public record ProviderEndpoint(
string Host,
int Port,
SecureSocketOptions Security,
string DisplayName,
bool RequiresFullEmailUsername);
public static class KnownProviders
{
public static ProviderEndpoint Gmail => new(
"smtp.gmail.com", 587, SecureSocketOptions.StartTls, "Gmail", false);
public static ProviderEndpoint iCloud => new(
"smtp.mail.me.com", 587, SecureSocketOptions.StartTls, "iCloud", true);
public static ProviderEndpoint Outlook => new(
"smtp-mail.outlook.com", 587, SecureSocketOptions.StartTls, "Outlook.com", false);
}
Step 3: Build the Transport Orchestrator
The orchestrator handles connection lifecycle, timeout enforcement, and provider-specific diagnostic mapping. It delegates authentication to the injected strategy.
public class SmtpTransportOrchestrator
{
private readonly IAuthStrategy _authStrategy;
private readonly ProviderEndpoint _endpoint;
private readonly TimeSpan _connectionTimeout;
public SmtpTransportOrchestrator(
IAuthStrategy authStrategy,
ProviderEndpoint endpoint,
TimeSpan? connectionTimeout = null)
{
_authStrategy = authStrategy;
_endpoint = endpoint;
_connectionTimeout = connectionTimeout ?? TimeSpan.FromSeconds(15);
}
public async Task<DeliveryResult> DeliverAsync(
MimeMessage message,
CancellationToken ct = default)
{
using var client = new SmtpClient
{
Timeout = (int)_connectionTimeout.TotalMilliseconds
};
try
{
await client.ConnectAsync(
_endpoint.Host,
_endpoint.Port,
_endpoint.Security,
ct);
await _authStrategy.AuthenticateAsync(client, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
return DeliveryResult.Success;
}
catch (SmtpCommandException ex)
{
return MapProviderDiagnostics(ex, _endpoint);
}
catch (OperationCanceledException)
{
return DeliveryResult.Timeout;
}
catch (Exception ex)
{
return DeliveryResult.Failure($"Transport error: {ex.Message}");
}
}
private static DeliveryResult MapProviderDiagnostics(SmtpCommandException ex, ProviderEndpoint endpoint)
{
if (ex.StatusCode == SmtpStatusCode.AuthenticationFailed)
{
if (endpoint.DisplayName == "iCloud")
{
return DeliveryResult.Failure(
"iCloud rejected credentials. Verify: full email address used, app-specific password generated, STARTTLS enabled.");
}
if (endpoint.DisplayName == "Outlook.com")
{
return DeliveryResult.Failure(
"Outlook.com auth failed. Check: SMTP toggle persistence in account settings, Modern Auth requirements, or app password validity.");
}
return DeliveryResult.Failure("Authentication rejected. Verify credentials and 2FA/app password status.");
}
return DeliveryResult.Failure($"SMTP command failed: {ex.Message}");
}
}
public record DeliveryResult(bool IsSuccess, string? Message = null)
{
public static DeliveryResult Success => new(true);
public static DeliveryResult Timeout => new(false, "Connection or send operation timed out.");
public static DeliveryResult Failure(string reason) => new(false, reason);
}
Architecture Decisions & Rationale
- Strategy Pattern for Auth: Decouples credential mechanics from transport logic. Adding OAuth2 refresh flows or provider-specific token exchange doesn't require rewriting the SMTP pipeline.
- Explicit Endpoint Records: Prevents configuration drift. Provider-specific quirks (like iCloud's full-email username requirement) are documented at the type level, not buried in error logs.
- Diagnostic Mapping:
SmtpCommandException codes are generic. Mapping them to provider-specific recovery steps transforms opaque failures into actionable UX. This is critical for customer-facing onboarding.
- Timeout Enforcement: MailKit defaults to infinite waits on some network operations. Explicit timeout configuration prevents thread pool exhaustion during provider outages.
- Result Objects over Exceptions: Returning
DeliveryResult avoids exception-driven control flow for expected failure states (auth rejection, timeouts), improving telemetry and retry logic.
Pitfall Guide
1. Relying on System.Net.Mail.SmtpClient
Explanation: The built-in .NET class lacks STARTTLS negotiation flexibility, modern SASL mechanisms, and async cancellation support. Microsoft explicitly recommends against it for new development.
Fix: Use MailKit or a maintained alternative. It provides explicit security option enums, OAuth2 SASL support, and proper async disposal patterns.
2. Hardcoding Port 587 Without Fallback Negotiation
Explanation: While 587 is standard for submission, some enterprise or legacy relays require 465 (implicit TLS) or 25 (internal relay). Blindly targeting 587 causes silent failures in hybrid environments.
Fix: Expose port and security options as configuration. Implement a connection fallback chain: StartTls (587) -> SslOnConnect (465) -> None (25) with explicit logging at each step.
3. Treating App Passwords as Production Credentials
Explanation: App passwords bypass consent screens but lack token expiration, revocation APIs, and scope limitation. Providers actively deprecate them for third-party apps. Using them in production creates security debt and sudden breakage when policies shift.
Fix: Reserve app passwords strictly for developer-owned test accounts. Implement OAuth2 or provider-specific API tokens for customer onboarding. Store tokens encrypted at rest with automatic refresh.
Explanation: Providers enforce strict username formatting. iCloud requires the full email address for SMTP auth. Gmail accepts local parts but fails silently if domain mismatch occurs. Outlook.com sometimes rejects partial usernames during OAuth exchange.
Fix: Validate username format against provider rules before connection. Use the RequiresFullEmailUsername flag in endpoint configuration to enforce validation early.
5. Failing to Handle OAuth Token Expiration
Explanation: Access tokens typically expire in 60-90 minutes. Assuming a token remains valid across multiple sends causes 401 Unauthorized or 535 Auth Failed responses mid-operation.
Fix: Implement a token refresh wrapper around the OAuth strategy. Check ExpiresAt before authentication. If expired, call the provider's token endpoint, update storage, and retry. Cache refresh tokens securely and handle revocation gracefully.
Explanation: Microsoft's consumer portal sometimes fails to persist the SMTP enable toggle due to backend sync delays or precondition failures (412 Precondition Failed in OWA services). Developers assume the toggle worked, then face authentication failures.
Fix: Add a verification step after enabling SMTP. Attempt a lightweight AUTH LOGIN or NOOP command. If it fails, surface a clear message instructing the user to retry the toggle or contact support. Do not assume UI state matches backend state.
7. Generic Error Handling Instead of Provider-Specific Diagnostics
Explanation: Returning Authentication failed for all providers forces users to debug identity systems they don't control. Each provider has distinct failure modes and recovery paths.
Fix: Map SmtpStatusCode and response codes to provider-specific messages. Include actionable checks: credential type, TLS version, username format, and account state. Log raw responses internally but expose sanitized, provider-aware guidance to users.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal tooling / Dev smoke tests | Static app passwords | Fast setup, no consent flow, sufficient for controlled accounts | Low (dev time) |
| Customer-facing SaaS onboarding | OAuth2 / Modern Auth | Provider-mandated, secure, supports scope limitation and revocation | Medium (implementation + token storage) |
| High-volume transactional email | Dedicated ESP API (SendGrid, Postmark, AWS SES) | Bypasses consumer SMTP limits, provides delivery analytics, handles bounces | High (ESP subscription + integration) |
| Enterprise / Hybrid relay | Custom endpoint config with fallback chain | Supports legacy systems, explicit TLS negotiation, internal routing | Low-Medium (configuration overhead) |
Configuration Template
{
"EmailProviders": [
{
"ProviderId": "gmail",
"Host": "smtp.gmail.com",
"Port": 587,
"SecurityMode": "StartTls",
"RequiresFullEmailUsername": false,
"AuthType": "AppPassword",
"Diagnostics": {
"AuthFailed": "Verify app password generation and 2FA status. Ensure STARTTLS is enabled."
}
},
{
"ProviderId": "icloud",
"Host": "smtp.mail.me.com",
"Port": 587,
"SecurityMode": "StartTls",
"RequiresFullEmailUsername": true,
"AuthType": "AppPassword",
"Diagnostics": {
"AuthFailed": "Use full iCloud email address. Generate app-specific password in Apple ID settings."
}
},
{
"ProviderId": "outlook",
"Host": "smtp-mail.outlook.com",
"Port": 587,
"SecurityMode": "StartTls",
"RequiresFullEmailUsername": false,
"AuthType": "OAuth2",
"Diagnostics": {
"AuthFailed": "Verify SMTP toggle persistence. Check Modern Auth requirements. Ensure token scopes include SMTP access."
}
}
],
"TransportDefaults": {
"ConnectionTimeoutMs": 15000,
"SendTimeoutMs": 30000,
"MaxRetries": 2,
"RetryDelayMs": 2000
}
}
Quick Start Guide
- Install MailKit: Run
dotnet add package MailKit in your project. This provides modern SMTP transport, TLS negotiation, and SASL authentication support.
- Define Endpoint & Strategy: Create a
ProviderEndpoint record for your target provider. Instantiate StaticCredentialStrategy for testing or OAuth2Strategy for production.
- Initialize Orchestrator: Pass the strategy and endpoint to
SmtpTransportOrchestrator. Configure timeouts and retry policies via constructor or configuration binding.
- Build & Send: Construct a
MimeMessage, set headers and body, then call DeliverAsync. Handle DeliveryResult to route success, timeout, or provider-specific failures to telemetry or user feedback.
- Validate Diagnostics: Intentionally trigger auth failures with incorrect credentials. Verify that diagnostic messages match provider-specific recovery steps. Log raw SMTP responses internally for engineering review.