No description
  • Python 95.2%
  • Shell 4.1%
  • Dockerfile 0.7%
Find a file
2026-05-21 06:54:36 +00:00
.forgejo Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
app Add optional GRAPH_SEND_BCC environment variable to support Bcc recipients in email messages. Update README and configuration files accordingly. Implement logic to merge Bcc addresses into outgoing messages while ensuring deduplication. 2026-05-04 20:33:00 +02:00
scripts Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
.env.example Add optional GRAPH_SEND_BCC environment variable to support Bcc recipients in email messages. Update README and configuration files accordingly. Implement logic to merge Bcc addresses into outgoing messages while ensuring deduplication. 2026-05-04 20:33:00 +02:00
.gitignore Implement spool management for message handling, introducing PENDING, QUEUE, and FAILED directories for improved reliability. Add a background worker for retrying failed messages and update the SMTP server to handle message states accordingly. Enhance logging and environment variable configurations to support the new functionality. 2026-02-06 14:40:27 +01:00
.gitlab-ci.yml Refactor GitLab CI configuration to simplify Docker image tagging logic. Replace array usage with space-separated tags and update condition checks for branch and version handling. 2026-05-21 08:53:03 +02:00
CHANGELOG.md Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
docker-compose.yml Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
Dockerfile Refactor project structure for improved organization and clarity 2026-02-03 11:21:28 +01:00
README.md Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
RELEASES.md Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00
requirements.txt Refactor project structure for improved organization and clarity 2026-02-03 11:21:28 +01:00
VERSION Add release hardening: VERSION, changelog, docs, and tag helper. 2026-05-21 08:27:41 +02:00

mx2smtp

Small SMTP-to-Microsoft-Graph relay designed to run in a Docker container.

It accepts email via SMTP (you can map the container port to 25 or 587) and forwards the raw message to Microsoft Graph sendMail using a registered Azure AD application.

How it works

  • SMTP in: A lightweight Python SMTP server (aiosmtpd) listens on a configurable port inside the container (default: 25).
  • Graph out: For each received message, the service sends it immediately to Graph (no storage or queue). It returns 250 only when Graph accepts (202); on failure (e.g. rate limit, network) it returns 451 and the message is not stored.
  • Messages are sent via POST /v1.0/users/{GRAPH_SEND_AS_USER}/sendMail with the raw RFC 5322 message base64-encoded (MIME, Content-Type: text/plain).

Microsoft Graph stores the message in the sender's Sent Items folder and delivers it according to Exchange Online rules.


Prerequisites

  • An Azure AD application with application permissions:
    • Mail.Send
    • Mail.ReadWrite (only required if you use draft mode; see below)
    • (and admin consent granted)
  • A mailbox (user or shared mailbox) that the app is allowed to send as.

You will need the following values from your Azure AD app registration:

  • AZURE_TENANT_ID Directory (tenant) ID
  • AZURE_CLIENT_ID Application (client) ID
  • AZURE_CLIENT_SECRET Client secret

And the mailbox identifier:

  • GRAPH_SEND_AS_USER user ID or UPN, e.g. noreply@contoso.com

Configuration

The service is configured entirely via environment variables:

  • AZURE_TENANT_ID (required)
  • AZURE_CLIENT_ID (required)
  • AZURE_CLIENT_SECRET (required)
  • GRAPH_SEND_AS_USER (required)
    • User ID or userPrincipalName the Graph call will send as.
  • GRAPH_SEND_BCC (optional)
    • Comma- or semicolon-separated list of addresses merged into the Bcc header on every message relayed through this service (deduplicated with any existing Bcc). Useful for archival or audit mailboxes.
  • GRAPH_SEND_MODE (optional, default: direct)
    • direct: POST /v1.0/users/{GRAPH_SEND_AS_USER}/sendMail
    • draft: POST /v1.0/users/{GRAPH_SEND_AS_USER}/messages then POST .../messages/{id}/send
    • Draft mode is useful if you want Graph to create a message entity first (and optionally allow future extensions like adding attachments via Graph APIs), but it requires additional permissions (below).
  • SMTP_HOST (optional, default: 0.0.0.0)
  • SMTP_PORT (optional, default: 25)
    • Inside the container; you can map this to 25 or 587 on the host.
  • GRAPH_MAX_CONCURRENT_SENDS (optional, default: 2)
    • Maximum number of messages sent to Graph at the same time. Lower values reduce the chance of hitting Graph's IncomingBytes throttle (429). Graph's sendMail API is one message per request.
  • GRAPH_MIN_SEND_INTERVAL_SEC (optional, default: 0.5)
    • Minimum seconds between starting each send. Default 0.5 s helps avoid hitting Graph's rate limit; set to 0 to disable or increase (e.g. 1, 2) if you still see 429.
  • LOG_LEVEL (optional, default: INFO)

