Teknisk artikel

At maile PDF'er via CDO i Delphi: apartment-threading-faldgruber

PDFlibPas, losLab PDF Developer Library til Delphi og C++Builder, sender en genereret PDF som en e-mail-vedhæftning gennem ét enkelt, fladt API-kald, SendDocumentByMail. På Windows bruger standard-transporten CDO (Collaboration Data Objects), COM-mail-komponenten indbygget i operativsystemet, og den detalje der rent faktisk ødelægger multi-trådede batch-jobs, er COM-apartment-initialisering, ikke SMTP

Scenariet bag dette API er uglamourøst og ekstremt almindeligt: en tjeneste gengiver en batch af månedsafslutnings-kontoudtog-PDF'er, én pr. kunde, og skal maile hver ud uden en person i løkken. Skub det job over på en trådpulje for gennemløb, og en brøkdel af afsendelserne begynder at fejle med en COM-fejl, der aldrig gengiver, når den samme kode kører på en enkelt tråd. Intet er galt med SMTP-serveren, PDF'en, eller vedhæftningen. Problemet er, hvad CoInitializeEx returnerer på en tråd, CDO ikke forventede, og PDFlibPas er skrevet til at håndtere det tilfælde bevidst frem for ved et tilfælde

Hvad SendDocumentByMail rent faktisk gør inde i PDFlibPas

SendDocumentByMail er en tynd orkestrator, ikke en mail-klient i sin egen ret. TPDFlib.SendDocumentByMail gemmer det aktuelt indlæste dokument til sin egen midlertidige PDF, pakker SMTP-indstillingerne og beskedteksten ind i en TPDFlibMailRequest-record, overdrager den record til, hvad end der implementerer IPDFlibMailProvider, og sletter den midlertidige fil igen, når udbyderen returnerer. Udbyder-grænsefladen er den faktiske mail-klient, og PDFlibPas leverer præcis én indbygget implementering: en CDO-baseret udbyder, der kun kompilerer på Windows. Kald SendDocumentByMail uden at tildele MailProvider-egenskaben først, og PDFlibPas falder automatisk tilbage til den standard. Returværdien forbliver bevidst snæver hele vejen igennem: 1 for accepteret, 0 for alt andet, hvad enten det er et manglende påkrævet felt, en midlertidig-fil-skrivefejl, eller udbyderen der afviser beskeden, med den faktiske årsag kun tilgængelig fra GetLastMailError bagefter

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;

Hvorfor returnerer CoInitializeEx S_FALSE, og er det en fejl?

S_FALSE fra CoInitializeEx er ikke en fejl, og kode der behandler den som en, rapporterer fejl på tråde, hvor intet rent faktisk gik galt. CoInitializeEx returnerer S_OK, første gang en tråd med succes initialiserer COM, og den returnerer S_FALSE, når den tråd allerede havde COM initialiseret med en kompatibel samtidigheds-model, og øger den samme per-tråd-referenceoptælling begge veje, så begge udfald har brug for et matchende CoUninitialize-kald, før tråden afsluttes eller går videre til urelateret arbejde. TPDFlib selv følger præcis dette mønster: at konstruere en TPDFlib-instans kalder allerede CoInitialize og registrerer, om et matchende CoUninitialize skyldes, ved brug af det identiske S_OK-eller-S_FALSE-tjek. På det tidspunkt SendDocumentByMail når sin CDO-udbyder, og den udbyder kalder CoInitializeEx igen, er COM derfor allerede initialiseret på tråden i det almindelige tilfælde, så udbyderen næsten altid observerer S_FALSE frem for S_OK. At behandle S_FALSE som noget andet end succes er ikke et sjældent randtilfælde i dette bibliotek; det er den almindelige vej

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;

Hvorfor returnerer CoInitializeEx RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE betyder, at den aktuelle tråd initialiserede COM tidligere under en anden samtidigheds-model end den, dette kald anmoder om, typisk fordi tråden tidligere gik multi-trådet (MTA), og CDO nu beder om single-threaded-apartment-semantik (STA) gennem COINIT_APARTMENTTHREADED. En tråd vælger sin apartment-model én gang, og intet kan ændre den model for resten af trådens liv; at genforsøge CoInitializeEx med forskellige flag fikser ikke uoverensstemmelsen, og at kalde CoUninitialize først ville rive en apartment ned, anden kode på den tråd måske stadig er afhængig af. PDFlibPas behandler RPC_E_CHANGED_MODE som en betingelse at arbejde med frem for en fejl at rapportere: den springer det parrede CoUninitialize over, da kaldet aldrig rent faktisk erhvervede en reference at frigive, og lader afsendelsen fortsætte på den eksisterende apartment

