Technisch artikel

PDF's e-mailen via CDO in Delphi: apartment-threading-valkuilen

PDFlibPas, de losLab PDF Developer Library voor Delphi en C++Builder, verzendt een gegenereerde PDF als e-mailbijlage via één platte API-aanroep, SendDocumentByMail. Op Windows gebruikt het standaardtransport CDO (Collaboration Data Objects), de in het besturingssysteem ingebouwde COM-mailcomponent, en het detail dat multithreaded batchtaken daadwerkelijk breekt, is COM-apartment-initialisatie, niet SMTP

Het scenario achter deze API is onopvallend en extreem gangbaar: een service rendert een batch maandafsluitingsafschrift-PDF's, één per klant, en moet elk daarvan mailen zonder dat er een mens tussen zit. Duw die taak naar een threadpool voor doorvoer, en een fractie van de verzendingen begint te falen met een COM-fout die nooit reproduceert wanneer dezelfde code op één thread draait. Er is niets mis met de SMTP-server, de PDF, of de bijlage. Het probleem is wat CoInitializeEx teruggeeft op een thread die CDO niet verwachtte, en PDFlibPas is geschreven om dat geval doelbewust af te handelen, niet per ongeluk

Wat SendDocumentByMail daadwerkelijk doet binnen PDFlibPas

SendDocumentByMail is een dunne orkestrator, geen mailclient op zichzelf. TPDFlib.SendDocumentByMail slaat het momenteel geladen document op naar zijn eigen tijdelijke PDF, verpakt de SMTP-instellingen en berichttekst in een TPDFlibMailRequest-record, geeft dat record aan wat IPDFlibMailProvider ook implementeert, en verwijdert het tijdelijke bestand weer zodra de provider terugkeert. De providerinterface is de eigenlijke mailclient, en PDFlibPas levert precies één ingebouwde implementatie: een op CDO gebaseerde provider die alleen op Windows compileert. Roep SendDocumentByMail aan zonder eerst de eigenschap MailProvider toe te wijzen, en PDFlibPas valt automatisch terug op die standaard. De retourwaarde blijft doelbewust smal doorheen: 1 voor geaccepteerd, 0 voor al het andere, of dat nu een ontbrekend verplicht veld is, een schrijffout van het tijdelijke bestand, of de provider die het bericht afwijst, met de werkelijke reden alleen achteraf beschikbaar via GetLastMailError

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;

Waarom geeft CoInitializeEx S_FALSE terug, en is dat een storing?

S_FALSE van CoInitializeEx is geen storing, en code die het als eentje behandelt, rapporteert storingen op threads waar in werkelijkheid niets misging. CoInitializeEx geeft S_OK terug de eerste keer dat een thread COM succesvol initialiseert, en het geeft S_FALSE terug wanneer die thread al COM had geïnitialiseerd met een compatibel concurrency-model, en verhoogt in beide gevallen dezelfde per-thread-referentietelling, dus beide uitkomsten hebben een bijbehorende CoUninitialize-aanroep nodig voordat de thread afsluit of doorgaat naar ongerelateerd werk. TPDFlib zelf volgt precies dit patroon: een TPDFlib-instantie construeren roept al CoInitialize aan en registreert of er een bijbehorende CoUninitialize verschuldigd is, met dezelfde S_OK-of-S_FALSE-controle. Tegen de tijd dat SendDocumentByMail zijn CDO-provider bereikt en die provider opnieuw CoInitializeEx aanroept, is COM daarom in het gewone geval al geïnitialiseerd op de thread, dus observeert de provider bijna altijd S_FALSE in plaats van S_OK. S_FALSE behandelen als iets anders dan succes is geen zeldzaam randgeval in deze bibliotheek; het is het gangbare pad

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;

Waarom geeft CoInitializeEx RPC_E_CHANGED_MODE terug?

RPC_E_CHANGED_MODE betekent dat de huidige thread eerder COM heeft geïnitialiseerd onder een ander concurrency-model dan waar deze aanroep om vraagt, meestal omdat de thread eerder multithreaded (MTA) is geworden en CDO nu vraagt om single-threaded-apartment-semantiek (STA) via COINIT_APARTMENTTHREADED. Een thread kiest zijn apartment-model één keer, en niets kan dat model voor de rest van de levensduur van de thread veranderen; CoInitializeEx opnieuw proberen met andere vlaggen lost de mismatch niet op, en CoUninitialize eerst aanroepen zou een apartment afbreken waar andere code op die thread mogelijk nog van afhangt. PDFlibPas behandelt RPC_E_CHANGED_MODE als een toestand om mee te werken in plaats van een fout om te rapporteren: het slaat de bijbehorende CoUninitialize over, aangezien de aanroep nooit daadwerkelijk een referentie verkreeg om vrij te geven, en laat de verzending doorgaan op het bestaande apartment

RPC_E_CHANGED_MODE duikt vrijwel uitsluitend op bij hergebruikte threads: een thread-pool-worker, een IIS- of service-host-thread, of elke thread waar eerdere code zoals ADO of WMI al CoInitializeEx aanriep met COINIT_MULTITHREADED voordat de mailcode er ook maar in de buurt kwam. Een gloednieuwe thread die niets anders doet dan SendDocumentByMail aanroepen, zal dit pad niet raken. Een workerthread die duizenden keren per dag wordt hergebruikt door een batchscheduler, en gedeeld wordt met ander COM-gebaseerd werk, zal dat absoluut wel, en het zal dat met tussenpozen doen, precies het patroon dat mensen eerst naar de SMTP-server laat kijken en pas daarna naar het threadingmodel