Copy .env.example to .env and fill in your values. .env is loaded by docker-compose and is ignored by git.


Send modes and required Graph permissions

This service uses application (client credentials) auth (.default scope), so all access is governed by the application permissions granted to your app registration (and tenant/admin consent).

  • Direct mode (GRAPH_SEND_MODE=direct)

    • Calls POST /users/{id | userPrincipalName}/sendMail
    • Requires: Microsoft Graph → Application permissions → Mail.Send
  • Draft mode (GRAPH_SEND_MODE=draft)

    • Calls POST /users/{id | userPrincipalName}/messages (create draft), then POST .../messages/{messageId}/send
    • Requires:
      • Microsoft Graph → Application permissions → Mail.ReadWrite (create/delete the draft)
      • Microsoft Graph → Application permissions → Mail.Send (send the message)
    • If you only grant Mail.Send, draft creation will fail with 403 ErrorAccessDenied on the /messages endpoint.

Exchange Online Application Access Policies (optional but common)

Some tenants restrict app-only mailbox access using an Application Access Policy. When enabled, Graph can return 403 ErrorAccessDenied unless the target mailbox (your GRAPH_SEND_AS_USER) is explicitly included/allowed by the policy.

If you see ErrorAccessDenied even after granting Mail.Send/Mail.ReadWrite and admin consent, verify whether an application access policy is in place for this app and that it includes the mailbox you are sending as.


Container image

CI publishes images to:

forge.seventythree.at/public/mx2graph:<tag>
Tag Use for
1.0.0 Production — exact version pin
1.0 Auto patch updates within 1.0.x
1 Auto minor updates within 1.x
latest Newest stable release (convenient, not immutable)
main Bleeding-edge builds from main

Production: prefer a fixed tag (1.0.0) or a digest (image: repo@sha256:…) instead of latest alone. Floating tags (1.0, 1, latest) can change when a new release is published.

See RELEASES.md for the full release workflow (including how to tag v1.1.0). Current version: see VERSION.


Docker Compose

Using the provided docker-compose.yml:

cp .env.example .env
# Edit .env with your Azure and mailbox values

docker compose up -d

By default the service is exposed on host port 25. To use port 587 instead, change the ports mapping in docker-compose.yml to "587:25".


Build the image

From the project root:

docker build -t mx2smtp .

Run the container

Example: listen on host port 25

docker run --rm \
  -p 25:25 \
  -e AZURE_TENANT_ID="your-tenant-id" \
  -e AZURE_CLIENT_ID="your-client-id" \
  -e AZURE_CLIENT_SECRET="your-client-secret" \
  -e GRAPH_SEND_AS_USER="noreply@contoso.com" \
  -e SMTP_PORT="25" \
  --name mx2smtp \
  mx2smtp

Example: listen on host port 587

docker run --rm \
  -p 587:25 \
  -e AZURE_TENANT_ID="your-tenant-id" \
  -e AZURE_CLIENT_ID="your-client-id" \
  -e AZURE_CLIENT_SECRET="your-client-secret" \
  -e GRAPH_SEND_AS_USER="noreply@contoso.com" \
  -e SMTP_PORT="25" \
  --name mx2smtp \
  mx2smtp

In this example the container still listens on 25 internally, but it is exposed as 587 on the host.

If you prefer, you can also set SMTP_PORT=587 and map 587:587.


Test script

A small script sends a test email to the relay (uses only the Python standard library). With the container running on host port 25:

python scripts/send_test_email.py

With the relay on port 587:

SMTP_TEST_PORT=587 python scripts/send_test_email.py

Optional: --host, --port, --from, --to, --subject, --body, --count, --delay, --threads; or set SMTP_TEST_HOST, SMTP_TEST_PORT, SMTP_TEST_FROM, SMTP_TEST_TO, SMTP_TEST_SUBJECT, SMTP_TEST_BODY, SMTP_TEST_COUNT, SMTP_TEST_DELAY, SMTP_TEST_THREADS.


Notes

  • Messages are relayed as-is via MIME; any headers and attachments are preserved by Microsoft Graph.

ToDo

  • ADD TLS Encryption with STARTTLS and Trusted Certificate