EmailSwitch 10.4.0
dotnet add package EmailSwitch --version 10.4.0
NuGet\Install-Package EmailSwitch -Version 10.4.0
<PackageReference Include="EmailSwitch" Version="10.4.0" />
<PackageVersion Include="EmailSwitch" Version="10.4.0" />
<PackageReference Include="EmailSwitch" />
paket add EmailSwitch --version 10.4.0
#r "nuget: EmailSwitch, 10.4.0"
#:package EmailSwitch@10.4.0
#addin nuget:?package=EmailSwitch&version=10.4.0
#tool nuget:?package=EmailSwitch&version=10.4.0
EmailSwitch
EmailSwitch is an open-source C# class library that sends and verifies email one-time
passcodes (OTPs). You call SendOTP to email someone a code and VerifyOTP to check it; EmailSwitch
owns the session, the code, its expiry, the attempt limits and the audit trail.
Codes are generated and verified through MongoDbTokenManager and everything is stored in your own MongoDB instance, configured with MongoDbService. No OTP state ever leaves your infrastructure.
Features
- Send and verify email OTPs — session lifecycle, expiry and attempt limits handled for you
- Provider failover on rejection — an ordered priority list, retried round-robin, so a provider that rejects the send — bad key, exhausted quota, malformed request, timeout — falls through to the next (accepted is not delivered)
- Delivery failover on bounce — optional webhooks that re-send the same code through the next provider when one reports the message it accepted will never arrive (see below)
DevConsoleprovider for local testing — writes the verification email to the log instead of sending it, so no credentials are needed (see below)- Covers SendGrid, Resend and Brevo as real providers (more can be added)
- Audit trail in your own MongoDB — every session, send attempt, failed verification and logo render is recorded
How it works
For each email address EmailSwitch opens a session. Creating one mints a code through
MongoDbTokenManager, renders the email, and stores the session in MongoDB. A send budget is built
from your Priority list repeated MaxRoundRobinAttempts times; each send attempt spends one slot,
and a rejected attempt falls through to the next provider. An attempt the provider accepts ends the
loop, whatever becomes of the email afterwards — see Accepted is not
delivered.
While a session is live, calling SendOTP again reuses it — the recipient gets the same code,
not a new one. The session ends when it is verified, when SessionTimeoutInSeconds elapses, or
after MaximumFailedAttemptsToVerify wrong guesses.
Verification is atomic: of two concurrent requests submitting the same correct code, exactly one succeeds. A wrong guess leaves the code usable, so an attacker cannot lock the legitimate holder out by guessing.
Sessions are kept as an audit record for SessionRetentionDays after they expire — 90 days by
default — and are then removed automatically by a MongoDB TTL index. Expired tokens are cleaned up
by MongoDbTokenManager separately.
The rendered email is held on the session only while a resend could still need it, and is dropped as soon as the code is verified or the send budget is spent. It carries the code in cleartext, so it must not sit in the audit record for the retention period — what survives is the session's timestamps and its send attempts, not the code or the contact details the body listed.
Getting started
1. Install
dotnet add package EmailSwitch
2. Prerequisites
| Requirement | Why |
|---|---|
| .NET 10.0 | The package targets net10.0. |
| An ASP.NET Core host | EmailSwitch maps its own minimal-API endpoint for the email signature logo, so it references the ASP.NET Core shared framework. |
| MongoDB | Sessions and tokens are stored in your instance. MongoDbTokenManager creates a TTL index to clean up expired tokens. |
| A SendGrid, Resend or Brevo account | Only for real sending — not needed if you use the DevConsole provider. All three need a verified sender or domain before they will deliver to anyone, and their free tiers each carry a limit that matters on an OTP path; see Worth knowing. |
3. Configure
Every section below is required. EmailSwitch fails at startup with a named error rather than misbehaving later, so a missing key is reported clearly.
{
"MongoDbSettings": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "MyApp"
},
"Settings": {
"BaseUrl": "https://api.example.com",
"FrontendUrl": "https://app.example.com"
},
"EmailSwitchSettings": {
"OtpLength": 6,
"SignatureLogoPath": "wwwroot/logo.png",
"Controls": {
"Priority": [ "Resend", "SendGrid" ],
"MaxRoundRobinAttempts": 2,
"MaximumFailedAttemptsToVerify": 3,
"SessionTimeoutInSeconds": 240
},
"Resend": {
"From": "noreply@example.com",
"ApiKey": "re_your-api-key"
},
"Brevo": {
"From": "noreply@example.com",
"ApiKey": "xkeysib-your-api-key"
},
"SendGrid": {
"From": "noreply@example.com",
"Password": "SG.your-api-key"
}
}
}
You only need a section for the providers you actually name in Priority. A section for a provider
you do not use is never read, and a provider you do name but do not configure fails startup.
Settings:BaseUrl is the public root of your API — the signature logo URL embedded in the email is
built from it, so it must be reachable by the recipient's email client.
Keep your provider keys out of appsettings.json — Resend:ApiKey and SendGrid:Password
both. Put them in user secrets, an environment variable or a key vault. The two are named
differently because despite its name SendGrid:Password is an API key, and renaming a
configuration key would break every existing consumer on upgrade; newer providers use the accurate
name rather than inheriting the mistake.
4. Register the services
EmailSwitch depends on MongoDbService and MongoDbTokenManager, and you must register both — it does not do it for you:
using EmailSwitch;
using MongoDbService;
using MongoDbTokenManager;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMongoDbServices();
builder.Services.AddMongoDbTokenServices();
builder.Services.AddEmailSwitchServices();
5. Map the endpoints
Required for the signature logo in the email to render — without it the image 404s:
var app = builder.Build();
app.AddEmailSwitchApiEndpoints();
app.Run();
This maps GET /emailswitch/logo/{sessionId}, which serves the file at SignatureLogoPath. The
endpoint is public and unauthenticated, because email clients fetch it with no credentials.
6. Send and verify
using EmailSwitch;
using EmailSwitch.Common.DTOs;
using HumanLanguages;
using SMSwitch.Common.DTOs;
app.MapPost("/send", async (EmailSwitchService emailSwitch, string email) =>
{
var response = await emailSwitch.SendOTP(
email: email, // string converts implicitly
verifiedMobileNumbers: [],
verifiedEmails: [],
preferredLanguageIsoCodeList: [new LanguageIsoCode(LanguageId.en)],
userAgent: UserAgent.WebBrowser);
return response.IsSent
? Results.Ok(new { response.OtpLength, response.ExpiryDateTimeOffset })
: Results.Problem("Could not send the verification code.");
});
app.MapPost("/verify", async (EmailSwitchService emailSwitch, string email, string code) =>
{
var response = await emailSwitch.VerifyOTP(email, code);
if (response.Verified) return Results.Ok();
// Expired means there is no live session: request a new code rather than retrying this one.
return response.Expired
? Results.Problem("That code has expired. Please request a new one.")
: Results.Problem("That code is not correct.");
});
API
SendOTP
| Parameter | Notes |
|---|---|
email |
EmailIdentifier. A string converts implicitly. |
verifiedMobileNumbers |
MobileNumber[] from SMSwitch. Listed in the email body as a "these are the contacts we already know for you" cue. Pass [] if you have none. |
verifiedEmails |
EmailIdentifier[], shown for the same reason. Pass [] if you have none. |
preferredLanguageIsoCodeList |
HashSet<LanguageIsoCode>. The first entry wins. Only English and Danish subjects are translated today; anything else falls back to English. |
userAgent |
Accepted for parity with SMSwitch. Not currently used when rendering the email. |
Returns EmailSwitchResponseSendOTP:
| Field | Meaning |
|---|---|
IsSent |
Whether a provider accepted the message — not whether it was delivered. See below. |
OtpLength |
Digits in the code, for sizing your input field. |
ExpiryDateTimeOffset |
When the session expires, for a countdown. |
Accepted is not delivered
IsSent = true means a provider took responsibility for the message and returned a success status.
It does not mean the email reached anyone. Every provider here accepts a send synchronously and
delivers asynchronously, so bounces, suppression lists, unauthenticated senders and recipient-server
refusals all surface after SendOTP has returned — in the provider's dashboard or over its event
webhooks. SendGrid documents this directly: "SendGrid API Returns '202 Accepted' Response but
doesn't Send Email".
The consequence on an OTP path is worth being blunt about: your user can be told the code was sent, and never receive it, and EmailSwitch cannot know. Provider failover cannot help, because at the moment it has to decide, the provider has reported success.
What to do about it:
- Turn on delivery failover — see below. EmailSwitch can consume the provider's delivery events and re-send the same code through the next provider when one reports that a message will never arrive.
- Watch your provider's dashboard or event webhooks. That is the only place a delivery failure is visible. A monitor on bounce and block rates is not optional if this is your login path.
- Get sender authentication right before going live — SPF, DKIM and a verified sending domain. Misconfigured sender authentication is the most common cause of accepted-then-dropped, and some providers report it only in the dashboard.
- Offer the user a resend. A live session reuses the same code, so a resend is cheap and is the practical remedy when the first email vanishes.
Delivery failover (webhooks)
Provider failover on rejection happens while SendOTP is running. Delivery failover happens
afterwards, when the provider tells you the message it accepted will never arrive — a hard bounce, a
blocked recipient, an unauthenticated sender. EmailSwitch receives that event, finds the session that
produced it, and sends the same code through the next provider in the session's budget.
Today this is implemented for Brevo. Resend and SendGrid sign their webhooks differently and are not wired up yet.
1. Configure a token
Brevo does not sign its webhooks — Resend uses Svix HMAC and SendGrid uses ECDSA, but Brevo offers only IP allowlisting. The endpoint can trigger an email send, so it must not be callable by anyone who finds the URL. EmailSwitch requires a shared secret in the path instead:
{
"EmailSwitchSettings": {
"Brevo": {
"From": "noreply@example.com",
"ApiKey": "xkeysib-your-api-key",
"WebhookToken": "a-long-random-string-from-a-csprng"
}
}
}
Generate it the way you would any secret — at least 32 random characters — and keep it in the same store as your API key. The path is compared in fixed time.
2. Map the endpoint
app.AddEmailSwitchApiEndpoints();
app.AddEmailSwitchWebhookEndpoints(); // opt-in; throws if WebhookToken is missing
Separate and opt-in on purpose: upgrading must not give an existing host a public POST endpoint it
never asked for. Calling it without a WebhookToken fails startup rather than mapping something
unauthenticated.
3. Point Brevo at it
Add a transactional webhook in Brevo for hardBounce, blocked, invalid and error, pointing
at:
https://api.example.com/emailswitch/webhooks/brevo/<your-WebhookToken>
What it will and will not do
- It needs at least two providers in
Priority. A single-provider list spends its only slot on the send that just failed, and the rendered email is retired with it — so there is nothing left to resend and nowhere to send it. This is the same condition rejection failover always had. - The resend carries the same code, because the session and its token are unchanged. A new code would leave two live in the recipient's inbox with no way to tell which one works.
- It is late by design.
SendOTPhas already returnedIsSent = trueand your user has already been told the code is on its way. The second email arrives seconds to minutes later. - It does nothing after the session expires. A bounce that arrives after
SessionTimeoutInSecondsis logged and dropped — there is no live code left to deliver. - It does nothing for a session the user has already replaced. If they gave up and requested a new code, the old session has handed back its claim and a late bounce for it is ignored, rather than putting a superseded code in front of them.
deferredandsoftBounceare deliberately not treated as failures. Brevo retries them itself, so acting on one would deliver a second copy of a code that is still in flight.spamis excluded too — the recipient marked a message they received.- Redeliveries are safe. Webhooks retry; each event is claimed server-side exactly once, so a redelivered bounce does not send a second email.
- The send budget is still the hard ceiling. A resend spends a slot, so no volume of events —
forged or genuine — can send more than
Priority.Count × MaxRoundRobinAttemptsin total.
VerifyOTP
Takes the address and the code the user typed. Returns EmailSwitchResponseVerifyOTP:
| Field | Meaning |
|---|---|
Verified |
The code was correct and has now been consumed. |
Expired |
There was no live session — it timed out, ran out of attempts, was already used, or could not be read. Ask the user to request a new code rather than retry. |
Email addresses
EmailIdentifier normalises before using an address as the session key: it lowercases, strips
plus-addressing, and collapses dots for gmail.com. So J.o.h.n+promo@Gmail.com and
john@gmail.com are one inbox and share a session. The address you passed in is preserved verbatim
for the actual send.
Configuration reference
| Key | Required | Default | Notes |
|---|---|---|---|
MongoDbSettings:ConnectionString |
yes | — | |
MongoDbSettings:DatabaseName |
no | Untitled-MongoDbService |
|
Settings:BaseUrl |
yes | — | Public API root; the logo URL is built from it. |
Settings:FrontendUrl |
yes | — | Required by the shared settings package. |
EmailSwitchSettings:OtpLength |
no | 6 |
|
EmailSwitchSettings:SignatureLogoPath |
yes | — | Read once at startup. .png, .jpg, .gif, .webp and .svg get a matching content type. |
EmailSwitchSettings:Controls:Priority |
yes | — | Ordered provider list — the order is the failover order. Case-insensitive; unrecognised names are logged and skipped, and a name repeated is kept once, in its first position. |
EmailSwitchSettings:Controls:MaxRoundRobinAttempts |
no | 1 |
Times the priority list repeats. Priority.Count × MaxRoundRobinAttempts is the total emails one session may send. |
EmailSwitchSettings:Controls:MaximumFailedAttemptsToVerify |
no | 3 |
Wrong guesses before the session dies. |
EmailSwitchSettings:Controls:SessionTimeoutInSeconds |
no | 240 |
Minimum 30. Below that, startup fails. |
EmailSwitchSettings:Controls:SessionRetentionDays |
no | 90 |
Days a session is kept after it expires, then removed by a TTL index. 0 or less keeps them indefinitely. |
EmailSwitchSettings:SendGrid:From |
if SendGrid used | — | Sender address; also used as reply-to. |
EmailSwitchSettings:SendGrid:Password |
if SendGrid used | — | Your SendGrid API key. Keep it in a secret store. |
EmailSwitchSettings:Resend:From |
if Resend used | — | Sender address; also used as reply-to. Must be on a domain verified in Resend, or delivery is limited to your own account address. |
EmailSwitchSettings:Resend:ApiKey |
if Resend used | — | Your Resend API key (re_…). Keep it in a secret store. Named ApiKey, not Password — see above. |
EmailSwitchSettings:Brevo:From |
if Brevo used | — | Sender address; also used as reply-to. Must be a verified sender or an authenticated domain in Brevo. |
EmailSwitchSettings:Brevo:ApiKey |
if Brevo used | — | Your Brevo API v3 key (xkeysib-…). Keep it in a secret store. |
EmailSwitchSettings:Brevo:WebhookToken |
if webhooks used | — | Shared secret in the webhook path. Required by AddEmailSwitchWebhookEndpoints(), which throws without it. Brevo does not sign its webhooks, so this is the only thing protecting an endpoint that can send email. See Delivery failover. |
Resend and Brevo are both reached over plain HTTPS with no SDK, so neither adds a package dependency to your app. Each request times out after 10 seconds, because a send sits on the login path with a user waiting on it.
Local testing without sending real email
For local development you can route messages to the DevConsole provider instead of a real one, so
no mail is sent and no credentials are needed. The rendered email — including the verification
code — is written to the log, and because codes are generated and verified through
MongoDbTokenManager in your own MongoDB instance, the full SendOTP → VerifyOTP flow works end to
end.
Put this in your appsettings.Development.json:
{
"EmailSwitchSettings": {
"Controls": {
"Priority": [ "DevConsole" ]
}
}
}
With DevConsole as the only provider you can leave every other provider section out entirely —
nothing constructs a provider unless it is actually named in Priority and resolved.
As a safety measure the DevConsole provider refuses to operate when the app runs in the
Production environment: it logs a critical error and reports the send as failed, so the provider
queue falls through to a real provider if one is configured after it.
The verification code is written to your logs in plain text. Never enable
DevConsoleanywhere real users receive codes, and keep those logs out of shared sinks.
Worth knowing
- A resend returns the same code. While a session is live,
SendOTPreuses it rather than minting a new code. - Sends are budgeted. Once
Priority.Count × MaxRoundRobinAttemptsattempts are spent,SendOTPreturnsIsSent = false. The code already delivered stays verifiable until the session expires. IsSent = truemeans accepted, not delivered, and failover only covers rejection. This is true of all three providers, not a quirk of any one of them — Accepted is not delivered explains what it means for a login path and what to do about it.- Sessions are the audit trail and expire on their own schedule.
SessionRetentionDays(90 by default) governs how long they survive past expiry. Sessions hold the verified email address, so set this to whatever your retention policy allows rather than leaving it unbounded. Note a TTL index gives time-based expiry, not erasure of one person's data on request. - The code is not kept — in your storage. MongoDbTokenManager stores only a hash of it, and the rendered email that contains it in cleartext is dropped as soon as it can no longer be needed — on verification, or once the send budget is spent. It is still readable in the sessions collection for the few minutes in between, so treat that collection as holding secrets even though nothing retains them. This guarantee stops at your infrastructure: whichever provider you route through is handed the rendered email, code included, and retains it on its own schedule and in its own dashboard. That is true of SendGrid and Resend alike, and it is not something EmailSwitch can shorten.
- Read the code off the log, not the database, if you are scripting against
DevConsole. It is no longer recoverable from the stored session once the budget is spent, which with a single provider is immediately after the first send. - The logo endpoint is public and keyed by session id, so a request to it reveals that a session exists. It also records each render, which doubles as an open-tracking signal.
If you use Resend
An OTP path is not a newsletter: a provider limit that would be a minor annoyance elsewhere locks
people out of their accounts here. These are the ones worth knowing before you point Priority at
Resend. All of them were checked against Resend's own documentation on 10-08-2026 — verify them
against the current docs rather than trusting this list.
- The free tier caps you at 100 emails/day and 3,000/month, on one verified domain. Past the cap
Resend answers
429, EmailSwitch reportsIsSent = false, and nobody can log in until the day rolls over. (quotas and limits) - The rate limit is 10 requests/second per team, shared across every API key — not per key. A
burst of logins, or another service on the same team, can push the OTP path into
429. - An unverified domain works in development and fails in production. Until you verify a domain
you can only send from
onboarding@resend.dev, and only to your own account's address. Every other recipient gets a403, which EmailSwitch logs with the response body. (403 on resend.dev) - Region selection is about where mail is sent from, not where data lives. Resend can dispatch
from
us-east-1,eu-west-1orsa-east-1, but its documentation states that account data, email metadata and logs are stored in the United States regardless. Resend publishes a DPA, states GDPR compliance and holds an EU-US Data Privacy Framework certification; if you are transferring personal data out of the EU, read those and the current subprocessor list yourself rather than taking this paragraph as a compliance assessment. (choosing a region, DPA) - Failover is your mitigation for all of the above. With
"Priority": [ "Resend", "SendGrid" ]a429or403from Resend falls through to the next provider within the same send budget, and the recipient still gets the code they asked for.
If you use Brevo
Checked against Brevo's own documentation on 12-08-2026; verify against the current docs rather than trusting this list.
- Do not run OTP on the free plan. Free-plan Brevo stamps a "Sent with Brevo" sticker into the body of every email it sends. On a verification email that puts third-party branding in front of exactly the users you want to be suspicious of anything unexpected, and it is not something EmailSwitch can strip. Removing it requires a paid plan or a paid add-on — confirm the current terms with Brevo directly. (free plan limits)
- The free plan also caps you at 300 emails/day. Past it sends fail and nobody can log in.
- The rate limit is generous — roughly 1,000 requests/second on the send endpoint, with
x-sib-ratelimit-limit,-remainingand-resetresponse headers. This is the one place Brevo is clearly better suited to a login path than Resend's 10 requests/second per team. (rate limits) - Brevo states that it stores data in the EU — its processing and database servers on its own hardware and Google Cloud, a GDPR-compliant DPA, and no Standard Contractual Clauses needed for standard deployments. That is a real contrast with Resend, whose account data and logs sit in the United States whichever region you send from, and for an EU deployment it may be the deciding factor between the two. This is Brevo's stated position, not an assessment: read the current DPA and subprocessor list yourself before relying on it. (data storage location, DPA)
- Authenticate your sender before you point a login path at Brevo. This one has bitten us. Brevo
accepts a send from an unauthenticated sender with
201and a message id, then drops it, reporting the reason only in the dashboard. EmailSwitch sees a success, spends the budget slot and never tries the next provider, so every code silently vanishes while the API says everything is fine. Resend is better here — it rejects an unverified sending domain synchronously with403, which does trigger failover. Verify the sender in Brevo and send one real test message before trusting it. This is the concrete case behind Accepted is not delivered. - Brevo authenticates with an
api-keyheader, not a bearer token, and answers201rather than200on a successful send. Both are handled; they are noted only because a proxy or gateway in front of it that normalises either will break sends.
Contributing
We welcome contributions! If you find a bug or have an idea for improvement, please submit an issue or a pull request on GitHub: https://github.com/prmeyn/EmailSwitch
License
This project is licensed under the MIT License.
Happy coding! 🚀🌐📚
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- HumanLanguages (>= 11.0.1)
- MongoDbTokenManager (>= 10.3.1)
- SendGrid (>= 9.29.3)
- SMSwitch (>= 10.4.1)
- uSignIn.CommonSettings (>= 10.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.4.0 | 21 | 8/14/2026 |
| 10.3.0 | 46 | 8/12/2026 |
| 10.2.0 | 74 | 8/11/2026 |
| 10.1.1 | 91 | 8/9/2026 |
| 10.1.0 | 105 | 8/1/2026 |
| 10.0.0 | 277 | 11/25/2025 |
| 5.0.0 | 581 | 1/8/2025 |
| 4.0.1 | 262 | 8/3/2024 |
| 4.0.0 | 194 | 8/3/2024 |
| 3.0.3 | 209 | 8/2/2024 |
| 3.0.2 | 186 | 8/2/2024 |
| 3.0.1 | 187 | 8/2/2024 |
| 3.0.0 | 170 | 8/2/2024 |
| 2.0.4 | 197 | 8/2/2024 |
| 2.0.3 | 191 | 8/2/2024 |
| 2.0.2 | 216 | 7/29/2024 |
| 2.0.1 | 206 | 7/28/2024 |
| 2.0.0 | 214 | 7/28/2024 |
| 1.1.0 | 210 | 7/28/2024 |