Outils pour utilisateurs

Outils du site


systeme:smtp:relais

Configurer un relais SMTP avec Postfix et Resend (RELAIS SMTP SANS IP PUBLIQUE)

Principe

Application (exemple Documenso) ⇒ Postfix (port 25) ⇒ Script Python (Communication via LPIPE) ⇒ Microsoft Graph API

Préparation Azure AD (OAuth2)

  • création d'une Inscription d'applications Entra ID :
    • Portail Azure ⇒ Entra ID
    • Inscription d'applications ⇒ Nouvelle inscription
    • Nom : smtp2graph-relay
    • Locataire unique seulement
    • S'incrire
  • Récupérer :
    • Tenant ID
    • Client ID
    • Ajouter un secret Client (dans Certificates & Secrets)
  • Ajouter la permission Microsoft Graph :
    • Autorisations d'application (et non Autorisations déléguées) :Mail.Send
  • Grant admin consent.
  • Adresse email 0365 utilisée pour l’envoi afin que l'app puisse avoir le droit d’envoyer au nom de ce compte.

Installation des prérequis

Types: deb
URIs: http://deb.debian.org/debian-security
Suites: trixie-security
Components: contrib main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg

Types: deb
URIs: http://deb.debian.org/debian
Suites: trixie trixie-updates
Components: contrib main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
  • . installer les paquets pour le script
apt install mailutils libsasl2-modules
apt install python3-pip

Script Python

  • créer le fichier /usr/local/bin/graph_sendmail.py
#!/usr/bin/env python3
import sys
import email
import requests
import json
import base64
import traceback
from email.utils import getaddresses
 
 
# ---------------------------------------------------
# CONFIG MICROSOFT GRAPH
# ---------------------------------------------------
TENANT = "TON_TENANT_ID"
CLIENT_ID = "TON_CLIENT_ID"
CLIENT_SECRET = "TON_CLIENT_SECRET"
FROM_ADDR = "ton.adresse@tondomaine.com"
 
DEBUG_LOG = "/tmp/graph_debug.log"
 
def debug(msg):
    with open(DEBUG_LOG, "a", encoding="utf-8") as f:
        f.write(msg + "\n")
 
 
def clean_address_list(field_value):
    """
    Extrait correctement toutes les adresses
    même si Documenso génère des listes complexes.
    """
    if not field_value:
        return []
 
    parsed = getaddresses([field_value])
    cleaned = []
 
    for name, addr in parsed:
        addr = addr.replace("<", "").replace(">", "").strip()
        if addr:
            cleaned.append(addr)
 
    return cleaned
 
 
def flatten_body(part, text_body, html_body, attachments, inline_images):
    """
    Parcourt récursivement l'arbre MIME.
    """
    ctype = part.get_content_type()
    disp = str(part.get("Content-Disposition", "")).lower()
 
    # 1. Si multipart → parcourir les sous-parties
    if part.is_multipart():
        for subpart in part.get_payload():
            flatten_body(subpart, text_body, html_body, attachments, inline_images)
        return
 
    # 2. Corps texte
    if ctype == "text/plain" and "attachment" not in disp:
        payload = part.get_payload(decode=True)
        if payload:
            text_body.append(payload.decode("utf-8", errors="ignore"))
        return
 
    # 3. Corps HTML
    if ctype == "text/html" and "attachment" not in disp:
        payload = part.get_payload(decode=True)
        if payload:
            html_body.append(payload.decode("utf-8", errors="ignore"))
        return
 
    # 4. Images inline (multipart/related)
    if "inline" in disp and ctype.startswith("image/"):
        cid = part.get("Content-ID", "").strip("<>")
        payload = part.get_payload(decode=True)
        if payload and cid:
            inline_images.append({
                "@odata.type": "#microsoft.graph.fileAttachment",
                "name": cid,
                "contentId": cid,
                "isInline": True,
                "contentBytes": base64.b64encode(payload).decode()
            })
        return
 
    # 5. Attachments
    if "attachment" in disp or part.get_filename():
        filename = part.get_filename()
        payload = part.get_payload(decode=True)
        if filename and payload:
            attachments.append({
                "@odata.type": "#microsoft.graph.fileAttachment",
                "name": filename,
                "contentBytes": base64.b64encode(payload).decode()
            })
        return
 
 
