Articolo tecnico

Inviare PDF via Email tramite CDO in Delphi: Insidie dell'Apartment-Threading

PDFlibPas, la losLab PDF Developer Library per Delphi e C++Builder, invia un PDF generato come allegato email tramite un'unica chiamata API piatta, SendDocumentByMail. Su Windows il trasporto predefinito usa CDO (Collaboration Data Objects), il componente di posta COM integrato nel sistema operativo, e il dettaglio che effettivamente rompe i job batch multi-thread è l'inizializzazione degli apartment COM, non SMTP

Lo scenario dietro questa API è poco affascinante ed estremamente comune: un servizio renderizza un batch di PDF di estratto conto di fine mese, uno per cliente, e deve spedirli via email senza una persona nel ciclo. Metti quel job su un pool di thread per il throughput, e una frazione degli invii inizia a fallire con un errore COM che non si riproduce mai quando lo stesso codice gira su un singolo thread. Non c'è nulla di sbagliato nel server SMTP, nel PDF, o nell'allegato. Il problema è cosa restituisce CoInitializeEx su un thread che CDO non si aspettava, e PDFlibPas è scritto per gestire quel caso deliberatamente piuttosto che per caso

Cosa fa realmente SendDocumentByMail dentro PDFlibPas

SendDocumentByMail è un orchestratore sottile, non un client di posta a sé. TPDFlib.SendDocumentByMail salva il documento attualmente caricato nel proprio PDF temporaneo, impacchetta le impostazioni SMTP e il testo del messaggio in un record TPDFlibMailRequest, passa quel record a qualunque cosa implementi IPDFlibMailProvider, ed elimina di nuovo il file temporaneo una volta che il provider ritorna. L'interfaccia provider è il vero client di posta, e PDFlibPas distribuisce esattamente un'implementazione integrata: un provider basato su CDO che compila solo su Windows. Chiama SendDocumentByMail senza assegnare prima la proprietà MailProvider, e PDFlibPas ricade automaticamente su quello predefinito. Il valore di ritorno resta deliberatamente ristretto in tutto: 1 per accettato, 0 per qualsiasi altra cosa, che sia un campo obbligatorio mancante, un fallimento di scrittura del file temporaneo, o il provider che rifiuta il messaggio, con la vera ragione disponibile solo da GetLastMailError in seguito

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;

Perché CoInitializeEx restituisce S_FALSE, ed è un fallimento?

S_FALSE da CoInitializeEx non è un fallimento, e il codice che lo tratta come tale segnala fallimenti su thread dove in realtà non è andato storto nulla. CoInitializeEx restituisce S_OK la prima volta che un thread inizializza con successo COM, e restituisce S_FALSE quando quel thread aveva già COM inizializzato con un modello di concorrenza compatibile, incrementando comunque lo stesso conteggio di riferimenti per thread in entrambi i casi, quindi entrambi gli esiti richiedono una chiamata corrispondente a CoUninitialize prima che il thread esca o passi a lavoro non correlato. TPDFlib stessa segue esattamente questo schema: costruire un'istanza TPDFlib chiama già CoInitialize e registra se una CoUninitialize corrispondente è dovuta, usando lo stesso controllo S_OK-o-S_FALSE identico. Nel momento in cui SendDocumentByMail raggiunge il proprio provider CDO e quel provider chiama di nuovo CoInitializeEx, COM è quindi già inizializzato sul thread nel caso ordinario, quindi il provider osserva quasi sempre S_FALSE piuttosto che S_OK. Trattare S_FALSE come qualcosa di diverso dal successo non è un caso limite raro in questa libreria; è il percorso comune

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;

Perché CoInitializeEx restituisce RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE significa che il thread corrente ha inizializzato COM in precedenza sotto un modello di concorrenza diverso da quello che questa chiamata sta richiedendo, tipicamente perché il thread è precedentemente diventato multi-thread (MTA) e CDO ora richiede semantica ad apartment singolo (STA) tramite COINIT_APARTMENTTHREADED. Un thread sceglie il proprio modello di apartment una volta, e nulla può cambiare quel modello per il resto della vita del thread; ritentare CoInitializeEx con flag diversi non risolve il disallineamento, e chiamare prima CoUninitialize smantellerebbe un apartment da cui altro codice su quel thread potrebbe ancora dipendere. PDFlibPas tratta RPC_E_CHANGED_MODE come una condizione con cui lavorare piuttosto che un errore da segnalare: salta la CoUninitialize abbinata, poiché la chiamata non ha mai effettivamente acquisito un riferimento da rilasciare, e lascia che l'invio prosegua sull'apartment esistente

