Repérer un numéro de suivi dans un e-mail

Retrouver la référence de colis dans une confirmation d’expédition et savoir de quel transporteur elle relève.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la norme UPU S10 fixe la forme et le calcul : le onzième caractère se déduit des huit chiffres qui le précèdent, par une somme pondérée par 8, 6, 4, 2, 3, 5, 9, 7 et un reste modulo onze. L’extrait reproduit l’exemple que la norme donne elle-même, et le test mesure ce que ce contrôle vaut : sur les soixante-douze substitutions d’un chiffre et les sept transpositions de deux chiffres voisins du numéro d’exemple, aucune ne passe. Le reste de la fiche est une discipline de vocabulaire. Une famille sans clé est une forme, pas une identification, et le rapport le dit à chaque ligne, avec une raison par famille : « dix chiffres » ne porte aucune clé, quand un numéro UPS en porte une — la dix-huitième position, calculée ici aussi, bien qu’UPS ne publie pas l’algorithme ; les familles sans clé ne sont pas cherchées si l’appelant ne les demande pas ; et les indicatifs de service que la norme réserve — JA à JZ, KA à KZ, SA à SZ, TA à TZ, WA à WZ — ne sont pas des numéros, quelle que soit leur allure.

La forme de la norme, et son chiffre de contrôle quand elle en a un

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-tracking-numbers-from-an-email/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Les références de colis qu'un e-mail porte, et ce que chacune vaut.

Niveau N0. Un numéro de suivi n'est pas du texte libre : c'est un
identifiant dont une norme fixe la forme, et la norme internationale — UPU
S10, employée par tous les opérateurs postaux et donc par Colissimo et
Chronopost — y met un chiffre de contrôle. Deux lettres, huit chiffres, un
chiffre de contrôle, deux lettres de pays : le onzième caractère se calcule à
partir des huit précédents, et un numéro dont un chiffre est faux échoue.
C'est la différence entre trouver un candidat et identifier une référence.

Un numéro UPS en porte une aussi. La dix-huitième position d'un numéro « 1Z »
est une clé calculée sur les quinze caractères qui la précèdent : les lettres
deviennent des chiffres, les places impaires comptent une fois et les paires
deux, et la clé est ce qui mène le total à la dizaine suivante. UPS ne publie
pas ce calcul ; il est écrit ici parce que c'est celui que satisfait l'exemple
canonique du transporteur — `1Z999AA10123456784`, dont les quinze caractères
font 96, d'où une clé de 4 — et parce qu'un numéro qui y échoue est rendu
quand même, avec `checked: false` et sa raison. Rien n'est écarté sur la foi
d'une règle non publiée ; seul le mot « vérifié » est retenu.

Dix chiffres nus, en revanche, ne portent aucune clé : c'est la forme d'une
lettre de transport DHL Express, et c'est aussi celle d'un numéro de
commande, d'un numéro de client et d'un numéro de téléphone écrit sans
espaces. Cette famille est déclarée et nommée, mais elle n'est pas cherchée
si l'appelant ne le demande pas : un niveau qui rend tous les nombres à dix
chiffres d'un e-mail comme des références de colis est pire qu'un niveau qui
ne rend rien. Chaque famille a sa propre raison, parce que « il n'y a rien à
vérifier » et « ce n'est pas vérifié ici » ne disent pas la même chose.

