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