Teknisk artikel

Att skicka PDF via CDO i Delphi: apartment-trådningsfällor

PDFlibPas, losLabs PDF-utvecklarbibliotek för Delphi och C++Builder, skickar en genererad PDF som en e-postbilaga genom ett enda platt API-anrop, SendDocumentByMail. På Windows använder standardtransporten CDO (Collaboration Data Objects), COM-e-postkomponenten inbyggd i operativsystemet, och detaljen som faktiskt slår sönder flertrådade batchjobb är COM-apartmentinitiering, inte SMTP

Scenariot bakom det här API:et är oglamoröst och extremt vanligt: en tjänst rendrerar en batch av månadsslutkontoutdrag-PDF:er, en per kund, och måste posta ut var och en utan en person i loopen. Skjut det jobbet till en trådpool för genomströmning, och en bråkdel av utskicken börjar misslyckas med ett COM-fel som aldrig reproduceras när samma kod körs på en enda tråd. Inget är fel med SMTP-servern, PDF:en, eller bilagan. Problemet är vad CoInitializeEx returnerar på en tråd CDO inte förväntade sig, och PDFlibPas är skrivet för att hantera det fallet medvetet snarare än av misstag

Vad SendDocumentByMail faktiskt gör inuti PDFlibPas

SendDocumentByMail är en tunn orkestrerare, ingen e-postklient i egen rätt. TPDFlib.SendDocumentByMail sparar det för närvarande laddade dokumentet till sin egen temporära PDF, paketerar SMTP-inställningarna och meddelandetexten in i en TPDFlibMailRequest-post, lämnar den posten till vad som än implementerar IPDFlibMailProvider, och raderar den temporära filen igen när leverantören returnerar. Leverantörsgränssnittet är den faktiska e-postklienten, och PDFlibPas levererar exakt en inbyggd implementation: en CDO-baserad leverantör som bara kompileras på Windows. Anropa SendDocumentByMail utan att tilldela MailProvider-egenskapen först, och PDFlibPas faller tillbaka till den standarden automatiskt. Returvärdet förblir medvetet smalt genomgående: 1 för accepterad, 0 för allt annat, oavsett om det är ett saknat obligatoriskt fält, ett skrivfel för den temporära filen, eller att leverantören avvisar meddelandet, med den faktiska anledningen bara tillgänglig från GetLastMailError efteråt

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;

Varför returnerar CoInitializeEx S_FALSE, och är det ett fel?

S_FALSE från CoInitializeEx är inget fel, och kod som behandlar det som ett rapporterar fel på trådar där inget faktiskt gick fel. CoInitializeEx returnerar S_OK första gången en tråd framgångsrikt initierar COM, och den returnerar S_FALSE när den tråden redan hade COM initierat med en kompatibel samtidighetsmodell, ökar samma per-tråd-referensräkning i endera fallet, så båda utfallen behöver ett matchande CoUninitialize-anrop innan tråden avslutas eller går vidare till orelaterat arbete. TPDFlib självt följer precis det här mönstret: att konstruera en TPDFlib-instans anropar redan CoInitialize och registrerar om ett matchande CoUninitialize är skyldigt, med hjälp av precis samma S_OK-eller-S_FALSE-kontroll. När SendDocumentByMail når sin CDO-leverantör och den leverantören anropar CoInitializeEx igen är COM därför redan initierat på tråden i det vanliga fallet, så leverantören observerar nästan alltid S_FALSE snarare än S_OK. Att behandla S_FALSE som något annat än framgång är inget sällsynt specialfall i det här biblioteket; det är den vanliga vägen

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;

Varför returnerar CoInitializeEx RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE betyder att den aktuella tråden initierade COM tidigare under en annan samtidighetsmodell än den detta anrop begär, vanligtvis eftersom tråden tidigare blev flertrådad (MTA) och CDO nu ber om semantik för enkeltrådsapartment (STA) genom COINIT_APARTMENTTHREADED. En tråd väljer sin apartmentmodell en gång, och inget kan ändra den modellen för resten av trådens livstid; att försöka igen med CoInitializeEx med olika flaggor fixar inte missmatchningen, och att anropa CoUninitialize först skulle riva ner ett apartment annan kod på den tråden fortfarande kan bero av. PDFlibPas behandlar RPC_E_CHANGED_MODE som ett tillstånd att jobba med snarare än ett fel att rapportera: den hoppar över det parade CoUninitialize-anropet, eftersom anropet aldrig faktiskt förvärvade en referens att släppa, och låter utskicket fortsätta på det befintliga apartmentet

