Article technique

Envoyer des PDF par e-mail via CDO en Delphi : pièges du threading par appartement

PDFlibPas, la bibliothèque de développement PDF losLab pour Delphi et C++Builder, envoie un PDF généré comme pièce jointe d'e-mail via un seul appel d'API plat, SendDocumentByMail. Sur Windows, le transport par défaut utilise CDO (Collaboration Data Objects), le composant de messagerie COM intégré au système d'exploitation, et le détail qui casse réellement les tâches par lot multithread est l'initialisation d'appartement COM, pas SMTP

Le scénario derrière cette API est peu glamour et extrêmement courant : un service rend un lot de PDF de relevé de fin de mois, un par client, et doit les envoyer par e-mail sans personne dans la boucle. Poussez cette tâche sur un pool de threads pour le débit, et une fraction des envois commence à échouer avec une erreur COM qui ne se reproduit jamais quand le même code s'exécute sur un seul thread. Rien ne va mal avec le serveur SMTP, le PDF, ou la pièce jointe. Le problème est ce que CoInitializeEx renvoie sur un thread que CDO n'attendait pas, et PDFlibPas est écrit pour gérer ce cas délibérément plutôt que par accident

Ce que fait réellement SendDocumentByMail à l'intérieur de PDFlibPas

SendDocumentByMail est un orchestrateur mince, pas un client de messagerie en soi. TPDFlib.SendDocumentByMail enregistre le document actuellement chargé dans son propre PDF temporaire, empaquette les réglages SMTP et le texte du message dans un enregistrement TPDFlibMailRequest, transmet cet enregistrement à quel que soit ce qui implémente IPDFlibMailProvider, et supprime à nouveau le fichier temporaire une fois le fournisseur revenu. L'interface de fournisseur est le véritable client de messagerie, et PDFlibPas fournit exactement une implémentation intégrée : un fournisseur basé sur CDO qui ne compile que sous Windows. Appelez SendDocumentByMail sans assigner d'abord la propriété MailProvider, et PDFlibPas retombe automatiquement sur ce défaut. La valeur de retour reste délibérément étroite tout du long : 1 pour accepté, 0 pour tout le reste, que ce soit un champ requis manquant, un échec d'écriture de fichier temporaire, ou le fournisseur rejetant le message, avec la raison réelle disponible seulement depuis GetLastMailError ensuite

var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // a new instance already holds one blank document
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... draw the statement: fonts, text, totals ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // port 0 with SSL 1 falls back to 465
      'billing@example.com', 'app-password',    // SMTP auth
      'billing@example.com', 'customer@example.com', '', '',
      'Your statement is ready',
      'Please find the attached PDF statement.',
      'statement-4471.pdf');                    // attachment display name
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

Pourquoi CoInitializeEx renvoie-t-il S_FALSE, et est-ce un échec ?

S_FALSE venant de CoInitializeEx n'est pas un échec, et le code qui le traite comme tel rapporte des échecs sur des threads où rien ne s'est réellement mal passé. CoInitializeEx renvoie S_OK la première fois qu'un thread initialise COM avec succès, et il renvoie S_FALSE quand ce thread avait déjà COM initialisé avec un modèle de concurrence compatible, incrémentant le même compteur de références par thread dans les deux cas, si bien que les deux résultats ont besoin d'un appel CoUninitialize correspondant avant que le thread ne se termine ou ne passe à un travail sans rapport. TPDFlib lui-même suit exactement ce schéma : construire une instance TPDFlib appelle déjà CoInitialize et enregistre si un CoUninitialize correspondant est dû, en utilisant la vérification identique S_OK-ou-S_FALSE. Au moment où SendDocumentByMail atteint son fournisseur CDO et que ce fournisseur appelle à nouveau CoInitializeEx, COM est donc déjà initialisé sur le thread dans le cas ordinaire, si bien que le fournisseur observe presque toujours S_FALSE plutôt que S_OK. Traiter S_FALSE comme autre chose qu'un succès n'est pas un cas limite rare dans cette bibliothèque ; c'est le chemin courant