RPC_E_CHANGED_MODE dukker op næsten udelukkende på genbrugte tråde: en trådpulje-arbejder, en IIS- eller service-host-tråd, eller enhver tråd hvor tidligere kode såsom ADO eller WMI allerede kaldte CoInitializeEx med COINIT_MULTITHREADED, før mail-koden kom nogen steder i nærheden af den. En splinterny tråd, der intet gør andet end at kalde SendDocumentByMail, vil ikke ramme denne vej. En arbejdstråd genbrugt tusindvis af gange om dagen af en batch-skemalægger, og delt med andet COM-baseret arbejde, vil absolut, og den vil gøre det periodisk, hvilket er præcis det mønster, der sender folk til at kigge på SMTP-serveren først og tråd-modellen dernæst

At holde en mail-vedhæftning ude af den forkerte mappe

PDFlibPas skriver hver udgående vedhæftning ind i en frisk mappe navngivet efter et GUID, den genererer ved hvert SendDocumentByMail-kald, specifikt så samtidige afsendelser aldrig kan kollidere på det samme filnavn, og så et vedhæftningsnavn ikke kan gå ud af den mappe. Navnet sendt ind som vedhæftningen betros ikke som en sti: det går gennem PLSanitizeAttachmentName, som strimler enhver mappe-komponent, afviser den tomme streng og de specielle .- og ..-navne, og erstatter hvert tegn, Windows behandler som ulovligt i et filnavn, sammen med ethvert kontroltegn, med en understregning. Fodr den ..\quarter:report.pdf, delvist sti-gennemløb og delvist ulovligt kolon, og hvad der når disk er quarter_report.pdf: alt op til den sidste sti-separator kasseres, og kolonet bliver en understregning, fordi det ikke kan optræde i et Windows-filnavn

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;

En dedikeret per-kald-mappe er ikke bare nydelighed. SendDocumentByMail sletter den midlertidige fil og fjerner sin mappe i en finally-blok, efter beskeden er sendt, ved brug af netop den sti, den skrev til, så et vedhæftningsnavn, der nåede den kode usanitiseret, ville ikke bare fejlplacere skrivningen. Den samme usanitiserede sti ville derefter nå et oprydnings-trin, der kalder DeleteFile uden at stille flere spørgsmål, og på en delt temp-mappe kunne to samtidige afsendelser også i stilhed overskrive hinandens vedhæftning under det samme navn, før nogen af leveringerne er færdige. At sanitisere navnet lukker gennemløbs-tilfældet, og per-kald-GUID-mappen lukker kollisions-tilfældet, og ingen af dem alene ville have været nok

At matche COM-levetid til trådlevetid i en arbejderpulje

Den mest pålidelige fix for apartment-threading-fejl i en batch-mailer er at holde op med at behandle hvert SendDocumentByMail-kald som sin egen isolerede COM-levetid, og i stedet initialisere COM én gang pr. arbejdstråd, for den trådens levetid. En arbejder, der kalder CoInitializeEx(nil, COINIT_APARTMENTTHREADED), når den starter, beholder den apartment for hvert SendDocumentByMail-kald, den foretager, og kalder CoUninitialize præcis én gang, når den afslutter, vil aldrig se RPC_E_CHANGED_MODE fra sine egne mail-afsendelser, fordi intet andet på den tråd får chancen for at initialisere COM i en modstridende tilstand først. Hvert individuelt SendDocumentByMail-kald kører stadig sit eget CoInitializeEx- og CoUninitialize-par internt under dette mønster, og det er harmløst: med apartmenten allerede etableret af arbejdstråden ser hvert af de interne kald nu S_FALSE, øger og sænker den samme referenceoptælling, og lader arbejdstrådens egen COM-apartment urørt

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;

At diagnosticere fejl og teste uden en levende postkasse

GetLastMailError er den anden halvdel af dette API, det er værd at bygge ind i logging fra dag ét, fordi 1-eller-0-returværdien alene ikke siger, om en mislykket afsendelse var et COM-initialiserings-problem, en SMTP-godkendelses-afvisning, eller en manglende vedhæftning. MailProvider-egenskaben er, hvad der gør hele vejen testbar uden en rigtig postkasse: tildel den en IPDFlibMailProvider-implementering, der registrerer anmodninger frem for at sende dem, kør et batch-job mod den falske udbyder i en CI-pipeline, og de samme SendDocumentByMail-kaldesteder bliver ved med at fungere uændret, når MailProvider er efterladt utildelt, og PDFlibPas falder tilbage til den indbyggede CDO-transport i produktion

Et batch-job, der mailer kontoudtog, stopper sjældent ved at sende: den samme pipeline har ofte brug for at validere og signere PDF'en, før den sendes ud, hvilket dækkes separat i artiklen om compliance- og signerings-workbenchen, da preflight og signaturverificering er et andet anliggende end mail-levering, selv når begge kører lige efter hinanden. Når dokumenterne der mailes, selv er outputtet af et stort flet- eller opdel-job frem for en enkelt friskbygget PDF, dækker guiden til store-PDF-direct-access det genererings-trin. SendDocumentByMail og mail-udbyder-modellen beskrevet her er en del af standard-PDFlibPas-PDF-Developer-Library til Delphi og C++Builder, og produktsiden bærer den fulde API-reference sammen med en prøve-download