Migration mail POP et IMAP vers IMAP
Procédure complète pour migrer une boîte mail vers un serveur IMAP, y compris les cas tordus : archives .pst, dossiers locaux .ost invisibles côté serveur, Outlook qui plante, disque plein.
Conventions et cadre d'intervention
Toutes les valeurs propres au client sont notées en placeholders : [email protected], MDP, imap.serveur.tld. Sur un mutualisé cPanel, le nom d'hôte IMAP est celui affiché dans votre propre panneau (forme xxxx.hebergeur.tld), en 993/SSL, SMTP 465/SSL. Ne jamais laisser les identifiants d'un client dans un script partagé.
SYSTEM décrite en phase 05 ne sert qu'à faire survivre un job long à une déconnexion : elle est supprimée dès le job terminé.Arbre de décision
Les phases à jouer dépendent de où vivent réellement les mails du client. On additionne les voies nécessaires.
Les mails sont sur un serveur IMAP (Gandi, OVH…)
Migration serveur à serveur, sans toucher au poste. → Phase 01 (imapsync)
Historique dans un .pst local (compte POP)
Le vrai des mails est sur le poste, pas sur le serveur. → Phase 02 (readpst vers IMAP)
Dossiers marqués (uniquement cet ordinateur) ou jamais montés
Présents seulement dans le cache .ost, invisibles côté serveur. → Phase 03 (pypff vers IMAP)
Après récupération
Phase 04 DNS · 05 Outlook · 06 dédoublonnage · 07 disque. Dans cet ordre.
Préparation
Quand : avant toute chose.
- Identifiants source (ancien serveur, chaque boîte) et cible (nouveau serveur, panneau d'hébergement).
- Accès poste : outil de prise en main à distance. Retenir que son shell tourne souvent en SYSTEM (session 0), ce qui change tout par la suite.
- État du poste : espace disque
C:(souvent le point de blocage), taille de la boîte, Outlook classique ou « nouveau ». - Un disque de travail avec de la place (disque externe) pour les extractions et les sauvegardes.
- Quota cible : le défaut d'une boîte fraîchement créée est souvent 1 Go. Passer en illimité dans le panneau avant toute migration, sinon
EXIT_OVERQUOTA.
Migration IMAP vers IMAP (imapsync)
Quand : la source est un serveur IMAP. Se lance en SSH sur le serveur cible.
Copie serveur à serveur, boîte par boîte. Sur un hébergement mutualisé sans droits root, imapsync s'installe en local::lib ; les modules XS qui ne compilent pas (Unicode::String, Sys::MemInfo…) se rendent optionnels en patchant la source et en posant un stub.
# passfiles par boîte printf '%s' 'MDP_SOURCE' > ~/pf/BOX.src printf '%s' 'MDP_CIBLE' > ~/pf/BOX.dst perl -I$HOME/perl5/lib/perl5 $HOME/imapsync-2.314/imapsync \ --host1 imap.source.tld --user1 [email protected] --passfile1 ~/pf/BOX.src --ssl1 \ --host2 localhost --user2 [email protected] --passfile2 ~/pf/BOX.dst --ssl2 \ --automap --syncinternaldates --pidfile ~/pid/BOX.pid
--pidfile distinct par boîte si lancements en parallèle. Ne jamais lancer deux fois la même boîte en parallèle : doublons garantis. Rattrapage de dernière minute : relancer avec --maxage 1 après la bascule DNS.Archive POP .pst vers IMAP
Quand : l'historique est dans un .pst local (compte POP). Extraction sur la machine, puis téléversement IMAP.
On convertit le .pst en .eml avec readpst (natif Windows, aucun WSL), puis on téléverse en IMAP avec un script Python reprenable et dédoublonné.
Déployer readpst en natif Windows, sans droits admin
# MSYS2 en dossier utilisateur (sans admin, sans redémarrage) curl.exe -L -o F:\m\msys2.sfx.exe https://github.com/msys2/msys2-installer/releases/download/nightly-x86_64/msys2-base-x86_64-latest.sfx.exe F:\m\msys2.sfx.exe -y -oF:\ F:\msys64\usr\bin\bash.exe -lc "pacman -Sy --noconfirm mingw-w64-ucrt-x86_64-libpst" F:\msys64\ucrt64\bin\readpst.exe -V # doit afficher la version
Extraire puis téléverser, en dédoublonnant
F:\msys64\ucrt64\bin\readpst.exe -e -o F:\out "C:\...\archive.pst" F:\m\py\python.exe F:\m\pst2imap.py F:\out
Le script pst2imap.py (voir les scripts) parcourt l'arborescence .eml, mappe vers INBOX.*, crée les dossiers manquants, et fait un APPEND de chaque message avec un en-tête X-Migrate-UID (sha256) plus une réconciliation côté serveur : on peut le relancer sans créer de doublon.
Éléments...). Corriger dans le mapping avec s.encode('cp1252').decode('utf-8') (repli latin1). Pas chcp 65001, qui n'agit que sur la console.Cache local .ost vers IMAP (pypff)
Quand : des dossiers (uniquement cet ordinateur) ou jamais synchronisés existent. Ils ne vivent que dans le .ost.
readpst plante sur les gros .ost corrompus (Out Of memory) et sur les chemins de plus de 260 caractères (Cannot open file). La parade : pypff lit le .ost directement et pousse en IMAP en mémoire, sans écrire un seul fichier sur disque (donc plus de limite de chemin), avec un try/except par message : un item corrompu est sauté, jamais de crash global.
# Python embeddable : l'installeur .exe "réussit" sans rien poser sous SYSTEM curl.exe -L -o F:\m\py.zip https://www.python.org/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip Expand-Archive -Force F:\m\py.zip F:\m\py # activer pip, puis : F:\m\py\python.exe -m pip install libpff-python-windows imapclient # copie de sécurité du .ost, puis extraction directe vers IMAP Copy-Item "C:\Users\U\AppData\Local\Microsoft\Outlook\...ost" F:\ost_actif.ost F:\m\py\python.exe F:\m\ost2imap.py "F:\ost_actif.ost" preview # vérifier le mapping F:\m\py\python.exe F:\m\ost2imap.py "F:\ost_actif.ost" # run réel
.ost contient des niveaux MAPI parasites : Racine / IPM_SUBTREE / Boîte de réception / …. Le mapping doit s'ancrer sur la Boîte de réception (détectée par la sous-chaîne ception), sinon on obtient INBOX.IPM_SUBTREE.Boîte de réception.…. pypff renvoie les noms en Unicode propre (pas de mojibake), mais garde le suffixe (uniquement cet ordinateur) qu'il faut retirer. Toujours lancer preview avant le run.Bascule DNS
Quand : une fois les boîtes migrées, pour rediriger le flux entrant.
- MX
@versmail.serveur.tld, en retirant les anciens MX. Le champ « serveur de messagerie » attend le nom d'hôte : il ne va pas dans le champ Name.- SPF
v=spf1 include:spf.hebergeur.tld -all, en gardant lesincludelégitimes déjà présents (routeur transactionnel par exemple).- DKIM
default._domainkeyrepris du panneau d'hébergement ; supprimer les anciens sélecteurs.- Ordre
- SPF et DKIM d'abord, puis resync rapide (
--maxage 2), puis changement du MX, puis rattrapages (--maxage 1à H+1h, H+4h, J+1).
autodiscover doit rester en DNS seul, jamais proxifié, sinon le certificat casse côté Outlook.Reconfiguration Outlook
Quand : après la bascule, sur chaque poste.
- Si le shell distant tourne en SYSTEM, viser le SID de l'utilisateur connecté (
Win32_ComputerSystem.UserName, puis SID, puisHKU\<SID>), jamais%APPDATA%niHKCU: ce sont ceux de SYSTEM, et on configure un profil que personne n'ouvrira. - Outlook classique obligatoire : les fichiers
.prfne s'appliquent pas au « nouveau Outlook ». - Ajout du compte IMAP par
.prfen modeOverwriteProfile=Append(ajoute sans retirer l'ancien), lancé paroutlook.exe /importprf. - Les signatures sont de simples fichiers locaux, dans le dossier
Signaturesdu bon SID : elles survivent à la migration sans rien faire.
NT AUTHORITY\SYSTEM (LogonType ServiceAccount, ExecutionTimeLimit 0) : elle tourne en session 0 et survit. Ajouter powercfg /change standby-timeout-ac 0, et supprimer la tâche une fois le job fini.Dédoublonnage et fusion
Quand : après avoir empilé plusieurs sources qui se recouvrent (.pst, .ost, copies Outlook).
Méthode en deux temps, jamais destructive d'un coup, toujours dédoublonnée par Message-ID :
- Fusion (COPY) : on copie dans le dossier « propre » les mails qui y manquent, identifiés par Message-ID. Non destructif, la source reste intacte.
- Vérification puis suppression : on ne supprime un dossier « doublon » que si l'intégralité de son contenu est retrouvée dans la cible. Script en mode aperçu par défaut,
gopour exécuter.
Cas typiques rencontrés : les dossiers X - Copie et X (2) (artefacts Outlook), et les doublons entre l'arbre Archive.* (issu du .pst) et les dossiers vivants (issus du .ost).
Disque et cache, la finalisation
Quand : tout est sur le serveur. C'est cette phase qui règle le problème de fond, le disque plein.
- Libérer le disque : supprimer ou déplacer le
.pstlocal, son contenu étant désormais sur le serveur et sauvegardé. Le.pst« par défaut » ne se retire pas directement : créer un petit.pstvide, le définir par défaut, puis retirer le gros du profil, puis supprimer le fichier. Fermer Outlook si Windows le déclare « utilisé ». - Limiter le cache : sur le compte IMAP, régler le curseur « Courrier à conserver hors connexion » sur 1 à 6 mois. Le récent reste local et rapide, l'ancien reste sur le serveur et se télécharge à l'ouverture. Effet de bord utile : ça reconstruit un
.ostneuf et léger, ce qui règle au passage un.ostcorrompu.
Les pièges et leurs parades
Chaque ligne est ancrable : le lien de la ligne pointe directement dessus.
| Symptôme | Cause réelle | Parade | Gravité |
|---|---|---|---|
| WSL « n'exécute rien » via tâche planifiée ou session distante | WSL exige un redémarrage et reste inaccessible en session 0 / SYSTEM | readpst.exe natif Windows (MSYS2). Jamais WSL à distance. | bloquant |
readpst Out Of memory ou Cannot open file | Item corrompu, ou chemin de plus de 260 caractères | pypff en lecture directe, téléversement IMAP en mémoire (ost2imap.py). | bloquant |
Noms de dossiers en Éléments... | readpst écrit en UTF-8, Windows enregistre en cp1252 | s.encode('cp1252').decode('utf-8'). Pas chcp 65001. | piège |
Cibles créées en INBOX.IPM_SUBTREE.Boîte de réception.… | Le .ost expose des niveaux MAPI parasites | Ancrer le mapping sur la Boîte de réception (sous-chaîne ception). | piège |
| L'installeur Python « réussit » sans rien poser | Exécution en contexte SYSTEM / session 0 | Python embeddable (zip), jamais l'installeur .exe. | piège |
Dossiers (uniquement cet ordinateur) absents du serveur | Dossiers locaux Outlook jamais synchronisés en IMAP | Les extraire du .ost (phase 03). | données |
| Le job détaché meurt à la déconnexion | Le shell distant est un enfant de l'agent de prise en main | Tâche planifiée avec principal SYSTEM (session 0). | piège |
EXIT_OVERQUOTA dès le début de l'imapsync | Quota par défaut de la boîte cible à 1 Go | Passer en illimité dans le panneau avant la migration. | bloquant |
Impossible de supprimer le gros .pst | Il est le fichier de données par défaut du profil | Créer un petit .pst vide, le définir par défaut, puis retirer le gros. | piège |
| Des dossiers « clignotent » dans le webmail | Cache d'affichage, ou un autre client qui synchronise en même temps | Vérifier l'état réel en IMAP (compteurs), fermer Outlook, ne rien supprimer à l'œil. | piège |
| Doublons entre l'arbre Archive et les dossiers vivants | Le .pst et le .ost contenaient les mêmes mails | Fusion dédoublonnée par Message-ID, puis suppression vérifiée (phase 06). | piège |
Disque plein, .ost corrompu, Outlook plante | Cache local qui déborde un C: déjà saturé | Sortir l'archive du poste, supprimer le .pst local, limiter le cache (phase 07). | racine |
Les scripts
Trois outils, tous rejouables sans créer de doublon : la déduplication repose sur un en-tête injecté X-Migrate-UID (sha256 des octets d'origine) croisé avec le Message-ID, réconciliés côté serveur avant chaque APPEND. En tête de chaque script : HOST, USER, PWD et les chemins.
ost2imap.py .ost vers IMAP, robuste, le pivot
Lit le .ost avec pypff, reconstruit chaque message (en-têtes, corps, pièces jointes), fait l'APPEND en IMAP. Zéro fichier sur disque, ce qui contourne MAX_PATH, et try/except par message, ce qui contourne la corruption. Mapping ancré sur la Boîte de réception, suffixe local retiré. Mode preview en deuxième argument.
HOST, PORT = "imap.serveur.tld", 993 USER, PWD = "[email protected]", "MDP" SEP = "." LOCAL_SUF = (" (uniquement cet ordinateur)", " (This computer only)") def isinbox(s): f = "".join(c for c in unicodedata.normalize("NFKD", s or "") if not unicodedata.combining(c)).lower() return "ception" in f or "inbox" in f # ancre sur la Boite de reception def maptgt(path): # noms .ost -> INBOX.x.y idx = next((i for i, seg in enumerate(path) if isinbox(_strip(seg))), None) if idx is None: return None rest = [_san(_strip(s)) for s in path[idx+1:]] rest = [s for s in rest if s] if not rest: return None # on ne deverse pas l'inbox brut if rest[0].lower() == "archive": return None return "INBOX" + SEP + SEP.join(rest) def _strip(s): for suf in LOCAL_SUF: if s.endswith(suf): return s[:-len(suf)] return s # build_rfc822(msg) : transport_headers | plain/html (bytes) | pieces jointes # connect() / ensure() / existing() : APPEND dedup par Message-ID + X-Migrate-UID # walk() : recursion pypff, saute les dossiers vides, try/except par message
pst2imap.py arborescence .eml (readpst) vers IMAP, reprenable
Parcourt la sortie de readpst, corrige le mojibake des noms de dossiers, mappe vers INBOX.*, fait l'APPEND avec X-Migrate-UID et réconciliation serveur. Reprend là où il s'est arrêté, sans doublon. Mode preview pour valider le mapping avant.
def demojibake(s): # noms de dossiers readpst try: return s.encode("cp1252").decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): try: return s.encode("latin1").decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): return s def dest(rel): p = [x for x in rel.split(os.sep) if x and x != "."] if p: p = p[1:] # retire la racine du .pst p = [demojibake(x).replace(".", "_") for x in p] return "INBOX.Archive." + ".".join(p) if p else "INBOX.Archive" # APPEND : sha256 des octets ORIGINAUX, avant injection = idempotent au re-run k = hashlib.sha256(raw).hexdigest() if seen(f, k) or k in present: continue c.append(f, b"X-Migrate-UID: " + k.encode() + eol + raw, flags=[b"\\Seen"], msg_time=dt)
fusion.py et fusion_del.py dédoublonner deux arbres, en sécurité
Deux temps. fusion copie, sans rien détruire, les mails absents de l'arbre d'origine, par Message-ID. fusion_del ne supprime un doublon que si son contenu est couvert à 100 % dans l'original, avec aperçu par défaut et go pour exécuter.
DO = len(sys.argv) > 1 and sys.argv[1] == "go"
for cop in doublons:
orig = original_de(cop)
manquants = mids_tree(cop) - mids_tree(orig)
if manquants:
print("GARDE", cop, len(manquants), "non couverts") # on ne touche pas
continue
if DO:
# bottom-up : un dossier parent ne part qu'apres ses enfants
for f in sorted(sous_dossiers(cop), key=lambda x: -x.count(".")):
supprimer(f)
Je peux la jouer pour vous
Cette procédure est publiée telle qu'elle est appliquée en intervention réelle. Si vous préférez la déléguer, ou si votre cas sort du cadre décrit ici, écrivez-moi : je reprends la migration de bout en bout, boîtes et postes compris.
Une migration ou une reprise technique à faire ?
Décrivez votre situation, je vous dis ce qui est faisable, en combien de temps et à quel prix.