Se rendre au contenu

Traçabilité d'une campagne en Odoo 19 — mailing.trace, ouvertures, clics et rebonds

Série Tech-Email · Article 12/14 · Parcours Infrastructure
6 juin 2026 par
Traçabilité d'une campagne en Odoo 19 — mailing.trace, ouvertures, clics et rebonds
B.Mustapha
| Aucun commentaire pour l'instant

Une campagne est partie : des milliers d'emails ont quitté le serveur. Commence alors une seconde vie, invisible côté code mais décisive côté résultat — la collecte des signaux. Qui a ouvert ? Qui a cliqué ? Qui a répondu ? Quelles adresses ont rebondi ? En Odoo 19, toutes ces réponses convergent vers un seul modèle, déjà rencontré à l'envoi : mailing.trace. Cet article montre comment chaque signal y est capté, et comment les taux d'une campagne ne sont qu'une lecture agrégée de ces lignes.

L'enjeu est double. Comprendre Odoo écrit l'ouverture, le clic, le rebond, c'est savoir déboguer une statistique qui paraît fausse. Et comprendre comment ces signaux se transforment en taux, c'est savoir lire une campagne sans se méprendre — un taux d'ouverture n'a pas la fiabilité qu'on lui prête. La trace n'est pas un tableau de bord : c'est la matière première, brute et honnête, à partir de laquelle tout le reste se calcule.

Prérequis lecteur. Le module mass_mailing installé, le mode debug, et la lecture de l'article sur la diffusion de masse. On suppose acquis qu'une campagne crée une mailing.trace par destinataire et réécrit ses liens en URLs courtes au moment de l'envoi.

1. La trace, source unique de vérité

Le point le plus important d'abord : Odoo ne tient aucun compteur de campagne. Il n'existe pas de champ « nombre d'ouvertures » mis à jour à chaque événement. Tout — absolument tout — s'écrit sur la mailing.trace du destinataire concerné, et les statistiques affichées sont recalculées à la volée par agrégation de ces lignes.

Cette conception a une vertu : il n'y a jamais de désynchronisation entre « le détail » et « le total ». Le taux d'ouverture d'une campagne est, par définition, le décompte des traces ouvertes. Pour déboguer une statistique suspecte, on ne cherche pas un compteur corrompu : on lit les traces. Chaque signal capté (ouverture, clic, réponse, rebond, échec) se matérialise par l'appel d'une méthode set_* qui fait avancer le statut de la ligne.

2. Anatomie d'une trace

Pour rattacher un événement entrant à la bonne ligne, la trace porte plusieurs clés d'identification, et pour chaque type de signal un horodatage dédié :

ChampRôle
mail_mail_id / mail_mail_id_intLien vers le mail.mail ; la version _int survit à la purge du mail (utile pour le pixel d'ouverture)
message_idLe Message-ID (RFC) de l'email — clé de matching pour les réponses et les rebonds
sent_datetimeHorodatage de livraison
open_datetime / reply_datetimeHorodatages d'ouverture et de réponse
links_click_datetimeHorodatage du dernier clic (un clic = ce champ est renseigné)
trace_statusLe statut : outgoing/sent/open/reply/bounce/error/cancel
failure_type / failure_reasonType et détail d'un échec ou d'un rebond

Les méthodes set_* sont le seul moyen d'écrire ces champs. Leur logique encode une subtilité essentielle — la hiérarchie des statuts :

# mass_mailing/models/mailing_trace.py — les transitions
def set_sent(self, domain=None):
    traces.write({'trace_status': 'sent', 'sent_datetime': fields.Datetime.now(),
                  'failure_type': False})

def set_opened(self, domain=None):
    # reply implique open, click implique open : ne JAMAIS écraser un statut plus avancé
    traces.filtered(lambda t: t.trace_status not in ('open', 'reply')).write(
        {'trace_status': 'open', 'open_datetime': fields.Datetime.now()})

def set_clicked(self, domain=None):
    traces.write({'links_click_datetime': fields.Datetime.now()})  # le STATUT ne change pas

def set_replied(self, domain=None):
    traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})

def set_bounced(self, domain=None, bounce_message=False):
    traces.write({'trace_status': 'bounce', 'failure_type': 'mail_bounce',
                  'failure_reason': bounce_message})

