- Python 95.2%
- Shell 4.1%
- Dockerfile 0.7%
|
All checks were successful
Docker Build / build (push) Successful in 31s
Reviewed-on: #3 |
||
|---|---|---|
| .forgejo | ||
| app | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| CHANGELOG.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| RELEASES.md | ||
| requirements.txt | ||
| VERSION | ||
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
250only when Graph accepts (202); on failure (e.g. rate limit, network) it returns451and the message is not stored. - Messages are sent via
POST /v1.0/users/{GRAPH_SEND_AS_USER}/sendMailwith 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.SendMail.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) IDAZURE_CLIENT_ID– Application (client) IDAZURE_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
Bccheader on every message relayed through this service (deduplicated with any existingBcc). Useful for archival or audit mailboxes.
- Comma- or semicolon-separated list of addresses merged into the
GRAPH_SEND_MODE(optional, default:direct)direct:POST /v1.0/users/{GRAPH_SEND_AS_USER}/sendMaildraft:POST /v1.0/users/{GRAPH_SEND_AS_USER}/messagesthenPOST .../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
25or587on the host.
- Inside the container; you can map this to
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
0to disable or increase (e.g.1,2) if you still see 429.
- Minimum seconds between starting each send. Default 0.5 s helps avoid hitting Graph's rate limit; set to
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
- Calls
-
Draft mode (
GRAPH_SEND_MODE=draft)- Calls
POST /users/{id | userPrincipalName}/messages(create draft), thenPOST .../messages/{messageId}/send - Requires:
- Microsoft Graph → Application permissions →
Mail.ReadWrite(create/delete the draft) - Microsoft Graph → Application permissions →
Mail.Send(send the message)
- Microsoft Graph → Application permissions →
- If you only grant
Mail.Send, draft creation will fail with403 ErrorAccessDeniedon the/messagesendpoint.
- Calls
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