InitResult := CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
NeedUninitialize := (InitResult = S_OK) or (InitResult = S_FALSE);
if Failed(InitResult) and (InitResult <> RPC_E_CHANGED_MODE) then
begin
  ErrorText := 'COM initialization failed';
  Exit;
end;
try
  // ... create CDO.Message, CDO.Configuration, send ...
finally
  if NeedUninitialize then
    CoUninitialize;
end;

Pourquoi CoInitializeEx renvoie-t-il RPC_E_CHANGED_MODE ?

RPC_E_CHANGED_MODE signifie que le thread actuel a initialisé COM plus tôt sous un modèle de concurrence différent de celui que cet appel demande, typiquement car le thread est précédemment devenu multithread (MTA) et CDO demande maintenant une sémantique d'appartement monothread (STA) via COINIT_APARTMENTTHREADED. Un thread choisit son modèle d'appartement une fois, et rien ne peut changer ce modèle pour le reste de la vie du thread ; réessayer CoInitializeEx avec des drapeaux différents ne corrige pas l'incompatibilité, et appeler CoUninitialize d'abord démonterait un appartement dont d'autre code sur ce thread pourrait encore dépendre. PDFlibPas traite RPC_E_CHANGED_MODE comme une condition avec laquelle travailler plutôt qu'une erreur à rapporter : il saute le CoUninitialize apparié, puisque l'appel n'a en fait jamais acquis de référence à libérer, et laisse l'envoi continuer sur l'appartement existant

RPC_E_CHANGED_MODE apparaît presque exclusivement sur des threads réutilisés : un travailleur de pool de threads, un thread IIS ou d'hébergement de service, ou tout thread où du code antérieur comme ADO ou WMI a déjà appelé CoInitializeEx avec COINIT_MULTITHREADED avant que le code de messagerie n'en approche. Un thread flambant neuf qui ne fait rien d'autre qu'appeler SendDocumentByMail ne rencontrera pas ce chemin. Un thread de travail recyclé des milliers de fois par jour par un planificateur de lots, et partagé avec d'autre travail basé sur COM, le rencontrera absolument, et le fera par intermittence, ce qui est exactement le schéma qui envoie les gens regarder d'abord le serveur SMTP et le modèle de threading en second

Garder une pièce jointe d'e-mail hors du mauvais répertoire

PDFlibPas écrit chaque pièce jointe sortante dans un répertoire neuf nommé d'après un GUID qu'il génère à chaque appel SendDocumentByMail, spécifiquement afin que des envois concurrents ne puissent jamais entrer en collision sur le même nom de fichier et afin qu'un nom de pièce jointe ne puisse pas sortir de ce répertoire. Le nom transmis comme pièce jointe n'est pas fait confiance comme chemin : il passe par PLSanitizeAttachmentName, qui retire tout composant de répertoire, rejette la chaîne vide et les noms spéciaux . et .., et remplace chaque caractère que Windows traite comme illégal dans un nom de fichier, ainsi que tout caractère de contrôle, par un tiret bas. Donnez-lui ..\quarter:report.pdf, en partie traversée de répertoire et en partie deux-points illégal, et ce qui atteint le disque est quarter_report.pdf : tout jusqu'au dernier séparateur de chemin est jeté, et les deux-points deviennent un tiret bas car ils ne peuvent pas apparaître dans un nom de fichier Windows

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // strip any directory part
  if (Result = '') or (Result = '.') or (Result = '..') then
    Result := 'document.pdf';
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], WideString('<>:"/\|?*')) > 0) then
      Result[I] := '_';
end;

Un répertoire dédié par appel n'est pas que de l'ordre. SendDocumentByMail supprime le fichier temporaire et retire son répertoire dans un bloc finally après l'envoi du message, en utilisant le même chemin où il a écrit, si bien qu'un nom de pièce jointe atteignant ce code non assaini n'aurait pas seulement mal placé l'écriture. Ce même chemin non assaini atteindrait alors une étape de nettoyage qui appelle DeleteFile sans poser plus de questions, et sur un dossier temporaire partagé, deux envois concurrents pourraient aussi silencieusement s'écraser mutuellement la pièce jointe sous le même nom avant que l'un ou l'autre envoi ne se termine. Assainir le nom ferme le cas de traversée, et le répertoire GUID par appel ferme le cas de collision, et aucun des deux seul n'aurait suffi