RPC_E_CHANGED_MODE dyker upp nästan uteslutande på återanvända trådar: en trådpoolsarbetare, en IIS- eller tjänstevärdstråd, eller vilken tråd som helst där tidigare kod som ADO eller WMI redan anropat CoInitializeEx med COINIT_MULTITHREADED innan e-postkoden kom i närheten av den. En helt ny tråd som inte gör annat än att anropa SendDocumentByMail kommer inte träffa den här vägen. En arbetartråd återvunnen tusentals gånger om dagen av en batchschemaläggare, och delad med annat COM-baserat arbete, kommer absolut göra det, och den kommer göra det intermittent, vilket är precis mönstret som får folk att titta på SMTP-servern först och trådningsmodellen sedan

Att hålla en e-postbilaga borta från fel katalog

PDFlibPas skriver varje utgående bilaga in i en färsk katalog namngiven efter en GUID den genererar vid varje SendDocumentByMail-anrop, specifikt så att samtidiga utskick aldrig kan krocka på samma filnamn och så att ett bilagenamn inte kan gå ut ur den katalogen. Namnet som skickas in som bilagan litas inte på som en sökväg: det går genom PLSanitizeAttachmentName, som strippar varje katalogkomponent, avvisar den tomma strängen och de speciella .- och ..-namnen, och ersätter varje tecken Windows behandlar som otillåtet i ett filnamn, tillsammans med varje kontrolltecken, med ett understreck. Mata den ..\quarter:report.pdf, dels katalogövergång och dels otillåten kolon, och vad som når disk är quarter_report.pdf: allt fram till den sista sökvägsavgränsaren kastas bort, och kolonen blir ett understreck eftersom det inte kan förekomma i ett Windows-filnamn

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 dedikerad katalog per anrop är inte bara städning. SendDocumentByMail raderar den temporära filen och tar bort dess katalog i ett finally-block efter att meddelandet skickats, med hjälp av precis samma sökväg den skrev till, så ett bilagenamn som nått den koden osanerat skulle inte bara felplacera skrivningen. Samma osanerade sökväg skulle sedan nå ett städsteg som anropar DeleteFile utan att fråga fler frågor, och på en delad temp-mapp kunde två samtidiga utskick också tyst skriva över varandras bilaga under samma namn innan endera leveransen slutförs. Att sanera namnet stänger övergångsfallet, och per-anrops-GUID-katalogen stänger krockfallet, och ingendera ensam skulle ha räckt

Att matcha COM-livstid mot trådlivstid i en arbetarpool

Den mest tillförlitliga fixen för apartment-trådningsfel i en batch-e-postavsändare är att sluta behandla varje SendDocumentByMail-anrop som sin egen isolerade COM-livstid, och istället initiera COM en gång per arbetartråd, för den trådens hela liv. En arbetare som anropar CoInitializeEx(nil, COINIT_APARTMENTTHREADED) när den startar, behåller det apartmentet för varje SendDocumentByMail-anrop den gör, och anropar CoUninitialize exakt en gång när den avslutas kommer aldrig se RPC_E_CHANGED_MODE från sina egna e-postutskick, eftersom inget annat på den tråden får chansen att initiera COM i ett motstridigt läge först. Varje enskilt SendDocumentByMail-anrop kör fortfarande sitt eget CoInitializeEx- och CoUninitialize-par internt under det här mönstret, och det är ofarligt: med apartmentet redan etablerat av arbetartråden ser vart och ett av de interna anropen nu S_FALSE, ökar och minskar samma referensräkning, och lämnar arbetartrådens eget COM-apartment orö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;

Att diagnostisera fel och testa utan en levande brevlåda

GetLastMailError är den andra hälften av det här API:et värd att bygga in i loggning från dag ett, eftersom 1-eller-0-returvärdet ensamt inte säger om ett misslyckat utskick var ett COM-initieringsproblem, ett SMTP-autentiseringsavslag, eller en saknad bilaga. MailProvider-egenskapen är vad som gör hela vägen testbar utan en riktig brevlåda: tilldela den en IPDFlibMailProvider-implementation som registrerar förfrågningar istället för att skicka dem, kör ett batchjobb mot den falska leverantören i en CI-pipeline, och samma SendDocumentByMail-anropsställen fortsätter fungera oförändrade när MailProvider lämnas otilldelad och PDFlibPas faller tillbaka till den inbyggda CDO-transporten i produktion

Ett batchjobb som e-postar kontoutdrag stannar sällan vid att skicka: samma pipeline behöver ofta validera och signera PDF:en innan den går ut, vilket täcks separat i artikeln om konformitets- och signeringsarbetsbänken, eftersom förkontroll och signaturverifiering är en annan angelägenhet än e-postleverans även när båda körs efter varandra. När dokumenten som e-postas själva är utdatan av ett stort sammanfognings- eller delningsjobb snarare än en enda nybyggd PDF, täcker guiden för direktåtkomst till stora PDF:er det genereringssteget. SendDocumentByMail och e-postleverantörsmodellen som beskrivs här är del av standard-PDFlibPas PDF-utvecklarbiblioteket för Delphi och C++Builder, och produktsidan bär den fullständiga API-referensen tillsammans med en provnedladdning