This post is completely AI generated based on what I did in coop with the AI. OpenProject isn't my main concern, but I thought it might be good to have this out there, because I would have needed it. It was a complete intranet deployment of OpenProject in docker so this guide kinda has this as a prerequisite. Have fun!
Problem: Microsoft is killing SMTP basic authentication in March 2026. Your OpenProject email notifications will stop working.
Solution: A simple Docker container that bridges OpenProject's SMTP to Microsoft Graph API - no complex OAuth2 configurations needed.
OpenProject Admins: This Is For You
If you're running OpenProject and using Microsoft 365 for email, you've probably seen these errors:
SMTP Error: Authentication failed
535 5.7.139 Authentication unsuccessful
Or maybe you're dreading the March 2026 deadline when Microsoft kills basic authentication entirely.
Good news: There's a simple solution that takes 10 minutes to implement and just works.
What This Solution Does
Instead of trying to make OpenProject speak Microsoft's complex OAuth2, we create a simple relay:
- OpenProject sends emails via regular SMTP (no changes needed)
- Docker container receives emails and forwards them via Microsoft Graph API
- Your users get their notifications without any hassle
OpenProject → SMTP Relay Container → Microsoft 365 (via Graph API)
Why this works better:
- ✅ No OpenProject configuration changes
- ✅ No complex OAuth2 setup in OpenProject
- ✅ Works with any SMTP-based application
- ✅ Future-proof beyond 2026
Step-by-Step Setup (10 Minutes)
Step 1: Set Up Microsoft 365 (5 minutes)
- Go to Azure Portal → Azure Active Directory → App registrations
-
Create new registration:
- Name: "OpenProject Email Relay"
- Account type: "Single tenant"
- No redirect URI needed
-
Copy these values:
- Application (client) ID
- Directory (tenant) ID
-
Create client secret:
- Go to "Certificates & secrets" → "New client secret"
- Copy the secret value immediately (you can't see it again)
-
Add permissions:
- Go to "API permissions" → "Add permission"
- Choose "Microsoft Graph" → "Application permissions"
- Select "Mail.Send"
- Click "Grant admin consent"
Step 2: Deploy the Container (5 minutes)
Create a new directory for the files:
mkdir openproject-email-relay
cd openproject-email-relay
Build the container:
docker build -f Dockerfile.graph-relay -t local/postfix-graph:latest .
Add to your docker-compose.yml:
services:
# Your existing OpenProject service...
postfix-relay:
image: local/postfix-graph:latest
container_name: postfix-relay
restart: unless-stopped
ports:
- "587:587"
environment:
OAUTH_CLIENT_ID: "paste-your-client-id-here"
OAUTH_CLIENT_SECRET: "paste-your-client-secret-here"
OAUTH_TENANT_ID: "paste-your-tenant-id-here"
volumes:
- postfix-spool:/var/spool/postfix
volumes:
postfix-spool:
Update OpenProject environment (in the same docker-compose.yml):
openproject:
# ... your existing config ...
environment:
# ... your existing environment vars ...
# Email settings - change these:
OPENPROJECT_EMAIL__DELIVERY__METHOD: smtp
OPENPROJECT_SMTP__ADDRESS: postfix-relay
OPENPROJECT_SMTP__PORT: 587
OPENPROJECT_SMTP__AUTHENTICATION: none # Important!
OPENPROJECT_SMTP__ENABLE__STARTTLS__AUTO: false
Deploy:
docker-compose up -d postfix-relay
docker-compose restart openproject
Test Your Setup
Step 3: Verify it works
Check the container logs:
docker logs postfix-relay
You should see:
✓ Graph API authentication successful
Starting Postfix...
Send a test email from OpenProject:
- Go to OpenProject Admin → Email notifications
- Send a test email to yourself
- Check your inbox!
If something's wrong, check:
# Container running?
docker ps | grep postfix-relay
# Authentication working?
docker logs postfix-relay | grep "authentication successful"
# OpenProject connecting?
docker logs postfix-relay | grep "connect from"
Troubleshooting Common Issues
"Authentication unsuccessful" in Azure:
- Make sure you clicked "Grant admin consent" for the Mail.Send permission
- Verify the client secret hasn't expired
"Connection refused" from OpenProject:
- Check the container is running:
docker ps
- Verify port 587 is accessible
- Make sure OpenProject and relay are on the same Docker network
Emails not being delivered:
- Check Microsoft 365 message trace (Exchange admin center)
- Verify the sender email exists in your Microsoft 365 tenant
- Look for bounced email notifications
Complete File Listing
If you prefer to create the files manually, here's everything you need:
Dockerfile.graph-relay:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
postfix python3 python3-pip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --break-system-packages requests msal
COPY postfix/main-graph.cf /etc/postfix/main.cf
COPY postfix/master-graph.cf /etc/postfix/master.cf
COPY scripts/graph-send-mail.py /usr/local/bin/graph-send-mail.py
COPY entrypoint-graph.sh /entrypoint.sh
RUN chmod +x /usr/local/bin/graph-send-mail.py /entrypoint.sh
EXPOSE 587
ENTRYPOINT ["/entrypoint.sh"]
scripts/graph-send-mail.py:
#!/usr/bin/env python3
import sys, os, json, email, requests, msal
from email.header import decode_header
class GraphMailSender:
def __init__(self):
self.client_id = os.environ.get('OAUTH_CLIENT_ID')
self.client_secret = os.environ.get('OAUTH_CLIENT_SECRET')
self.tenant_id = os.environ.get('OAUTH_TENANT_ID')
# Try to load from environment file if not in environment
if not all([self.client_id, self.client_secret, self.tenant_id]):
try:
with open('/etc/postfix/graph-env', 'r') as f:
for line in f:
key, value = line.strip().split('=', 1)
if key == 'OAUTH_CLIENT_ID': self.client_id = value
elif key == 'OAUTH_CLIENT_SECRET': self.client_secret = value
elif key == 'OAUTH_TENANT_ID': self.tenant_id = value
except FileNotFoundError: pass
if not all([self.client_id, self.client_secret, self.tenant_id]):
raise ValueError("Missing OAuth2 environment variables")
self.authority = f"https://login.microsoftonline.com/{self.tenant_id}"
self.scopes = ["https://graph.microsoft.com/.default"]
def get_access_token(self):
app = msal.ConfidentialClientApplication(
self.client_id, authority=self.authority, client_credential=self.client_secret)
result = app.acquire_token_for_client(scopes=self.scopes)
if "access_token" not in result:
raise Exception(f"Failed to acquire token: {result.get('error_description')}")
return result["access_token"]
def send_email(self, message):
msg = email.message_from_string(message)
from_addr = msg.get('From', '')
to_addrs = msg.get('To', '').split(',')
subject = msg.get('Subject', '')
if subject:
decoded_subject = decode_header(subject)[0]
if decoded_subject[1]:
subject = decoded_subject[0].decode(decoded_subject[1])
else: subject = str(decoded_subject[0])
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
else: body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
graph_message = {
"message": {
"subject": subject,
"body": {"contentType": "Text", "content": body},
"toRecipients": [{"emailAddress": {"address": addr.strip()}} for addr in to_addrs if addr.strip()]
}
}
access_token = self.get_access_token()
headers = {'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'}
sender_email = from_addr
if '<' in from_addr: sender_email = from_addr.split('<')[1].split('>')[0]
url = f"https://graph.microsoft.com/v1.0/users/{sender_email}/sendMail"
response = requests.post(url, headers=headers, json=graph_message)
if response.status_code == 202:
print(f"Email sent successfully via Graph API")
return True
else:
print(f"Failed to send email: {response.status_code} - {response.text}")
return False
if __name__ == "__main__":
try:
message = sys.stdin.read()
sender = GraphMailSender()
success = sender.send_email(message)
sys.exit(0 if success else 1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
postfix/main-graph.cf:
myhostname = postfix-relay.local
mydomain = local
myorigin = $mydomain
mydestination =
mynetworks = 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, 127.0.0.0/8
inet_interfaces = all
inet_protocols = ipv4
relayhost =
transport_maps = hash:/etc/postfix/transport
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
maillog_file = /dev/stdout
postfix/master-graph.cf:
smtp inet n - n - - smtpd
submission inet n - n - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=none
-o smtpd_client_restrictions=permit_mynetworks,reject
-o milter_macro_daemon_name=ORIGINATING
pickup unix n - n 60 1 pickup
cleanup unix n - n - 0 cleanup
qmgr unix n - n 300 1 qmgr
tlsmgr unix - - n 1000? 1 tlsmgr
rewrite unix - - n - - trivial-rewrite
bounce unix - - n - 0 bounce
defer unix - - n - 0 bounce
trace unix - - n - 0 bounce
verify unix - - n - 1 verify
flush unix n - n 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
showq unix n - n - - showq
error unix - - n - - error
retry unix - - n - - error
discard unix - - n - - discard
local unix - n n - - local
virtual unix - - n - - virtual
lmtp unix - - n - - lmtp
anvil unix - - n - 1 anvil
scache unix - - n - 1 scache
postlog unix-dgram n - n - 1 postlogd
graph unix - n n - - pipe
flags=F user=nobody argv=/usr/local/bin/graph-send-mail.py
entrypoint-graph.sh:
#!/bin/bash
echo "Starting Postfix Graph API Relay..."
python3 -c "
import os, msal
try:
client_id = os.environ.get('OAUTH_CLIENT_ID')
client_secret = os.environ.get('OAUTH_CLIENT_SECRET')
tenant_id = os.environ.get('OAUTH_TENANT_ID')
app = msal.ConfidentialClientApplication(
client_id, authority=f'https://login.microsoftonline.com/{tenant_id}',
client_credential=client_secret)
result = app.acquire_token_for_client(scopes=['https://graph.microsoft.com/.default'])
if 'access_token' in result:
print('✓ Graph API authentication successful')
else:
print('✗ Graph API authentication failed:', result.get('error_description'))
exit(1)
except Exception as e:
print('✗ Graph API test failed:', e)
exit(1)
"
export OAUTH_CLIENT_ID OAUTH_CLIENT_SECRET OAUTH_TENANT_ID
echo "OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}" > /etc/postfix/graph-env
echo "OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}" >> /etc/postfix/graph-env
echo "OAUTH_TENANT_ID=${OAUTH_TENANT_ID}" >> /etc/postfix/graph-env
chmod 644 /etc/postfix/graph-env
echo "* graph:" > /etc/postfix/transport
postmap /etc/postfix/transport
chown postfix:postfix /etc/postfix/transport*
echo "Starting Postfix..."
exec /usr/sbin/postfix start-fg
Create these files in the appropriate directories:
mkdir -p scripts postfix
# Then copy the contents above into each file
Why This Solution Is Better
Instead of fighting OAuth2 SMTP complexity:
- ❌ Compiling SASL plugins
- ❌ Managing token refresh mechanisms
- ❌ Debugging authentication failures
- ❌ Reading Microsoft's confusing SMTP OAuth2 docs
You get a simple, reliable solution:
- ✅ 10-minute setup
- ✅ Uses Microsoft's preferred Graph API
- ✅ Future-proof beyond 2026
- ✅ Easy to troubleshoot and maintain
Everything You Need Is Above
This blog post contains:
- 🐳 Complete Docker setup and all source files
- 📋 Step-by-step instructions
- 🧪 Testing and troubleshooting guidance
- 🛠️ Common issue solutions
- 📚 Complete configuration examples
Top comments (0)