Ce qui revient dit donc trois choses : ce qui a été trouvé, de quelle famille
cela relève, et si quelque chose a réellement été vérifié. Un candidat n'est
jamais rendu comme une identification.
"""

from __future__ import annotations

import re

# The service indicators the standard reserves: a valid S10 identifier never
# starts with one of them.
RESERVED = ("J", "K", "S", "T", "W")

# The weighting factors of the S10 check digit, in the order of the serial
# number's eight digits.
WEIGHTS = (8, 6, 4, 2, 3, 5, 9, 7)

# The eighteenth character of a `1Z` number is a check digit over the fifteen
# that precede it: letters become digits, the odd places count once and the
# even ones twice, and the key is what brings the total to the next ten.
#
# UPS does not publish this. It is written here because it is the algorithm the
# carrier's own canonical example satisfies — `1Z999AA10123456784`, whose
# fifteen characters add up to 96, hence a key of 4 — and because a number that
# fails it is still returned, with `checked: false` and the reason. Nothing is
# dropped on the strength of an unpublished rule; only the word « verified » is
# withheld.
UPS_BODY = 15


def ups_check_digit(body: str) -> int:
    """The check digit of the fifteen characters after « 1Z »."""
    total = 0
    for place, character in enumerate(body, start=1):
        value = int(character) if character.isdigit() else (ord(character) - 63) % 10
        total += value if place % 2 else value * 2
    return (10 - total % 10) % 10


# Each family: what it looks like, who uses it, and what the number itself
# proves when there is something in it to check.
FAMILIES = {
    "upu-s10": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])([A-Z]{2})([0-9]{8})([0-9])([A-Z]{2})(?![0-9A-Za-z])"),
        "carriers": ["La Poste", "Colissimo", "Chronopost", "postal operators"],
        "verifiable": True,
    },
    "ups": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])1Z[0-9A-Z]{16}(?![0-9A-Za-z])"),
        "carriers": ["UPS"],
        "verifiable": True,
    },
    "ten-digits": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])[0-9]{10}(?![0-9A-Za-z])"),
        "carriers": ["DHL Express", "and anything else written on ten digits"],
        "verifiable": False,
    },
}

# The families searched when the caller does not say. The one left out is the
# one that matches an order number as readily as a parcel.
DEFAULT_FAMILIES = ("upu-s10", "ups")


def find_tracking_numbers(text, families=DEFAULT_FAMILIES) -> dict:
    """
    Every parcel reference candidate in `text`, with what could be verified.

    `families` is declared by the caller. Adding « ten-digits » finds DHL
    Express numbers, and every other ten-digit number in the same message.
    """
    if not isinstance(text, str):
        return {"found": [], "reason": f"expected text, not {type(text).__name__}"}
    unknown = [name for name in families if name not in FAMILIES]
    if unknown:
        return {"found": [], "reason": f"unknown families: {', '.join(sorted(unknown))}"}

    found = []
    for name in families:
        family = FAMILIES[name]
        for match in family["pattern"].finditer(text):
            checked, why = _verify(name, match)
            if why == "reserved":
                continue  # not an S10 identifier at all, whatever it looks like
            found.append({"text": match.group(), "family": name,
                          "carriers": list(family["carriers"]), "checked": checked,
                          "why": why, "start": match.start(), "end": match.end()})
    found.sort(key=lambda item: (item["start"], item["family"]))
    return {"found": found, "reason": None}


def _verify(name: str, match):
    """
    What the number itself proves.

    One reason per family, because they are not the same statement. « Ten
    digits » carries no key at all: nothing in the number can contradict it.
    A UPS number carries one, and this snippet computes it.
    """
    if name == "ten-digits":
        return False, "shape only: ten digits carry no key to check"
    if name == "ups":
        body, digit = match.group()[2:2 + UPS_BODY], match.group()[-1]
        if not digit.isdigit() or ups_check_digit(body) != int(digit):
            return False, "the UPS check digit does not match the rest of the number"
        return True, None
    service, serial, digit, _country = match.groups()
    if service[0] in RESERVED:
        return False, "reserved"
    if check_digit(serial) != int(digit):
        return False, "the check digit does not match the serial number"
    return True, None


def check_digit(serial: str) -> int:
    """
    The S10 check digit of an eight-digit serial number.

    Weighted modulus 11, as the standard describes it: the weighted sum is
    subtracted from eleven, 10 becomes 0 and 11 becomes 5.
    """
    total = sum(int(figure) * weight for figure, weight in zip(serial, WEIGHTS))
    remainder = 11 - total % 11
    return {10: 0, 11: 5}.get(remainder, remainder)

JavaScript

snippets/extract-tracking-numbers-from-an-email/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Les références de colis qu'un e-mail porte, et ce que chacune vaut.
 *
 * Niveau N0. Un numéro de suivi n'est pas du texte libre : c'est un
 * identifiant dont une norme fixe la forme, et la norme internationale — UPU
 * S10, employée par tous les opérateurs postaux et donc par Colissimo et
 * Chronopost — y met un chiffre de contrôle. Deux lettres, huit chiffres, un
 * chiffre de contrôle, deux lettres de pays : le onzième caractère se calcule à
 * partir des huit précédents, et un numéro dont un chiffre est faux échoue.
 * C'est la différence entre trouver un candidat et identifier une référence.
 *
 * Un numéro UPS en porte une aussi. La dix-huitième position d'un numéro « 1Z »
 * est une clé calculée sur les quinze caractères qui la précèdent : les lettres
 * deviennent des chiffres, les places impaires comptent une fois et les paires
 * deux, et la clé est ce qui mène le total à la dizaine suivante. UPS ne publie
 * pas ce calcul ; il est écrit ici parce que c'est celui que satisfait l'exemple
 * canonique du transporteur — `1Z999AA10123456784`, dont les quinze caractères
 * font 96, d'où une clé de 4 — et parce qu'un numéro qui y échoue est rendu
 * quand même, avec `checked: false` et sa raison. Rien n'est écarté sur la foi
 * d'une règle non publiée ; seul le mot « vérifié » est retenu.
 *
 * Dix chiffres nus, en revanche, ne portent aucune clé : c'est la forme d'une
 * lettre de transport DHL Express, et c'est aussi celle d'un numéro de
 * commande, d'un numéro de client et d'un numéro de téléphone écrit sans
 * espaces. Cette famille est déclarée et nommée, mais elle n'est pas cherchée
 * si l'appelant ne le demande pas : un niveau qui rend tous les nombres à dix
 * chiffres d'un e-mail comme des références de colis est pire qu'un niveau qui
 * ne rend rien. Chaque famille a sa propre raison, parce que « il n'y a rien à
 * vérifier » et « ce n'est pas vérifié ici » ne disent pas la même chose.
 *
 * Ce qui revient dit donc trois choses : ce qui a été trouvé, de quelle famille
 * cela relève, et si quelque chose a réellement été vérifié. Un candidat n'est
 * jamais rendu comme une identification.
 */

// The service indicators the standard reserves: a valid S10 identifier never
// starts with one of them.
const RESERVED = new Set(['J', 'K', 'S', 'T', 'W']);

// The weighting factors of the S10 check digit, in the order of the serial
// number's eight digits.
export const WEIGHTS = [8, 6, 4, 2, 3, 5, 9, 7];

// The eighteenth character of a `1Z` number is a check digit over the fifteen
// that precede it: letters become digits, the odd places count once and the
// even ones twice, and the key is what brings the total to the next ten.
//
// UPS does not publish this. It is written here because it is the algorithm
// the carrier's own canonical example satisfies — `1Z999AA10123456784`, whose
// fifteen characters add up to 96, hence a key of 4 — and because a number
// that fails it is still returned, with `checked: false` and the reason.
// Nothing is dropped on the strength of an unpublished rule; only the word
// « verified » is withheld.
export const UPS_BODY = 15;

/** The check digit of the fifteen characters after « 1Z ». */
export function upsCheckDigit(body) {
  let total = 0;
  [...body].forEach((character, index) => {
    const place = index + 1;
    const value = /[0-9]/.test(character)
      ? Number(character)
      : (character.charCodeAt(0) - 63) % 10;
    total += place % 2 === 1 ? value : value * 2;
  });
  return (10 - (total % 10)) % 10;
}

// Each family: what it looks like, who uses it, and what the number itself
// proves when there is something in it to check.
export const FAMILIES = {
  'upu-s10': {
    pattern: /(?<![0-9A-Za-z])([A-Z]{2})([0-9]{8})([0-9])([A-Z]{2})(?![0-9A-Za-z])/g,
    carriers: ['La Poste', 'Colissimo', 'Chronopost', 'postal operators'],
    verifiable: true,
  },
  ups: {
    pattern: /(?<![0-9A-Za-z])1Z[0-9A-Z]{16}(?![0-9A-Za-z])/g,
    carriers: ['UPS'],
    verifiable: true,
  },
  'ten-digits': {
    pattern: /(?<![0-9A-Za-z])[0-9]{10}(?![0-9A-Za-z])/g,
    carriers: ['DHL Express', 'and anything else written on ten digits'],
    verifiable: false,
  },
};

// The families searched when the caller does not say. The one left out is the
// one that matches an order number as readily as a parcel.
export const DEFAULT_FAMILIES = ['upu-s10', 'ups'];

/**
 * Every parcel reference candidate in `text`, with what could be verified.
 *
 * `families` is declared by the caller. Adding « ten-digits » finds DHL
 * Express numbers, and every other ten-digit number in the same message.
 */
export function findTrackingNumbers(text, families = DEFAULT_FAMILIES) {
  if (typeof text !== 'string') {
    return { found: [], reason: `expected text, not ${typeof text}` };
  }
  const unknown = [...families].filter((name) => !(name in FAMILIES));
  if (unknown.length) {
    return { found: [], reason: `unknown families: ${unknown.sort().join(', ')}` };
  }

  const found = [];
  for (const name of families) {
    const family = FAMILIES[name];
    for (const match of text.matchAll(family.pattern)) {
      const [checked, why] = verify(name, match);
      if (why === 'reserved') continue; // not an S10 identifier at all
      found.push({
        text: match[0],
        family: name,
        carriers: [...family.carriers],
        checked,
        why,
        start: match.index,
        end: match.index + match[0].length,
      });
    }
  }
  found.sort((a, b) => a.start - b.start
    || (a.family < b.family ? -1 : a.family > b.family ? 1 : 0));
  return { found, reason: null };
}

/**
 * What the number itself proves.
 *
 * One reason per family, because they are not the same statement. « Ten
 * digits » carries no key at all: nothing in the number can contradict it. A
 * UPS number carries one, and this snippet computes it.
 */
function verify(name, match) {
  if (name === 'ten-digits') return [false, 'shape only: ten digits carry no key to check'];
  if (name === 'ups') {
    const numero = match[0];
    const dernier = numero.at(-1);
    if (!/[0-9]/.test(dernier) || upsCheckDigit(numero.slice(2, 2 + UPS_BODY)) !== Number(dernier)) {
      return [false, 'the UPS check digit does not match the rest of the number'];
    }
    return [true, null];
  }
  const [, service, serial, digit] = match;
  if (RESERVED.has(service[0])) return [false, 'reserved'];
  if (checkDigit(serial) !== Number(digit)) {
    return [false, 'the check digit does not match the serial number'];
  }
  return [true, null];
}

/**
 * The S10 check digit of an eight-digit serial number.
 *
 * Weighted modulus 11, as the standard describes it: the weighted sum is
 * subtracted from eleven, 10 becomes 0 and 11 becomes 5.
 */
export function checkDigit(serial) {
  const total = [...serial].reduce((sum, figure, index) => sum + Number(figure) * WEIGHTS[index], 0);
  const remainder = 11 - (total % 11);
  return { 10: 0, 11: 5 }[remainder] ?? remainder;
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Rien ne part sur le réseau : aucun transporteur n’est interrogé. Seule la norme UPU S10 porte un chiffre de contrôle ; les autres familles ne sont que des formes reconnues.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

2 passages attrapés

Ce que le code a repéré

Bonjour, Votre commande 1234567890 du 10 octobre 2026 est expédiée. Numéro de suivi Colissimo : RB123456785GB. Suivi UPS : 1Z9999W99999999999. Cordialement, Le service client

Ce qu’il renvoie

2 candidat(s), dont 1 vérifié(s) par leur chiffre de contrôle

Le détail

TrouvéFamilleVérifiéPourquoi
RB123456785GBupu-s10ouichiffre de contrôle juste
1Z9999W99999999999upsnon

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le message est lu dans votre processus et aucun transporteur n’est interrogé
  • Un numéro de suivi relie une adresse de livraison à une personne : ce niveau est celui qui garde ce lien chez vous

Point de rupture

Une famille sans clé attrape tout ce qui a sa forme : demander « dix chiffres » rend aussi le numéro de commande et le téléphone compact du même message. Témoin dans le même test : le numéro postal de la même ligne revient vérifié, et le même numéro avec un chiffre de travers revient refusé.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus qui ait un sens ici. Quand vous avez besoin de savoir où est le colis, et non de quel numéro il s’agit, la suite n’est pas un modèle : c’est l’interface de suivi du transporteur, appelée avec le numéro que ce niveau a vérifié.

Les quatre options

  1. RecommandéN0La forme de la norme, et son chiffre de contrôle quand elle en a unGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre : la forme est publiée, et le chiffre de contrôle est une multiplication par huit poids suivie d’un reste. Un classifieur appris sur des e-mails annotés retrouverait les formes qu’il a vues et se tromperait sur celles qu’il n’a pas vues, sans jamais pouvoir dire ce que le chiffre de contrôle dit : que ce numéro-là n’a pas été abîmé en le recopiant.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, avec le budget en plus. Le travail se compte en microsecondes par message, sur des boîtes qui en reçoivent des milliers par jour ; un modèle résident coûterait des ordres de grandeur de plus pour une réponse moins vérifiable que huit multiplications.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un appel de modèle par message pour lire un identifiant dont la norme est publique est la sur-ingénierie que ce site existe pour nommer. Et la sortie de données porte ici sur une correspondance qui relie un nom, une adresse de livraison et une commande — le genre de message qu’on ne transmet pas à un tiers pour lui faire lire treize caractères qu’un test de deux lignes vérifie.

Pour aller plus loin

Source des chiffres

Métadonnées