Deux détails à retenir dès maintenant. set_opened n'écrase pas un statut open ou reply déjà posé — une réponse vaut mieux qu'une simple ouverture, on ne régresse pas. Et set_clicked ne touche pas au statut : « avoir cliqué » n'est pas un état, c'est la présence d'un horodatage links_click_datetime. Ces deux choix expliqueront, en section 7, la façon dont les taux sont calculés.

3. L'ouverture : le pixel espion

Comment savoir qu'un email a été ouvert, alors que la lecture se fait dans un client mail distant, hors d'Odoo ? Par une vieille astuce : une image invisible. Le corps HTML envoyé contient un <img> de 1×1 pixel pointant vers une route d'Odoo. Quand le client mail affiche le message, il charge cette image — et c'est cette requête qui signale l'ouverture.

# mass_mailing/controllers/main.py — la route du pixel
@http.route('/mail/track/<int:mail_id>/<string:token>/blank.gif', type='http', auth='public')
def track_mail_open(self, mail_id, token, **post):
    expected_token = request.env['mail.mail']._generate_mail_recipient_token(mail_id)
    if not consteq(token, expected_token):       # comparaison à temps constant
        raise Unauthorized()
    request.env['mailing.trace'].sudo().set_opened(domain=[('mail_mail_id_int', 'in', [mail_id])])
    response = Response()
    response.mimetype = 'image/gif'
    # un GIF transparent de 1×1 pixel, encodé en dur
    response.data = base64.b64decode(b'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==')
    return response

