Skip to content

taskscape/MailSenderService

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 

Repository files navigation

MailService

What the mail sending service is

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:

  1. The client sends a POST with JSON (Content-Type: application/json) to /api/mail/send.
  2. The endpoint checks the apiKey against all keys in the machine-local api-keys.json file.
  3. It resolves fromEmail to a sender profile in MAIL_SENDER_CONFIG_JSON (each allowed sender is a key in that JSON object).
  4. It validates recipients, subject, and that the requested messageType has the matching body payload.
  5. EmailSenderService sends through the provider paired with that sender: Amazon Simple Email Service, Azure Email Communication Service, or SMTP through MailKit.
  6. The app always writes local Serilog files under logs/ and can optionally export remote telemetry to Application Insights or Zipkin.
  7. 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).

How the REST service works

The API exposes one endpoint:

  • POST /api/mail/send

Each request must include:

  • apiKey: API key that must match one value in api-keys.json
  • fromEmail: sender e-mail address that must exist in MAIL_SENDER_CONFIG_JSON
  • toEmails: list of recipient addresses
  • subject: mail subject
  • messageType: one of PlainText, Html, or Both
  • plainTextBody and/or htmlBody: required according to messageType

The service validates:

  1. API key
  2. Sender (fromEmail) exists in MAIL_SENDER_CONFIG_JSON
  3. Request payload contains required fields
  4. Requested messageType has 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.

When messageType is Both

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.

Reliability and failure handling

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.

Configuration (.env)

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.

Configuration parameters

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_STRING is 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.

Sender configuration parameters

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.

Sample .env file with all providers

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.

SMTP-only .env

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"}}}

Amazon SES .env using the AWS default credential chain

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"}}}

Azure Email Communication Service .env

MAIL_SENDER_CONFIG_JSON={"notifications@example.com":{"provider":"AzureEmailCommunicationService","azureEmailCommunicationService":{"connectionString":"endpoint=https://example.communication.azure.com/;accesskey=replace_with_access_key"}}}

Zipkin-only .env

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}}}

Local logs

The service always configures Serilog local files, regardless of remote telemetry settings:

  • Path: MailService.Api/logs/mailservice-*.log during local development, or logs/ under the published content root on IIS.
  • Rotation: daily.
  • Retention: 7 files.

These files are ignored by Git.

API keys file

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.

Run

cd MailService.Api
dotnet run

By 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.

Host on IIS (Windows)

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.

1. Install IIS and the .NET Hosting Bundle

  1. 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.
  2. 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).

2. Publish the application

On a machine with the SDK, from the repository root:

dotnet publish .\MailService.Api\MailService.Api.csproj -c Release -o C:\Publish\MailService.Api

Use 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.

3. Provide configuration (.env)

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.

4. Create the app pool and site in IIS Manager

  1. Open IIS ManagerApplication PoolsAdd 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).
  2. Advanced Settings (optional but common): set Identity to a dedicated account if you do not want ApplicationPoolIdentity.
  3. Under SitesAdd 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.

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.

5. HTTPS and firewall

  • 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.

6. If something fails

  • 502.5 / app won’t start: Usually a missing runtime or bad web.config. Confirm the hosting bundle version matches net10.0 and 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.config for 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.

Example request

{
  "apiKey": "A1b2C3d4E5",
  "fromEmail": "noreply@example.com",
  "toEmails": ["recipient@example.com"],
  "subject": "Test message",
  "messageType": "PlainText",
  "plainTextBody": "Hello from MailService."
}

Calling from a .NET (C#) application

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.

Calling from a PHP application

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].

About

Service exposing a single HTTP endpoint for sending e-mail messages.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages