Tehnički članak

Slanje PDF-ova putem CDO-a u Delphi-ju: COM niti

PDFlibPas, losLab biblioteka za razvoj PDF-ova za Delphi i C++Builder, šalje generisani PDF kao prilog e-pošte jednim ravnim API pozivom, SendDocumentByMail. Na Windows-u podrazumevani transport koristi CDO (Collaboration Data Objects), COM komponentu za poštu ugrađenu u operativni sistem, a detalj koji zaista ruši višestruke serijske poslove jeste inicijalizacija COM apartmana, a ne SMTP

Scenarijo iza ovog API-ja je običan i veoma čest: servis generiše seriju PDF izveštaja na kraju meseca, po jedan za svakog klijenta, i mora da pošalje svaki bez ručne intervencije. Kada se posao zbog protoka prebaci na bazen niti, deo slanja počinje da otkazuje COM greškom koja se nikada ne ponavlja kada isti kod radi u jednoj niti. SMTP server, PDF ni prilog nisu problem. Problem je ono što CoInitializeEx vrati u niti koju CDO nije očekivao, a PDFlibPas je napisan da taj slučaj obrađuje namerno, a ne slučajno

Šta SendDocumentByMail zaista radi unutar PDFlibPas-a

SendDocumentByMail je tanak orkestrator, a ne samostalan klijent za poštu. TPDFlib.SendDocumentByMail čuva trenutno učitani dokument u sopstveni privremeni PDF, pakuje SMTP podešavanja i tekst poruke u zapis TPDFlibMailRequest, prosleđuje taj zapis svemu što implementira IPDFlibMailProvider, a zatim briše privremenu datoteku kada se provajder vrati. Interfejs provajdera je stvarni klijent za poštu, a PDFlibPas isporučuje tačno jednu ugrađenu implementaciju: provajder zasnovan na CDO-u koji se kompajlira samo na Windows-u. Pozovite SendDocumentByMail bez prethodnog dodeljivanja svojstva MailProvider i PDFlibPas će automatski koristiti tu podrazumevanu implementaciju. Povratna vrednost je namerno uska: 1 znači prihvaćeno, a 0 sve ostalo, bilo da nedostaje obavezno polje, upis privremene datoteke nije uspeo ili je provajder odbio poruku, dok je stvarni razlog dostupan tek naknadno kroz 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;

Zašto CoInitializeEx vraća S_FALSE i da li je to greška

S_FALSE iz CoInitializeEx nije greška, a kod koji ga tako tumači prijavljuje greške na nitima na kojima se zapravo ništa loše nije dogodilo. CoInitializeEx vraća S_OK kada nit prvi put uspešno inicijalizuje COM, a S_FALSE kada je COM na toj niti već inicijalizovan kompatibilnim modelom konkurentnosti; u oba slučaja povećava se isti brojač referenci po niti, pa oba ishoda zahtevaju odgovarajući poziv CoUninitialize pre izlaska niti ili prelaska na nepovezan posao. I sam TPDFlib prati upravo taj obrazac: pravljenje instance TPDFlib već poziva CoInitialize i beleži da li duguje odgovarajući CoUninitialize, koristeći istu proveru S_OK-ili-S_FALSE. Kada SendDocumentByMail stigne do CDO provajdera i on ponovo pozove CoInitializeEx, COM je zato u uobičajenom slučaju već inicijalizovan na niti, pa provajder gotovo uvek vidi S_FALSE, a ne S_OK. Tretirati S_FALSE kao bilo šta osim uspeha nije redak rubni slučaj u ovoj biblioteci; to je uobičajeni put

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;

Zašto CoInitializeEx vraća RPC_E_CHANGED_MODE

RPC_E_CHANGED_MODE znači da je trenutna nit ranije inicijalizovala COM drugim modelom konkurentnosti od onog koji ovaj poziv traži, najčešće zato što je nit prethodno postala višestruka (MTA), a CDO sada zahteva semantiku jednonitnog apartmana (STA) kroz COINIT_APARTMENTTHREADED. Nit bira model apartmana jednom i taj model se ne može promeniti do kraja njenog života; ponovni pokušaj CoInitializeEx sa drugim zastavicama ne rešava neusaglašenost, a prethodni CoUninitialize bi srušio apartman od kog drugi kod na toj niti možda još zavisi. PDFlibPas tretira RPC_E_CHANGED_MODE kao uslov sa kojim treba raditi, a ne kao grešku za prijavu: preskače upareni CoUninitialize, jer poziv zapravo nije dobio referencu koju treba osloboditi, i nastavlja slanje u postojećem apartmanu

RPC_E_CHANGED_MODE pojavljuje se gotovo isključivo na ponovo korišćenim nitima: radnoj niti iz bazena niti, niti IIS-a ili hosta servisa, ili bilo kojoj niti na kojoj je raniji kod, kao što su ADO ili WMI, već pozvao CoInitializeEx sa COINIT_MULTITHREADED pre nego što je kod za poštu došao na red. Potpuno nova nit koja samo poziva SendDocumentByMail neće dospeti do ove putanje. Radna nit koju planer serijskih poslova ponovo koristi hiljadama puta dnevno i koja deli izvršavanje sa drugim COM poslovima hoće, i to povremeno, što je upravo obrazac zbog kog ljudi najpre proveravaju SMTP server, a tek zatim model niti

Kako sprečiti da prilog e-pošte završi u pogrešnom direktorijumu

PDFlibPas upisuje svaki odlazni prilog u novi direktorijum nazvan prema GUID-u koji generiše pri svakom pozivu SendDocumentByMail, upravo da se istovremena slanja nikada ne sudare zbog istog imena datoteke i da ime priloga ne može izaći iz tog direktorijuma. Ime prosleđeno kao prilog ne tretira se kao putanja od poverenja: prolazi kroz PLSanitizeAttachmentName, koji uklanja komponentu direktorijuma, odbacuje prazan niz i posebna imena . i .., a svaki znak koji Windows smatra nedozvoljenim u imenu datoteke, kao i svaki kontrolni znak, zamenjuje donjom crtom. Ako mu prosledite ..\quarter:report.pdf, delom pokušaj prolaska kroz direktorijume, a delom nedozvoljenu dvotačku, na disk stiže quarter_report.pdf: sve do poslednjeg razdvajača putanje se odbacuje, a dvotačka postaje donja crta jer ne može da se pojavi u imenu Windows datoteke

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;

Poseban direktorijum za svaki poziv nije samo pitanje urednosti. SendDocumentByMail briše privremenu datoteku i uklanja njen direktorijum u bloku finally nakon slanja poruke, koristeći potpuno istu putanju na koju je upisivao, pa bi nesanitizovano ime priloga dospelo ne samo do pogrešnog mesta za upis. Ista nesanitizovana putanja zatim bi stigla do koraka čišćenja koji poziva DeleteFile bez dodatnih provera, a u zajedničkom privremenom direktorijumu dva istovremena slanja mogla bi i tiho da prepišu prilog jedan drugom pod istim imenom pre nego što se bilo koja isporuka završi. Sanitizacija imena zatvara slučaj prolaska kroz direktorijume, a GUID direktorijum po pozivu zatvara slučaj sudara, i nijedna od te dve mere sama ne bi bila dovoljna

Usklađivanje životnog veka COM-a sa životnim vekom niti u bazenu radnika

Najpouzdanije rešenje za greške u radu apartmana pri slanju serijske pošte jeste da se svaki poziv SendDocumentByMail više ne tretira kao zaseban COM životni vek, već da se COM inicijalizuje jednom po radnoj niti i zadrži tokom njenog života. Radna nit koja pri pokretanju pozove CoInitializeEx(nil, COINIT_APARTMENTTHREADED), zadrži taj apartman za svaki svoj poziv SendDocumentByMail i pri izlasku pozove CoUninitialize tačno jednom nikada neće dobiti RPC_E_CHANGED_MODE iz sopstvenih slanja pošte, jer ništa drugo na toj niti neće prvo dobiti priliku da inicijalizuje COM u sukobljenom režimu. Svaki pojedinačni poziv SendDocumentByMail i dalje interno izvršava sopstveni par CoInitializeEx i CoUninitialize, što je bezopasno: pošto je apartman već uspostavila radna nit, svaki od tih internih poziva sada vidi S_FALSE, povećava i smanjuje isti brojač referenci i ostavlja sopstveni COM apartman radne niti netaknutim

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;

Dijagnostikovanje grešaka i testiranje bez aktivnog sandučeta

GetLastMailError je druga polovina ovog API-ja koju vredi uključiti u evidentiranje od prvog dana, jer sama povratna vrednost 1 ili 0 ne govori da li je neuspešno slanje izazvao problem sa inicijalizacijom COM-a, odbijena SMTP autentifikacija ili nedostajući prilog. Svojstvo MailProvider omogućava testiranje cele putanje bez stvarnog sandučeta: dodelite mu implementaciju IPDFlibMailProvider koja beleži zahteve umesto da ih šalje, pokrenite serijski posao protiv tog lažnog provajdera u CI procesu i ista mesta poziva SendDocumentByMail nastaviće da rade nepromenjeno kada MailProvider ostane nedodeljen, a PDFlibPas se u produkciji vrati na ugrađeni CDO transport

Serijski posao koji šalje izveštaje e-poštom retko se završava samim slanjem: isti proces često mora da proveri i potpiše PDF pre slanja, što je zasebno obrađeno u članku o usklađenosti i radnom okruženju za potpisivanje, jer su predprovera i verifikacija potpisa drugačija briga od isporuke pošte čak i kada se izvršavaju jedna za drugom. Kada su dokumenti koji se šalju rezultat velikog posla spajanja ili razdvajanja, a ne jedan novoizrađeni PDF, vodič za direktan pristup velikim PDF-ovima pokriva taj korak generisanja. SendDocumentByMail i ovde opisani model provajdera pošte deo su standardne PDFlibPas PDF Developer Library za Delphi i C++Builder, a stranica proizvoda sadrži potpunu API referencu i probnu verziju