This repository contains MailService.Api, a small ASP.NET Core app that exposes a single HTTP endpoint for sending e-mail. Incoming requests must specify a sender address, and the server looks up the provider configuration assigned to that address instead of accepting provider credentials from callers.
Flow:
- The client sends a
POSTwith JSON (Content-Type: application/json) to/api/mail/send. - The endpoint checks the
apiKeyagainst all keys in the machine-localapi-keys.jsonfile. - It resolves
fromEmailto a sender profile inMAIL_SENDER_CONFIG_JSON(each allowed sender is a key in that JSON object). - It validates recipients, subject, and that the requested
messageTypehas the matching body payload. EmailSenderServicesends through the provider paired with that sender: Amazon Simple Email Service, Azure Email Communication Service, or SMTP through MailKit.- The app always writes local Serilog files under
logs/and can optionally export remote telemetry to Application Insights or Zipkin. - Transient provider send failures are retried with Polly before the request is reported as failed.
Typical HTTP results:
| Status | Meaning |
|---|---|
200 |
Success; JSON body includes a confirmation message. |
401 |
API key missing or wrong. |
400 |
Validation error (e.g. unknown fromEmail, empty toEmails, missing body). |
500 |
Provider or internal failure while sending (details in server logs). |
Property names in JSON are camelCase (for example fromEmail, toEmails, messageType, plainTextBody).
The API exposes one endpoint:
POST /api/mail/send
Each request must include:
apiKey: API key that must match one value inapi-keys.jsonfromEmail: sender e-mail address that must exist inMAIL_SENDER_CONFIG_JSONtoEmails: list of recipient addressessubject: mail subjectmessageType: one ofPlainText,Html, orBothplainTextBodyand/orhtmlBody: required according tomessageType
The service validates:
- API key
- Sender (
fromEmail) exists inMAIL_SENDER_CONFIG_JSON - Request payload contains required fields
- Requested
messageTypehas the matching payload:
messageType |
Required payload |
|---|---|
PlainText |
plainTextBody |
Html |
htmlBody |
Both |
plainTextBody and htmlBody |
If valid, it selects the one provider configured for the sender and sends the message through that provider.
Extra unrequested body fields are ignored. For example, when messageType is PlainText, only plainTextBody is delivered even if htmlBody is also supplied.
The service does not pick only one body or concatenate them into a single blob. It passes both body formats to the selected provider. The SMTP provider uses MimeKit’s BodyBuilder, which produces a standard multipart/alternative MIME message: both a plain text part and an HTML part are included in the same outgoing e-mail.
Recipients’ mail programs then choose what to display: clients that support HTML usually show the HTML part, and plain-text-only clients (or “view as plain text”) typically show the plain text part. You should treat the two fields as the same content in two formats (for example HTML newsletter plus an accessible text fallback), not as two unrelated bodies.
If messageType is PlainText or Html, the message is sent with that single requested part only.
Provider sends use a Polly retry pipeline:
- Applies to SMTP, Amazon SES, and Azure Email Communication Service.
- Retries likely transient failures such as socket errors, timeouts, SMTP 4xx responses, provider throttling, HTTP 408/429, and HTTP 5xx failures.
- Uses 3 retry attempts with exponential backoff and jitter.
- Logs every retry attempt with provider name, retry attempt, delay, and exception.
Permanent failures, invalid configuration, malformed requests, and cancellation are not retried. Final send failures are logged by the provider service and by the endpoint before returning 500.
The app also has a global exception handler for unexpected request failures. Those failures are logged and returned as 500 with a generic response.
Place a .env file next to the project during local development:
MailService.Api/.env
When published to IIS, place the same file in the publish/content root next to MailService.Api.dll and web.config.
The .env reader is line-based, so keep MAIL_SENDER_CONFIG_JSON on one physical line. The expanded JSON examples below are for readability only.
| Parameter | Required | Source | Description |
|---|---|---|---|
MAIL_SENDER_CONFIG_JSON |
Yes | .env |
JSON object keyed by allowed sender e-mail address. Each sender config chooses exactly one delivery provider. |
APPLICATIONINSIGHTS_CONNECTION_STRING |
No | .env or process environment |
Azure Application Insights connection string used by Azure Monitor OpenTelemetry. When present, it is the only remote telemetry exporter. |
ZIPKIN_ENDPOINT |
No | .env or process environment |
Absolute URI for the Zipkin spans endpoint, for example http://localhost:9411/api/v2/spans. Used only when Application Insights is not configured. |
OTEL_EXPORTER_ZIPKIN_ENDPOINT |
No | .env or process environment |
Alternate Zipkin endpoint name. Used only when ZIPKIN_ENDPOINT is absent and Application Insights is not configured. |
Telemetry values in .env override process environment variables with the same name. Sender/provider configuration is loaded from .env only.
Telemetry selection rules:
- If
APPLICATIONINSIGHTS_CONNECTION_STRINGis present, the app sends traces/spans, metrics, and logs to Application Insights. - If Application Insights is absent and a Zipkin endpoint is present, the app sends OpenTelemetry traces/spans to Zipkin.
- If both Application Insights and Zipkin are configured, only Application Insights is used.
- If neither remote destination is configured, no remote telemetry exporter is configured.
- In all cases, local Serilog files are written and rotated daily with 7 files retained.
MAIL_SENDER_CONFIG_JSON must be a JSON object where each property name is a sender e-mail address that callers are allowed to use in fromEmail.
Each sender object accepts:
| Property | Required | Description |
|---|---|---|
provider |
Yes | One of Smtp, AmazonSes, or AzureEmailCommunicationService. |
fromName |
No | Display name used where the provider supports sender display names. |
smtp |
Only for Smtp |
SMTP provider settings. Must be the only provider block when provider is Smtp. |
amazonSes |
Only for AmazonSes |
Amazon SES provider settings. Must be the only provider block when provider is AmazonSes. |
azureEmailCommunicationService |
Only for AzureEmailCommunicationService |
Azure Email Communication Service provider settings. Must be the only provider block when provider is AzureEmailCommunicationService. |
Every sender must have exactly one provider block. For example, a sender cannot define both smtp and amazonSes.
SMTP provider fields:
| Property | Required | Default | Description |
|---|---|---|---|
host |
Yes | None | SMTP host name or IP address. |
port |
No | 587 |
SMTP port. Startup validation rejects values less than 1. |
enableSsl |
No | true |
Enables TLS/SSL behavior for the SMTP connection. |
username |
No | None | SMTP username. Must be provided together with password when authentication is required. |
password |
No | None | SMTP password. Must be provided together with username. |
Amazon SES provider fields:
| Property | Required | Description |
|---|---|---|
region |
Yes | AWS region system name, for example us-east-1. |
accessKeyId |
No | Optional AWS access key id. If omitted, the AWS SDK default credential chain is used. |
secretAccessKey |
No | Optional AWS secret access key. Required when accessKeyId is supplied. |
sessionToken |
No | Optional session token for temporary AWS credentials. Requires accessKeyId and secretAccessKey. |
Azure Email Communication Service provider fields:
| Property | Required | Description |
|---|---|---|
connectionString |
Yes | Azure Communication Services connection string. Startup validates the SDK connection string shape without contacting Azure. |
This sample configures three allowed sender addresses, one for each supported provider. It also configures Application Insights. The commented Zipkin line can be used when Application Insights is omitted.
# MailService.Api/.env
APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://example.in.applicationinsights.azure.com/
# ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans
MAIL_SENDER_CONFIG_JSON={"smtp@example.com":{"provider":"Smtp","fromName":"Mail Service","smtp":{"host":"smtp.example.com","port":587,"enableSsl":true,"username":"smtp-user","password":"smtp-password"}},"ses@example.com":{"provider":"AmazonSes","amazonSes":{"region":"us-east-1","accessKeyId":"optional-access-key","secretAccessKey":"optional-secret-key"}},"azure@example.com":{"provider":"AzureEmailCommunicationService","azureEmailCommunicationService":{"connectionString":"endpoint=https://example.communication.azure.com/;accesskey=replace_with_access_key"}}}The same MAIL_SENDER_CONFIG_JSON value shown as formatted JSON:
{
"smtp@example.com": {
"provider": "Smtp",
"fromName": "Mail Service",
"smtp": {
"host": "smtp.example.com",
"port": 587,
"enableSsl": true,
"username": "smtp-user",
"password": "smtp-password"
}
},
"ses@example.com": {
"provider": "AmazonSes",
"amazonSes": {
"region": "us-east-1",
"accessKeyId": "optional-access-key",
"secretAccessKey": "optional-secret-key",
"sessionToken": "optional-session-token"
}
},
"azure@example.com": {
"provider": "AzureEmailCommunicationService",
"azureEmailCommunicationService": {
"connectionString": "endpoint=https://example.communication.azure.com/;accesskey=replace_with_access_key"
}
}
}Amazon SES credentials are optional in MAIL_SENDER_CONFIG_JSON. If accessKeyId and secretAccessKey are omitted, the AWS SDK uses its default credential chain, which is useful for hosted environments that rely on IAM roles. If one of those values is present, both must be present. sessionToken can also be supplied for temporary AWS credentials.
Startup validates required configuration before the app begins serving requests. It fails fast and logs promptly for missing .env, missing sender mappings, duplicate senders, invalid sender e-mail keys, unsupported providers, multiple provider blocks for one sender, invalid SMTP host/port/auth pairs, invalid Amazon SES credential combinations, invalid Azure Email Communication Service connection string shape, and malformed telemetry endpoints.
MAIL_SENDER_CONFIG_JSON={"noreply@example.com":{"provider":"Smtp","fromName":"No Reply","smtp":{"host":"smtp.example.com","port":587,"enableSsl":true,"username":"smtp-user","password":"smtp-password"}}}Use this form when the runtime environment supplies AWS credentials through IAM role, environment variables, shared credentials, or another AWS SDK-supported credential source.
MAIL_SENDER_CONFIG_JSON={"alerts@example.com":{"provider":"AmazonSes","fromName":"Alerts","amazonSes":{"region":"us-east-1"}}}MAIL_SENDER_CONFIG_JSON={"notifications@example.com":{"provider":"AzureEmailCommunicationService","azureEmailCommunicationService":{"connectionString":"endpoint=https://example.communication.azure.com/;accesskey=replace_with_access_key"}}}ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans
MAIL_SENDER_CONFIG_JSON={"smtp@example.com":{"provider":"Smtp","smtp":{"host":"smtp.example.com","port":587,"enableSsl":true}}}The service always configures Serilog local files, regardless of remote telemetry settings:
- Path:
MailService.Api/logs/mailservice-*.logduring local development, orlogs/under the published content root on IIS. - Rotation: daily.
- Retention: 7 files.
These files are ignored by Git.
API keys are stored separately from .env in:
MailService.Api/api-keys.json
When the app starts and this file does not exist, it creates the file with ten independently generated random 10-character alphanumeric keys. Later startups reuse the same file, so those initial keys are effectively created once per machine or deployment folder.
Sample api-keys.json file:
{
"apiKeys": [
"A1b2C3d4E5",
"F6g7H8i9J0",
"kLmN123pQr"
]
}You can add more than ten valid keys by editing the apiKeys array. Every key must be a unique 10-character alphanumeric value. Requests without an apiKey, or with a value that does not match any configured key, receive 401 with {"message":"Access denied."}.
Startup validation rejects an existing API key file when it contains invalid JSON, an empty apiKeys array, duplicate keys, or any key that is not exactly 10 ASCII letters/digits.
cd MailService.Api
dotnet runBy default (see Properties/launchSettings.json) the app listens on http://localhost:5127 and https://localhost:7012. Use the same base URL in your client examples.
These steps assume a Windows machine with IIS acting as the reverse proxy; the ASP.NET Core Module (ANCM) starts your app in a worker process.
- Turn on Internet Information Services and the features you need (at minimum Web Management Tools and World Wide Web Services). On Windows Server this is done in Server Manager → Add Roles and Features; on Windows client editions, Turn Windows features on or off.
- Install the ASP.NET Core Hosting Bundle for the same major .NET version as the app. This repository targets
net10.0(MailService.Api.csproj), so install the bundle that includes that runtime. The hosting bundle installs the .NET runtime and the IIS AspNetCoreModuleV2 shim.
Restart IIS after installing the bundle (for example iisreset from an elevated command prompt, or restart the server).
On a machine with the SDK, from the repository root:
dotnet publish .\MailService.Api\MailService.Api.csproj -c Release -o C:\Publish\MailService.ApiUse any output folder you prefer; that folder becomes the IIS site’s physical path.
The publish output includes web.config, which tells IIS to forward requests to your app via ANCM.
The app loads .env from its content root—on IIS, that is the site’s physical path (the publish folder), not the project folder on your dev machine.
Copy a valid .env file into the publish directory next to MailService.Api.dll (same place as web.config), with MAIL_SENDER_CONFIG_JSON as described in Configuration (.env).
Ensure the IIS app pool identity can read that file (and the folder). Avoid granting broader permissions than needed.
The same physical folder also holds api-keys.json. If it is missing, the app creates it on startup, so the IIS app pool identity needs write access for the first startup and read access afterward. To preserve API keys across redeployments, keep or copy this file with the deployment.
The app also writes Serilog files under logs/ in the same physical folder, so the IIS app pool identity needs write access to create and append those logs.
- Open IIS Manager → Application Pools → Add Application Pool.
- Name: e.g.
MailService.Api. - .NET CLR version: No Managed Code (ASP.NET Core runs out-of-process; the CLR setting does not load classic .NET Framework).
- Managed pipeline mode: Integrated (default).
- Name: e.g.
- Advanced Settings (optional but common): set Identity to a dedicated account if you do not want ApplicationPoolIdentity.
- Under Sites → Add Website:
- Physical path: your publish folder (e.g.
C:\Publish\MailService.Api). - Binding: host name, port, and (for HTTPS) certificate as required. There is no fixed port; use 80/443 or another port your clients will call.
- Physical path: your publish folder (e.g.
Start the site (and ensure the app pool is started). Your API base URL is then http://your-server/api/... or https://your-server/api/... according to the binding—for example POST https://your-server/api/mail/send. If the app is installed under an application or virtual directory (for example /MailService), prefix that path: POST https://your-server/MailService/api/mail/send.
- For HTTPS, create a binding in IIS and assign a server certificate (internal CA, public CA, or development certificate).
- Open Windows Defender Firewall (or your network firewall) for the HTTP/HTTPS ports you expose.
- 502.5 / app won’t start: Usually a missing runtime or bad
web.config. Confirm the hosting bundle version matchesnet10.0and check Event Viewer → Windows Logs → Application for errors from IIS AspNetCore Module or .NET Runtime. - 401/400 from the API when the app does run: configuration or request shape; see the REST sections above.
- stdout logs: You can enable temporary stdout logging in
web.configfor the published app per Microsoft’s ASP.NET Core IIS troubleshooting (disable or remove in production once resolved).
Telemetry keys can be supplied through process environment variables instead of .env. Sender/provider configuration is still read from .env, so for IIS the straightforward approach is a .env file in the publish folder plus optional App Pool environment variables for APPLICATIONINSIGHTS_CONNECTION_STRING, ZIPKIN_ENDPOINT, or OTEL_EXPORTER_ZIPKIN_ENDPOINT.
{
"apiKey": "A1b2C3d4E5",
"fromEmail": "noreply@example.com",
"toEmails": ["recipient@example.com"],
"subject": "Test message",
"messageType": "PlainText",
"plainTextBody": "Hello from MailService."
}Use HttpClient and PostAsJsonAsync. The snippet below uses camelCase property names in an anonymous type so the JSON matches what the API expects.
using System.Net.Http.Json;
const string baseUrl = "http://localhost:5127"; // or https://localhost:7012
using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
var payload = new
{
apiKey = "A1b2C3d4E5",
fromEmail = "noreply@example.com",
toEmails = new[] { "recipient@example.com" },
subject = "Test message",
messageType = "PlainText",
plainTextBody = "Hello from MailService.",
// For messageType = "Html", provide htmlBody instead.
// For messageType = "Both", provide both plainTextBody and htmlBody.
};
using var response = await client.PostAsJsonAsync("/api/mail/send", payload);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Mail API returned {(int)response.StatusCode}: {errorBody}");
}
// Success: e.g. { "message": "Mail sent successfully." }
var json = await response.Content.ReadAsStringAsync();For a shared client library, you can replace the anonymous type with a class whose properties use [JsonPropertyName("apiKey")] (and so on) or configure JsonSerializerOptions with PropertyNamingPolicy = JsonNamingPolicy.CamelCase.
Send JSON with curl (or any HTTP client). Encode the body with json_encode and set Content-Type: application/json.
<?php
$baseUrl = 'http://localhost:5127'; // or https://localhost:7012
$url = rtrim($baseUrl, '/') . '/api/mail/send';
$payload = [
'apiKey' => 'A1b2C3d4E5',
'fromEmail' => 'noreply@example.com',
'toEmails' => ['recipient@example.com'],
'subject' => 'Test message',
'messageType' => 'PlainText',
'plainTextBody' => 'Hello from MailService.',
// For messageType = 'Html', provide htmlBody instead.
// For messageType = 'Both', provide both plainTextBody and htmlBody.
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
]);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
throw new RuntimeException('cURL error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("Mail API returned HTTP $status: $responseBody");
}
// Success: e.g. {"message":"Mail sent successfully."}
echo $responseBody;If you use Guzzle, the same payload can be sent with POST $url, ['json' => $payload].