Faire correspondre la durée de vie COM à la durée de vie du thread dans un pool de travailleurs

La correction la plus fiable pour les échecs de threading par appartement dans un mailer par lot est d'arrêter de traiter chaque appel SendDocumentByMail comme sa propre durée de vie COM isolée, et d'initialiser plutôt COM une fois par thread de travail, pour la durée de vie de ce thread. Un travailleur qui appelle CoInitializeEx(nil, COINIT_APARTMENTTHREADED) à son démarrage, garde cet appartement pour chaque appel SendDocumentByMail qu'il effectue, et appelle CoUninitialize exactement une fois à sa sortie ne verra jamais RPC_E_CHANGED_MODE depuis ses propres envois de messagerie, car rien d'autre sur ce thread n'a la chance d'initialiser COM dans un mode conflictuel en premier. Chaque appel individuel SendDocumentByMail exécute toujours sa propre paire CoInitializeEx et CoUninitialize en interne sous ce schéma, et c'est inoffensif : avec l'appartement déjà établi par le thread de travail, chacun de ces appels internes voit désormais S_FALSE, incrémente et décrémente le même compteur de références, et laisse intact le propre appartement COM du thread de travail

type
  TMailWorker = class(TThread)
  protected
    procedure Execute; override;
  end;

procedure TMailWorker.Execute;
var
  PDF: TPDFlib;
  Job: TStatementJob;
begin
  CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
  try
    while not Terminated do
    begin
      if not TryGetNextJob(Job) then
        Break;
      PDF := TPDFlib.Create;
      try
        BuildStatement(PDF, Job);
        if PDF.SendDocumentByMail(Job.Host, 0, 1, Job.User, Job.Pass,
             Job.From, Job.Recipient, '', '', Job.Subject, Job.Body,
             Job.AttachmentName) <> 1 then
          LogFailure(Job, PDF.GetLastMailError);
      finally
        PDF.Free;
      end;
    end;
  finally
    CoUninitialize;
  end;
end;

Diagnostiquer les échecs et tester sans boîte aux lettres active

GetLastMailError est l'autre moitié de cette API qui mérite d'être intégrée à la journalisation dès le premier jour, car la seule valeur de retour 1-ou-0 ne dit pas si un envoi échoué était un problème d'initialisation COM, un rejet d'authentification SMTP, ou une pièce jointe manquante. La propriété MailProvider est ce qui rend tout le chemin testable sans véritable boîte aux lettres : assignez-lui une implémentation IPDFlibMailProvider qui enregistre les requêtes au lieu de les envoyer, exécutez une tâche par lot contre ce faux fournisseur dans un pipeline CI, et les mêmes points d'appel SendDocumentByMail continuent de fonctionner sans changement une fois MailProvider laissé non défini et PDFlibPas retombant sur le transport CDO intégré en production

Une tâche par lot qui envoie des relevés par e-mail s'arrête rarement à l'envoi : le même pipeline a souvent besoin de valider et de signer le PDF avant qu'il ne parte, ce qui est couvert séparément dans l'article sur l'atelier de conformité et de signature, puisque le préflight et la vérification de signature sont une préoccupation différente de la livraison de courrier même quand les deux s'exécutent l'un après l'autre. Quand les documents envoyés par e-mail sont eux-mêmes la sortie d'une grande tâche de fusion ou de division plutôt qu'un seul PDF fraîchement construit, le guide d'accès direct pour PDF volumineux couvre cette étape de génération. SendDocumentByMail et le modèle de fournisseur de messagerie décrits ici font partie de la bibliothèque de développement PDF PDFlibPas standard pour Delphi et C++Builder, et la page produit porte la référence complète de l'API aux côtés d'un téléchargement d'essai