La route est publique (le destinataire n'est pas connecté à Odoo), mais protégée par un token propre au mail, vérifié en temps constant (consteq) pour empêcher de forger une ouverture. Elle marque la trace correspondante open puis renvoie le pixel transparent.

La limite à connaître. Le pixel ne se déclenche que si le client mail charge les images. Or beaucoup de clients les bloquent par défaut, et certains (relais de confidentialité) les préchargent tous, gonflant artificiellement le taux. Le taux d'ouverture est donc une estimation basse et bruitée, jamais une mesure exacte. C'est le signal le moins fiable de la campagne — à interpréter avec prudence.

4. Le clic : la redirection tracée

Le clic, lui, est un signal solide : on ne clique pas un lien par accident de chargement. Pour le capter, Odoo ne laisse aucun lien « nu » dans l'email. À l'envoi, la campagne réécrit chaque URL du corps en une URL courte de la forme /r/<code>/m/<trace_id> — un lien qui passe par Odoo avant de rebondir vers la vraie destination.

# mass_mailing/controllers/main.py — la redirection de clic
@http.route('/r/<string:code>/m/<int:mailing_trace_id>', type='http', auth="public")
def full_url_redirect(self, code, mailing_trace_id, **post):
    request.env['link.tracker.click'].sudo().add_click(
        code, ip=request.httprequest.remote_addr,
        country_code=request.geoip.country_code, mailing_trace_id=mailing_trace_id)
    redirect_url = request.env['link.tracker'].get_url_from_code(code)
    return request.redirect(redirect_url, code=301, local=False)   # transparent pour le lecteur

Au clic, Odoo enregistre un link.tracker.click (avec l'IP et le pays) puis redirige en 301 vers l'URL réelle — le lecteur ne voit rien d'autre que la page attendue. Et côté trace, l'enregistrement du clic déclenche deux mises à jour :

# mass_mailing/models/link_tracker.py — add_click
def add_click(self, code, **route_values):
    click = super().add_click(code, **route_values)
    if click and click.mailing_trace_id:
        click.mailing_trace_id.set_opened()    # un clic implique une ouverture
        click.mailing_trace_id.set_clicked()   # et marque l'horodatage de clic
    return click

Un clic implique une ouverture : set_opened est appelé en plus de set_clicked. C'est même un signal d'ouverture plus fiable que le pixel — cliquer prouve que le message a été lu, là où le pixel peut être bloqué ou préchargé. Si une trace a un links_click_datetime mais que le pixel n'a jamais répondu, l'ouverture est tout de même comptée.

5. La réponse : le gateway entrant

Quand un destinataire répond à l'email, sa réponse arrive dans la passerelle de messagerie entrante d'Odoo. Pour relier cette réponse à la campagne d'origine, Odoo exploite les en-têtes de threading du courrier — References et In-Reply-To — qui contiennent les Message-ID des messages précédents.

# mass_mailing/models/mail_thread.py — détection de réponse
def _message_route_process(self, message, message_dict, routes):
    if routes:
        thread_references = message_dict['references'] or message_dict['in_reply_to']
        msg_references = tools.mail.mail_header_msgid_re.findall(thread_references)
        if msg_references:
            # matche les Message-ID contre mailing.trace.message_id
            self.env['mailing.trace'].set_opened(domain=[('message_id', 'in', msg_references)])
            self.env['mailing.trace'].set_replied(domain=[('message_id', 'in', msg_references)])
    return super()._message_route_process(message, message_dict, routes)

Les Message-ID extraits sont comparés au champ message_id des traces : un match marque la ligne open et reply (répondre implique avoir ouvert). La réponse est le signal d'engagement le plus fort — et le plus fiable, car il transite par un vrai email entrant, non par un chargement d'image.

6. Le rebond et l'auto-blacklist

Quand un serveur distant renvoie un email (adresse inexistante, boîte pleine, rejet), le message de rebond revient dans la passerelle entrante. Odoo le détecte, marque la trace bounce avec le motif, puis applique une protection cruciale pour la réputation du domaine d'envoi.

# mass_mailing/models/mail_thread.py — rebond + auto-blacklist
BLACKLIST_MAX_BOUNCED_LIMIT = 5

def _routing_handle_bounce(self, email_message, message_dict):
    super()._routing_handle_bounce(email_message, message_dict)
    bounced_email = message_dict['bounced_email']
    bounced_msg_ids = message_dict['bounced_msg_ids']
    if bounced_msg_ids:
        self.env['mailing.trace'].set_bounced(
            domain=[('message_id', 'in', bounced_msg_ids)],
            bounce_message=tools.html2plaintext(message_dict.get('body') or ''))
    bounced_partner = message_dict['bounced_partner']
    if bounced_email:
        three_months_ago = ... # now() - 13 semaines
        stats = self.env['mailing.trace'].search([
            ('trace_status', '=', 'bounce'),
            ('write_date', '>', three_months_ago),
            ('email', '=ilike', bounced_email)]).mapped('write_date')
        # Deux critères cumulatifs avant blacklist :
        if len(stats) >= BLACKLIST_MAX_BOUNCED_LIMIT and (
                not bounced_partner
                or any(p.message_bounce >= BLACKLIST_MAX_BOUNCED_LIMIT for p in bounced_partner)):
            # … et des rebonds espacés d'au moins 1 semaine (pas une panne ponctuelle)
            if max(stats) > min(stats) + timedelta(weeks=1):
                self.env['mail.blacklist'].sudo()._add(bounced_email, message=...)

La règle est défensive et repose sur deux critères cumulatifs. D'abord, l'adresse doit avoir rebondi au moins 5 fois sur 13 semaines ; et si un partenaire Odoo est associé à cette adresse, son propre compteur message_bounce doit lui aussi avoir atteint 5 (sans partenaire lié, ce second contrôle est ignoré). Ensuite, ces rebonds doivent être espacés d'au moins une semaine — un garde-fou pour ne pas blacklister sur une panne SMTP temporaire qui ferait rebondir cinq messages d'affilée. Les deux conditions réunies, l'adresse est ajoutée à mail.blacklist. Dès lors, plus aucune campagne ne la contactera — c'est exactement la liste noire que respecte l'option use_exclusion_list vue dans l'article précédent. Continuer à marteler des adresses mortes dégrade la réputation de l'expéditeur ; Odoo s'en prémunit automatiquement.

7. Les statistiques : une agrégation de statuts

Toutes les statistiques d'une campagne se calculent dans _compute_statistics, par un simple regroupement des traces selon leur statut. Aucun compteur, donc : un _read_group, et des dérivations.

# mass_mailing/models/mailing.py — _compute_statistics (cœur)
result = self.env["mailing.trace"].sudo()._read_group(
    [("mass_mailing_id", "in", self.ids)],
    ['mass_mailing_id', 'trace_status'],
    ['__count', 'links_click_datetime:count', 'sent_datetime:count'])
# ... regroupé par statut, puis :
values = {
    'delivered': line['sent'] + line['open'] + line['reply'],   # délivré = arrivé à bon port
    'opened':    line['open'] + line['reply'],                  # ouvert inclut répondu
    'replied':   line['reply'],
    'bounced':   line['bounce'],
    'failed':    line['error'],
    'clicked':   line['links_click_datetime'],                  # = nombre de traces cliquées
    'expected':  sum(tous les statuts),
    'canceled':  line['cancel'],
}
total          = (values['expected'] - values['canceled']) or 1
total_no_error = (values['expected'] - values['canceled'] - values['bounced'] - values['failed']) or 1
total_sent     = (values['expected'] - values['canceled'] - values['failed']) or 1
values['received_ratio'] = 100.0 * values['delivered'] / total
values['opened_ratio']   = 100.0 * values['opened']   / total_no_error
values['replied_ratio']  = 100.0 * values['replied']  / total_no_error
values['bounced_ratio']  = 100.0 * values['bounced']  / total_sent

On lit ici la hiérarchie des statuts annoncée en section 2. Un email simplement délivré reste sent ; s'il est ouvert il devient open ; s'il reçoit une réponse, reply. Ces trois statuts se cumulent dans « délivré » : un message répondu a forcément été délivré et ouvert. D'où les sommes : delivered = sent + open + reply, opened = open + reply. Et puisque « cliqué » n'est pas un statut mais un horodatage, il se compte à part (links_click_datetime:count) — un email peut être open et cliqué à la fois.

Les dénominateurs des taux sont eux aussi pensés : le taux d'ouverture se calcule hors erreurs et rebonds (un email qui n'est jamais arrivé ne peut être ouvert), tandis que le taux de rebond se rapporte aux envois réellement tentés. Le taux de clic, lui, fait l'objet d'un calcul SQL distinct (_compute_clicks_ratio) : le nombre de traces distinctes ayant au moins un clic, rapporté au nombre de traces non rebondies/annulées/en erreur — une trace cliquée cinq fois ne compte qu'une fois.

8. Lire l'analytics, et les pièges

Comprendre d'où viennent les chiffres change la façon de les lire. Quelques pièges récurrents :

  • Prendre le taux d'ouverture au pied de la lettre. Il repose sur un pixel souvent bloqué (sous-estimation) ou préchargé (surestimation). C'est un ordre de grandeur, pas une vérité. Le taux de clic est bien plus fiable.
  • Croire que « cliqué » est un statut. Non : c'est la présence d'un links_click_datetime. Une trace peut afficher le statut open tout en étant comptée comme cliquée.
  • Confondre délivré et ouvert. delivered signifie « accepté par le serveur distant », pas « lu ». Seuls open/reply attestent une lecture.
  • Ignorer l'auto-blacklist. Des adresses qui « ne reçoivent plus » peuvent avoir été exclues après cinq rebonds. Le diagnostic est dans mail.blacklist, pas dans la campagne.
  • Oublier que la réponse écrase l'ouverture, jamais l'inverse. set_opened ne régresse pas un statut reply — la hiérarchie est strictement croissante.

À retenir : mailing.trace est le grand livre de la campagne. Quatre capteurs indépendants — le pixel d'ouverture, la redirection de clic, le gateway de réponse, le gateway de rebond — y inscrivent les signaux ; un cinquième chemin, l'envoi lui-même, y pose les statuts sent et error. Les taux affichés ne sont qu'une agrégation honnête de ces lignes, avec leurs forces (le clic, la réponse) et leurs faiblesses (l'ouverture). Savoir lire la trace, c'est savoir lire — et déboguer — toute l'analytique d'emailing d'Odoo.

Reste un dernier maillon, en amont de toute cette traçabilité : la file technique des emails elle-même. Quand un mail.mail reste bloqué en outgoing ou bascule en exception, où regarder, comment relancer ? C'est l'objet de l'article suivant : déboguer la file d'envoi mail.mail.

Voir aussi dans ce parcours Infrastructure

Diffusion de masse — mailing.mailing

La campagne qui crée la trace et réécrit ses liens en URLs courtes.

Lire l'article →
Notifications et Discuss — mail.notification

L'accusé par destinataire à l'unité, équivalent de la trace en masse.

Lire l'article →

Série Tech-Email — Article 12/14 — Parcours Infrastructure emailing Odoo 19.

Articles complémentaires

Architecture mail Odoo — les 4 couches

Le HUB de la série : la place de la traçabilité dans le flux complet.

Lire l'article →
Configurer Brevo SMTP — ir.mail_server & from_filter

Le transport dont la réputation se lit dans les rebonds tracés.

Lire l'article →

Source officielle : Odoo 19 — Email Marketing.

Se connecter pour laisser un commentaire.
Diffusion de masse en Odoo 19 — mailing.mailing, file d'envoi et traçabilité
Série Tech-Email · Article 11/14 · Parcours Infrastructure