Common Mistakes
1. Forgetting to set a transport
Section titled “1. Forgetting to set a transport”// Problem: no transport configured, NullTransport is the defaultservices.AddPragmaticEmail();
var result = await emailSender.SendAsync(email);// result.Success == true but no email was actually sent!When no transport is configured, NullTransport is registered as the default (via TryAddSingleton). It silently succeeds without sending anything.
Fix: Always configure a transport explicitly.
services.AddPragmaticEmail(email => email .UseSmtp(smtp => { smtp.Host = "smtp.example.com"; smtp.Username = "user"; smtp.Password = "pass"; }));2. Not providing both TextBody and HtmlBody
Section titled “2. Not providing both TextBody and HtmlBody”// Problem: HTML-only email — some clients won't render itvar email = new EmailMessageBuilder() .From("noreply@hotel.com") .To("guest@example.com") .Subject("Confirmation") .HtmlBody("<h1>Confirmed!</h1>") .Build();Some email clients (especially plain-text clients, accessibility readers, and corporate firewalls) strip HTML. If there is no text fallback, the recipient sees nothing.
Fix: Always provide both bodies.
var email = new EmailMessageBuilder() .From("noreply@hotel.com") .To("guest@example.com") .Subject("Confirmation") .HtmlBody("<h1>Confirmed!</h1>") .TextBody("Your booking is confirmed.") .Build();3. Ignoring EmailResult
Section titled “3. Ignoring EmailResult”// Problem: fire-and-forget — failures go unnoticedawait emailSender.SendAsync(email);SMTP can fail for many reasons: authentication errors, rejected recipients, network timeouts. If you ignore the result, you won’t know.
Fix: Check result.Success and handle failures.
var result = await emailSender.SendAsync(email);if (!result.Success) logger.LogWarning("Email to {Address} failed: {Error}", guest.Email, result.ErrorMessage);4. Creating a new EmailMessageBuilder per field
Section titled “4. Creating a new EmailMessageBuilder per field”// Problem: not a real issue, but verbose and easy to lose the chainvar builder = new EmailMessageBuilder();builder.From("noreply@hotel.com");builder.To("guest@example.com");// Forgot to call builder.Subject(...) — Build() will throwvar email = builder.Build();The builder validates at Build() time. Missing From, To, Subject, or both bodies throws InvalidOperationException.
Fix: Use the fluent chain — it’s harder to skip required fields.
var email = new EmailMessageBuilder() .From("noreply@hotel.com") .To("guest@example.com") .Subject("Required") .TextBody("Also required") .Build();5. Putting DKIM private key in source code
Section titled “5. Putting DKIM private key in source code”// Problem: private key hardcoded.EnableDkim(dkim =>{ dkim.Domain = "hotel.com"; dkim.Selector = "pragmatic"; dkim.PrivateKeyPem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...";})Fix: Load from file, environment variable, or secrets manager.
.EnableDkim(dkim =>{ dkim.Domain = "hotel.com"; dkim.Selector = "pragmatic"; dkim.PrivateKeyPem = File.ReadAllText( Environment.GetEnvironmentVariable("DKIM_KEY_PATH")!);})6. Setting MaxConnections too high
Section titled “6. Setting MaxConnections too high”smtp.MaxConnections = 100; // Problem: overwhelms SMTP serverMost SMTP servers throttle connections per IP. Exceeding the limit causes connection rejections or bans.
Fix: Start with the default (5) and tune based on your SMTP provider’s limits. For SES, 10 is typically fine. For shared hosting SMTP, 2-3 is safer.
smtp.MaxConnections = 5; // Default — good for most use cases7. Not resetting InMemoryTransport between tests
Section titled “7. Not resetting InMemoryTransport between tests”// Problem: emails from previous test bleed into the next test[Fact]public async Task Test1(){ await sender.SendAsync(email1); transport.Sent.Should().HaveCount(1); // Passes}
[Fact]public async Task Test2(){ await sender.SendAsync(email2); transport.Sent.Should().HaveCount(1); // FAILS — count is 2!}If you share the same InMemoryTransport instance across tests (e.g., via a shared fixture), emails accumulate.
Fix: Call Reset() in setup, or use a fresh ServiceProvider per test.
transport.Reset();// orvar transport = services.AddEmailTestHarness(); // Fresh instance per test8. Using wrong Content-ID format for inline images
Section titled “8. Using wrong Content-ID format for inline images”// Problem: wrapping Content-ID in angle brackets.InlineImage("<logo>", logoBytes, "image/png")
// Then in HTML:// <img src="cid:<logo>" /> — double brackets, won't displayThe InlineImage method sets the Content-ID directly. The MIME writer adds <> around it in the header. Don’t add them yourself.
Fix: Use the bare identifier, and reference it as cid:{id} in HTML.
.InlineImage("logo", logoBytes, "image/png")
// HTML:// <img src="cid:logo" />9. Middleware ordering: content changes after signing
Section titled “9. Middleware ordering: content changes after signing”public class FooterMiddleware : IEmailMiddleware{ public int Order => 150; // Problem: runs AFTER DKIM (100) // ...}DKIM signs the message content. If middleware modifies the body or signed headers after DKIM runs, the signature becomes invalid and recipients’ mail servers will reject or flag the email.
Fix: Content-modifying middleware must have Order < 100 (before DKIM).
public int Order => 10; // Before DKIM (100) and S/MIME (200)10. Disposing IEmailSender manually
Section titled “10. Disposing IEmailSender manually”// Problem: IEmailSender doesn't implement IDisposableusing var sender = sp.GetRequiredService<IEmailSender>(); // Compile errorIEmailSender is not disposable. The underlying IEmailTransport is IAsyncDisposable and is managed by the DI container. The connection pool is cleaned up when the host shuts down.
Fix: Let DI manage the lifetime. Just inject IEmailSender and use it.