Een e-mailbijlage uit de verkeerde directory houden

PDFlibPas schrijft elke uitgaande bijlage naar een verse directory genoemd naar een GUID die het bij elke SendDocumentByMail-aanroep genereert, specifiek zodat gelijktijdige verzendingen nooit kunnen botsen op dezelfde bestandsnaam en zodat een bijlagenaam niet uit die directory kan weglopen. De naam die als bijlage wordt doorgegeven, wordt niet als pad vertrouwd: deze gaat door PLSanitizeAttachmentName, dat elk directorycomponent strippt, de lege string en de speciale namen . en .. afwijst, en elk teken dat Windows als illegaal in een bestandsnaam behandelt, samen met elk controleteken, vervangt door een underscore. Voer ..\quarter:report.pdf in, deels padtraversal en deels illegale dubbele punt, en wat op schijf terechtkomt is quarter_report.pdf: alles tot en met de laatste padscheider wordt weggegooid, en de dubbele punt wordt een underscore omdat deze niet in een Windows-bestandsnaam mag voorkomen

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;

Een toegewijde directory per aanroep is niet alleen netheid. SendDocumentByMail verwijdert het tijdelijke bestand en zijn directory in een finally-blok nadat het bericht is verzonden, met gebruik van precies hetzelfde pad waarnaar het schreef, dus een bijlagenaam die die code niet-gesaneerd zou bereiken, zou niet alleen de schrijfbewerking verkeerd plaatsen. Datzelfde niet-gesaneerde pad zou dan een opruimstap bereiken die DeleteFile aanroept zonder verdere vragen te stellen, en op een gedeelde temp-map zouden twee gelijktijdige verzendingen ook stilzwijgend elkaars bijlage onder dezelfde naam kunnen overschrijven voordat een van beide leveringen voltooid is. De naam saneren sluit het traversal-geval, en de GUID-directory per aanroep sluit het botsingsgeval, en geen van beide alleen zou genoeg zijn geweest

De COM-levensduur laten overeenkomen met de threadlevensduur in een workerpool

De betrouwbaarste fix voor apartment-threading-storingen in een batchmailer is stoppen met elke SendDocumentByMail-aanroep als zijn eigen geïsoleerde COM-levensduur te behandelen, en in plaats daarvan COM één keer per workerthread initialiseren, voor de levensduur van die thread. Een worker die CoInitializeEx(nil, COINIT_APARTMENTTHREADED) aanroept bij het starten, dat apartment behoudt voor elke SendDocumentByMail-aanroep die het doet, en CoUninitialize precies één keer aanroept bij het afsluiten, zal nooit RPC_E_CHANGED_MODE zien van zijn eigen mailverzendingen, omdat niets anders op die thread de kans krijgt om COM eerst in een conflicterende modus te initialiseren. Elke individuele SendDocumentByMail-aanroep draait onder dit patroon nog steeds zijn eigen paar CoInitializeEx en CoUninitialize intern, en dat is onschadelijk: met het apartment al opgezet door de workerthread, ziet elk van die interne aanroepen nu S_FALSE, verhoogt en verlaagt dezelfde referentietelling, en laat het eigen COM-apartment van de workerthread ongemoeid

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;

Storingen diagnosticeren en testen zonder een echte mailbox

GetLastMailError is de andere helft van deze API die het waard is om vanaf dag één in logging in te bouwen, omdat de 1-of-0-retourwaarde alleen niet zegt of een mislukte verzending een COM-initialisatieprobleem was, een SMTP-authenticatie-afwijzing, of een ontbrekende bijlage. De eigenschap MailProvider is wat het hele pad testbaar maakt zonder een echte mailbox: wijs er een implementatie van IPDFlibMailProvider aan toe die verzoeken registreert in plaats van ze te verzenden, draai een batchtaak tegen die nepprovider in een CI-pipeline, en dezelfde SendDocumentByMail-aanroepplekken blijven ongewijzigd werken zodra MailProvider niet ingesteld blijft en PDFlibPas terugvalt op het ingebouwde CDO-transport in productie

Een batchtaak die afschriften mailt, stopt zelden bij het verzenden: dezelfde pipeline moet vaak de PDF valideren en ondertekenen voordat deze wordt verzonden, wat apart wordt behandeld in het artikel over de compliance- en ondertekeningsworkbench, aangezien preflight en handtekeningverificatie een andere zorg zijn dan maillevering, zelfs wanneer beide na elkaar draaien. Wanneer de gemailde documenten zelf de output zijn van een grote samenvoeg- of splitstaak in plaats van een enkele vers gebouwde PDF, behandelt de gids over direct-access voor grote PDF's die generatiestap. SendDocumentByMail en het hier beschreven mailprovidermodel maken deel uit van de standaard PDFlibPas PDF Developer Library voor Delphi en C++Builder, en de productpagina bevat de volledige API-referentie naast een proefdownload