RPC_E_CHANGED_MODE compare quasi esclusivamente su thread riutilizzati: un worker di thread pool, un thread IIS o service-host, o qualsiasi thread dove codice precedente come ADO o WMI abbia già chiamato CoInitializeEx con COINIT_MULTITHREADED prima che il codice di posta gli si avvicinasse. Un thread nuovo di zecca che non fa altro che chiamare SendDocumentByMail non incontrerà questo percorso. Un worker thread riciclato migliaia di volte al giorno da uno scheduler batch, e condiviso con altro lavoro basato su COM, lo incontrerà assolutamente, e lo farà in modo intermittente, che è esattamente lo schema che manda le persone a guardare prima il server SMTP e poi il modello di threading

Mantenere un allegato email fuori dalla directory sbagliata

PDFlibPas scrive ogni allegato in uscita in una directory nuova nominata secondo un GUID che genera a ogni chiamata SendDocumentByMail, specificamente cosicché invii concorrenti non possano mai collidere sullo stesso nome file e cosicché un nome allegato non possa uscire da quella directory. Il nome passato come allegato non è considerato attendibile come percorso: passa attraverso PLSanitizeAttachmentName, che elimina qualsiasi componente di directory, rifiuta la stringa vuota e i nomi speciali . e .., e sostituisce ogni carattere che Windows tratta come illegale in un nome file, insieme a qualsiasi carattere di controllo, con un underscore. Dagli in pasto ..\quarter:report.pdf, parte traversata di directory e parte due punti illegali, e ciò che raggiunge il disco è quarter_report.pdf: tutto fino all'ultimo separatore di percorso viene scartato, e i due punti diventano un underscore perché non possono comparire in un nome file 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;

Una directory dedicata per chiamata non è solo ordine. SendDocumentByMail elimina il file temporaneo e rimuove la propria directory in un blocco finally dopo che il messaggio è stato inviato, usando esattamente lo stesso percorso in cui ha scritto, quindi un nome allegato che raggiungesse quel codice non sanificato non farebbe solo posizionare male la scrittura. Lo stesso percorso non sanificato raggiungerebbe poi un passaggio di pulizia che chiama DeleteFile senza porre ulteriori domande, e su una cartella temp condivisa, due invii concorrenti potrebbero anche sovrascriversi silenziosamente a vicenda l'allegato sotto lo stesso nome prima che una delle due consegne finisca. Sanificare il nome chiude il caso di traversata, e la directory GUID per chiamata chiude il caso di collisione, e nessuno dei due da solo sarebbe stato sufficiente

Far corrispondere la vita di COM alla vita del thread in un pool di worker

La correzione più affidabile per i fallimenti di apartment-threading in un mailer batch è smettere di trattare ogni chiamata SendDocumentByMail come una propria vita COM isolata, e invece inizializzare COM una volta per worker thread, per l'intera vita di quel thread. Un worker che chiama CoInitializeEx(nil, COINIT_APARTMENTTHREADED) al proprio avvio, mantiene quell'apartment per ogni chiamata SendDocumentByMail che fa, e chiama CoUninitialize esattamente una volta quando esce non vedrà mai RPC_E_CHANGED_MODE dai propri invii di posta, perché nient'altro su quel thread ha la possibilità di inizializzare COM in una modalità in conflitto prima. Ogni singola chiamata SendDocumentByMail continua comunque a eseguire internamente la propria coppia CoInitializeEx e CoUninitialize sotto questo schema, ed è innocuo: con l'apartment già stabilito dal worker thread, ognuna di quelle chiamate interne ora vede S_FALSE, incrementa e decrementa lo stesso conteggio di riferimenti, e lascia intatto l'apartment COM proprio del worker thread

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;

Diagnosticare i fallimenti e testare senza una casella di posta live

GetLastMailError è l'altra metà di questa API che vale la pena integrare nel logging dal primo giorno, perché il solo valore di ritorno 1-o-0 non dice se un invio fallito sia stato un problema di inizializzazione COM, un rifiuto di autenticazione SMTP, o un allegato mancante. La proprietà MailProvider è ciò che rende testabile l'intero percorso senza una vera casella di posta: assegnale un'implementazione IPDFlibMailProvider che registra le richieste invece di inviarle, esegui un job batch contro quel provider finto in una pipeline CI, e gli stessi punti di chiamata SendDocumentByMail continuano a funzionare invariati una volta che MailProvider viene lasciato non impostato e PDFlibPas ricade sul trasporto CDO integrato in produzione

Un job batch che invia estratti conto via email raramente si ferma all'invio: la stessa pipeline spesso ha bisogno di validare e firmare il PDF prima che esca, trattato separatamente nell'articolo sul banco di lavoro di conformità e firma, poiché preflight e verifica della firma sono una questione diversa dalla consegna della posta anche quando entrambe girano una dopo l'altra. Quando i documenti spediti sono essi stessi l'output di un grande job di unione o divisione invece di un singolo PDF appena costruito, la guida direct-access per PDF di grandi dimensioni tratta quel passaggio di generazione. SendDocumentByMail e il modello provider di posta descritto qui fanno parte della PDFlibPas PDF Developer Library standard per Delphi e C++Builder, e la pagina del prodotto porta il riferimento API completo insieme a un download di prova