# ---------------------------------------------------
# MAIN
# ---------------------------------------------------
try:
    raw = sys.stdin.read()
    msg = email.message_from_string(raw)
 
    debug("=== Nouveau mail reçu ===")
 
    subject = msg.get("Subject", "(no subject)")
    debug("Sujet: " + subject)
 
    # Extraction des adresses MIME (Documenso friendly)
    to_list = clean_address_list(msg.get("To"))
    cc_list = clean_address_list(msg.get("Cc"))
    bcc_list = clean_address_list(msg.get("Bcc"))
    reply_to_list = clean_address_list(msg.get("Reply-To"))
 
    debug("To: " + str(to_list))
    debug("Cc: " + str(cc_list))
    debug("Bcc: " + str(bcc_list))
    debug("Reply-To: " + str(reply_to_list))
 
    # Corps du message
    text_body = []
    html_body = []
    attachments = []
    inline_images = []
 
    flatten_body(msg, text_body, html_body, attachments, inline_images)
 
    # Déterminer le corps principal HTML
    final_html = ""
 
    if html_body:
        final_html = "\n".join(html_body)
    elif text_body:
        final_html = "<pre>" + "\n".join(text_body) + "</pre>"
    else:
        final_html = "<p>(vide)</p>"
 
    debug("Corps HTML (200 chars): " + final_html[:200])
    debug("Attachments: " + str(len(attachments)))
    debug("Inline images: " + str(len(inline_images)))
 
    # ---------------------------------------------------
    # TOKEN OAUTH2
    # ---------------------------------------------------
    debug("Before OAuth token request")
 
    token_res = requests.post(
        f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token",
        data={
            "client_id": CLIENT_ID,
            "scope": "https://graph.microsoft.com/.default",
            "client_secret": CLIENT_SECRET,
            "grant_type": "client_credentials"
        }
    )
 
    debug("Token response: " + token_res.text)
 
    token_json = token_res.json()
    if "access_token" not in token_json:
        raise Exception("OAuth2 failed: " + token_res.text)
 
    token = token_json["access_token"]
 
    # ---------------------------------------------------
    # CONSTRUCTION JSON GRAPH
    # ---------------------------------------------------
    def addr_obj(addr):
        return {"emailAddress": {"address": addr}}
 
    message = {
        "subject": subject,
        "body": {
            "contentType": "HTML",
            "content": final_html
        },
        "toRecipients": [addr_obj(a) for a in to_list],
        "ccRecipients": [addr_obj(a) for a in cc_list],
        "bccRecipients": [addr_obj(a) for a in bcc_list],
        "attachments": attachments + inline_images
    }
 
    if reply_to_list:
        message["replyTo"] = [addr_obj(a) for a in reply_to_list]
 
    mail_json = {
        "message": message,
        "saveToSentItems": True
    }
 
    debug("Mail JSON ready")
 
    # ---------------------------------------------------
    # ENVOI VIA GRAPH API
    # ---------------------------------------------------
    graph_res = requests.post(
        f"https://graph.microsoft.com/v1.0/users/{FROM_ADDR}/sendMail",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        },
        data=json.dumps(mail_json)
    )
 
    debug("Graph sendMail response: " + graph_res.text)
    debug("Status: " + str(graph_res.status_code))
 
except Exception as e:
    debug("SCRIPT ERROR: " + str(e))
    debug(traceback.format_exc())
 
  • Rendre le script exécutable :
chmod 755 /usr/local/bin/graph_sendmail.py
chown root:root /usr/local/bin/graph_sendmail.py

Configurer Postfix

utiliser LPIPE pour appeler le script

  • Créer le fichier /etc/postfix/transport :
*     graph:
  • compiler
postmap /etc/postfix/transport
  • vérifier
postmap -s /etc/postfix/transport


  • vérifier que le transport existe
#postconf -M
...
graph unix - n n - - pipe
  • Configurer Postfix en modifiant le fichier /etc/postfix/main.cf pour ajouter :
#myhostname = postfix-relay.lan
#mydomain = lan
#mydestination =
#relayhost =

# Postfix doit écouter sur toutes les interfaces
inet_interfaces = all

# Autoriser l’IP des serveurs client 
mynetworks = 127.0.0.0/8 9.9.9.0/24

# Postfix doit accepter les mails :
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination


transport_maps = hash:/etc/postfix/transport

lpipe_destination_recipient_limit = 1
  • Config /etc/postfix/master.cf pour appeler le script Python en ajoutant à la fin du fichier :
graph   unix  -   n   n   -   -   pipe
  flags=Fq.
  user=nobody
  argv=/scripts/graph_sendmail.py
  • relancer Postfix :
systemctl restart postfix

Tester

  • envoyer un message
echo "hello" | mail -s "test mime" -A document.pdf charles@gmail.xxx
  • vérifier dans les logs générés par le script qu'il n'y a pas d'erreur (le statut doit être 202) :
cat /tmp/graph_debug.log
  • vérifier dans les logs de Potsfix qu'il n'y a pas d'erreur :
journalctl -u postfix -n 50
systeme/smtp/relais.txt · Dernière modification : 2026/03/28 19:23 de techer.charles_educ-valadon-limoges.fr