DEV Community

Edgar Dyck
Edgar Dyck

Posted on

How to Fix OpenProject Email with Microsoft 365 (The Easy Way)

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. OpenProject sends emails via regular SMTP (no changes needed)
  2. Docker container receives emails and forwards them via Microsoft Graph API
  3. Your users get their notifications without any hassle
OpenProject → SMTP Relay Container → Microsoft 365 (via Graph API)
Enter fullscreen mode Exit fullscreen mode

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)

  1. Go to Azure Portal → Azure Active Directory → App registrations
  2. Create new registration:
    • Name: "OpenProject Email Relay"
    • Account type: "Single tenant"
    • No redirect URI needed
  3. Copy these values:
    • Application (client) ID
    • Directory (tenant) ID
  4. Create client secret:
    • Go to "Certificates & secrets" → "New client secret"
    • Copy the secret value immediately (you can't see it again)
  5. 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
Enter fullscreen mode Exit fullscreen mode

Build the container:

docker build -f Dockerfile.graph-relay -t local/postfix-graph:latest .
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Deploy:

docker-compose up -d postfix-relay
docker-compose restart openproject
Enter fullscreen mode Exit fullscreen mode

Test Your Setup

Step 3: Verify it works

Check the container logs:

docker logs postfix-relay
Enter fullscreen mode Exit fullscreen mode

You should see:

✓ Graph API authentication successful
Starting Postfix...
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create these files in the appropriate directories:

mkdir -p scripts postfix
# Then copy the contents above into each file
Enter fullscreen mode Exit fullscreen mode

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)