PDFlibPas, die losLab-PDF-Entwicklerbibliothek für Delphi und C++Builder, versendet ein erzeugtes PDF als E-Mail-Anhang über einen einzigen flachen API-Aufruf, SendDocumentByMail. Unter Windows verwendet der Standard-Transport CDO (Collaboration Data Objects), die in das Betriebssystem eingebaute COM-Mail-Komponente, und das Detail, das tatsächlich mehr-threadige Batch-Jobs bricht, ist COM-Apartment-Initialisierung, nicht SMTP
Das Szenario hinter dieser API ist unglamourös und extrem häufig: Ein Dienst rendert eine Charge von Monatsabschluss-PDFs, eine pro Kunde, und muss jede davon versenden, ohne einen Menschen in der Schleife. Schiebt man diesen Job für Durchsatz auf einen Thread-Pool, beginnt ein Bruchteil der Sendevorgänge mit einem COM-Fehler zu scheitern, der sich nie reproduziert, wenn derselbe Code auf einem einzelnen Thread läuft. Nichts stimmt mit dem SMTP-Server, dem PDF oder dem Anhang nicht. Das Problem ist, was CoInitializeEx auf einem Thread zurückgibt, den CDO nicht erwartet hat, und PDFlibPas ist geschrieben, um diesen Fall bewusst zu behandeln, nicht zufällig
Was SendDocumentByMail innerhalb von PDFlibPas tatsächlich tut
SendDocumentByMail ist ein dünner Orchestrator, kein Mail-Client für sich. TPDFlib.SendDocumentByMail speichert das aktuell geladene Dokument in seine eigene temporäre PDF, verpackt die SMTP-Einstellungen und Nachrichtentext in einen TPDFlibMailRequest-Datensatz, übergibt diesen Datensatz an was auch immer IPDFlibMailProvider implementiert, und löscht die temporäre Datei wieder, sobald der Provider zurückkehrt. Die Provider-Schnittstelle ist der eigentliche Mail-Client, und PDFlibPas liefert genau eine eingebaute Implementierung: einen CDO-basierten Provider, der nur unter Windows kompiliert. Rufen Sie SendDocumentByMail auf, ohne zuvor die MailProvider-Eigenschaft zuzuweisen, fällt PDFlibPas automatisch auf diesen Standard zurück. Der Rückgabewert bleibt durchgehend bewusst eng gefasst: 1 für akzeptiert, 0 für alles andere, egal ob es ein fehlendes Pflichtfeld ist, ein Schreibfehler bei der temporären Datei, oder der Provider, der die Nachricht ablehnt, mit dem tatsächlichen Grund erst danach nur über GetLastMailError verfügbar
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;
Warum gibt CoInitializeEx S_FALSE zurück, und ist das ein Fehlschlag?
S_FALSE von CoInitializeEx ist kein Fehlschlag, und Code, der es als solchen behandelt, meldet Fehlschläge auf Threads, auf denen tatsächlich nichts schiefgegangen ist. CoInitializeEx gibt S_OK zurück, wenn ein Thread COM zum ersten Mal erfolgreich initialisiert, und es gibt S_FALSE zurück, wenn dieser Thread COM bereits mit einem kompatiblen Nebenläufigkeitsmodell initialisiert hatte, wobei beide Fälle denselben Pro-Thread-Referenzzähler erhöhen, sodass beide Ergebnisse einen passenden CoUninitialize-Aufruf brauchen, bevor der Thread beendet wird oder zu unabhängiger Arbeit übergeht. TPDFlib selbst folgt genau diesem Muster: Das Konstruieren einer TPDFlib-Instanz ruft bereits CoInitialize auf und protokolliert, ob ein passendes CoUninitialize geschuldet wird, unter Verwendung derselben S_OK-oder-S_FALSE-Prüfung. Wenn SendDocumentByMail seinen CDO-Provider erreicht und dieser Provider CoInitializeEx erneut aufruft, ist COM auf dem Thread daher im gewöhnlichen Fall bereits initialisiert, sodass der Provider fast immer S_FALSE statt S_OK beobachtet. S_FALSE als irgendetwas anderes als Erfolg zu behandeln ist in dieser Bibliothek kein seltener Randfall; es ist der übliche Pfad
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;
Warum gibt CoInitializeEx RPC_E_CHANGED_MODE zurück?
RPC_E_CHANGED_MODE bedeutet, der aktuelle Thread hat COM früher unter einem anderen Nebenläufigkeitsmodell initialisiert als dem, um das dieser Aufruf gerade bittet, typischerweise weil der Thread zuvor auf Multi-Threaded (MTA) ging und CDO jetzt über COINIT_APARTMENTTHREADED nach Single-Threaded-Apartment-Semantik (STA) fragt. Ein Thread wählt sein Apartment-Modell einmal, und nichts kann dieses Modell für den Rest der Lebensdauer des Threads ändern; ein erneuter Versuch von CoInitializeEx mit anderen Flags behebt die Diskrepanz nicht, und zuerst CoUninitialize aufzurufen würde ein Apartment abbauen, von dem anderer Code auf diesem Thread möglicherweise noch abhängt. PDFlibPas behandelt RPC_E_CHANGED_MODE als eine Bedingung, mit der es arbeiten muss, nicht als Fehler zum Melden: Es überspringt das gepaarte CoUninitialize, da der Aufruf nie tatsächlich eine Referenz erworben hat, die freizugeben wäre, und lässt den Sendevorgang auf dem bestehenden Apartment weiterlaufen
RPC_E_CHANGED_MODE zeigt sich fast ausschließlich auf wiederverwendeten Threads: einem Thread-Pool-Worker, einem IIS- oder Service-Host-Thread, oder jedem Thread, auf dem früherer Code wie ADO oder WMI bereits CoInitializeEx mit COINIT_MULTITHREADED aufgerufen hat, bevor der Mail-Code auch nur in seine Nähe kam. Ein brandneuer Thread, der nichts tut außer SendDocumentByMail aufzurufen, trifft diesen Pfad nicht. Ein von einem Batch-Scheduler tausendfach am Tag recycelter Worker-Thread, geteilt mit anderer COM-basierter Arbeit, wird ihn absolut treffen, und zwar unregelmäßig, genau das Muster, das Leute zuerst auf den SMTP-Server schauen lässt und erst zweitens auf das Threading-Modell
Einen Mail-Anhang aus dem falschen Verzeichnis heraushalten
PDFlibPas schreibt jeden ausgehenden Anhang in ein frisches, nach einer bei jedem SendDocumentByMail-Aufruf erzeugten GUID benanntes Verzeichnis, speziell damit gleichzeitige Sendevorgänge nie über denselben Dateinamen kollidieren können und ein Anhangsname nicht aus diesem Verzeichnis herauswandern kann. Der als Anhang übergebene Name wird nicht als Pfad vertraut: Er läuft durch PLSanitizeAttachmentName, das jede Verzeichniskomponente entfernt, den leeren String und die Spezialnamen . und .. ablehnt, und jedes Zeichen, das Windows in einem Dateinamen als unzulässig behandelt, zusammen mit jedem Steuerzeichen, durch einen Unterstrich ersetzt. Füttern Sie es mit ..\quarter:report.pdf, teils Verzeichnis-Traversal, teils unzulässiger Doppelpunkt, kommt quarter_report.pdf auf der Festplatte an: Alles bis zum letzten Pfadtrenner wird verworfen, und der Doppelpunkt wird zu einem Unterstrich, weil er in einem Windows-Dateinamen nicht vorkommen darf
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;
Ein dediziertes Pro-Aufruf-Verzeichnis ist nicht nur Ordentlichkeit. SendDocumentByMail löscht die temporäre Datei und entfernt ihr Verzeichnis in einem finally-Block, nachdem die Nachricht gesendet wurde, unter Verwendung genau desselben Pfads, in den es geschrieben hat, sodass ein unbereinigt in diesen Code gelangter Anhangsname nicht nur den Schreibvorgang verlagern würde. Derselbe unbereinigte Pfad würde dann einen Bereinigungsschritt erreichen, der DeleteFile aufruft, ohne weitere Fragen zu stellen, und in einem gemeinsam genutzten Temp-Ordner könnten sich zwei gleichzeitige Sendevorgänge auch still gegenseitig den Anhang unter demselben Namen überschreiben, bevor einer der beiden Zustellvorgänge fertig ist. Den Namen zu bereinigen schließt den Traversal-Fall, und das Pro-Aufruf-GUID-Verzeichnis schließt den Kollisionsfall, und keines der beiden allein wäre ausreichend gewesen
COM-Lebensdauer an Thread-Lebensdauer in einem Worker-Pool anpassen
Die zuverlässigste Lösung für Apartment-Threading-Fehlschläge in einem Batch-Mailer ist, aufzuhören, jeden SendDocumentByMail-Aufruf als seine eigene isolierte COM-Lebensdauer zu behandeln, und stattdessen COM einmal pro Worker-Thread zu initialisieren, für die Lebensdauer dieses Threads. Ein Worker, der beim Start CoInitializeEx(nil, COINIT_APARTMENTTHREADED) aufruft, dieses Apartment für jeden SendDocumentByMail-Aufruf behält, den er tätigt, und beim Beenden genau einmal CoUninitialize aufruft, wird von seinen eigenen Mail-Sendevorgängen nie RPC_E_CHANGED_MODE sehen, weil nichts anderes auf diesem Thread die Chance bekommt, COM zuerst in einem widersprüchlichen Modus zu initialisieren. Jeder einzelne SendDocumentByMail-Aufruf führt unter diesem Muster intern weiterhin sein eigenes CoInitializeEx-und-CoUninitialize-Paar aus, und das ist harmlos: Mit dem bereits vom Worker-Thread etablierten Apartment sieht jeder dieser internen Aufrufe jetzt S_FALSE, erhöht und verringert denselben Referenzzähler, und lässt das eigene COM-Apartment des Worker-Threads unangetastet
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;
Fehlschläge diagnostizieren und testen ohne echtes Postfach
GetLastMailError ist die andere Hälfte dieser API, die es sich lohnt, von Tag eins an ins Logging einzubauen, denn der bloße 1-oder-0-Rückgabewert sagt nicht, ob ein fehlgeschlagener Sendevorgang ein COM-Initialisierungsproblem war, eine SMTP-Authentifizierungsablehnung, oder ein fehlender Anhang. Die Eigenschaft MailProvider ist das, was den gesamten Pfad testbar macht, ohne ein echtes Postfach: Weisen Sie ihr eine IPDFlibMailProvider-Implementierung zu, die Anfragen protokolliert statt sie zu senden, führen Sie einen Batch-Job gegen diesen Fake-Provider in einer CI-Pipeline aus, und dieselben SendDocumentByMail-Aufrufstellen funktionieren unverändert weiter, sobald MailProvider ungesetzt gelassen wird und PDFlibPas in der Produktion auf den eingebauten CDO-Transport zurückfällt
Ein Batch-Job, der Kontoauszüge per Mail versendet, hört selten beim Senden auf: Dieselbe Pipeline muss oft das PDF validieren und signieren, bevor es hinausgeht, was separat im Artikel zur Konformitäts- und Signier-Werkbank behandelt wird, da Preflight und Signaturprüfung eine andere Angelegenheit als Mail-Zustellung sind, selbst wenn beide nacheinander laufen. Wenn die versendeten Dokumente selbst die Ausgabe eines großen Zusammenführungs- oder Aufteilungsjobs sind, statt eines einzelnen frisch gebauten PDFs, behandelt der Leitfaden zum Direct-Access bei großen PDFs diesen Erzeugungsschritt. SendDocumentByMail und das hier beschriebene Mail-Provider-Modell sind Teil der Standard-PDFlibPas-PDF-Entwicklerbibliothek für Delphi und C++Builder, und die Produktseite trägt die vollständige API-Referenz zusammen mit einem Test-Download