Technical Article

Emailing PDFs via CDO in Delphi: Apartment-Threading Gotchas

PDFlibPas, the losLab PDF Developer Library for Delphi and C++Builder, sends a generated PDF as an email attachment through one flat API call, SendDocumentByMail. On Windows the default transport uses CDO (Collaboration Data Objects), the COM mail component built into the operating system, and the detail that actually breaks multi-threaded batch jobs is COM apartment initialization, not SMTP

The scenario behind this API is unglamorous and extremely common: a service renders a batch of month-end statement PDFs, one per customer, and has to mail each one out without a person in the loop. Push that job onto a thread pool for throughput, and a fraction of the sends start failing with a COM error that never reproduces when the same code runs on a single thread. Nothing is wrong with the SMTP server, the PDF, or the attachment. The problem is what CoInitializeEx returns on a thread CDO did not expect, and PDFlibPas is written to handle that case deliberately rather than by accident

What SendDocumentByMail actually does inside PDFlibPas

SendDocumentByMail is a thin orchestrator, not a mail client in its own right. TPDFlib.SendDocumentByMail saves the currently loaded document to its own temporary PDF, packages the SMTP settings and message text into a TPDFlibMailRequest record, hands that record to whatever implements IPDFlibMailProvider, and deletes the temporary file again once the provider returns. The provider interface is the actual mail client, and PDFlibPas ships exactly one built-in implementation: a CDO-based provider that only compiles on Windows. Call SendDocumentByMail without assigning the MailProvider property first, and PDFlibPas falls back to that default automatically. The return value stays deliberately narrow throughout: 1 for accepted, 0 for anything else, whether that is a missing required field, a temporary-file write failure, or the provider rejecting the message, with the actual reason available only from GetLastMailError afterward

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;

Why does CoInitializeEx return S_FALSE, and is that a failure?

S_FALSE from CoInitializeEx is not a failure, and code that treats it as one reports failures on threads where nothing actually went wrong. CoInitializeEx returns S_OK the first time a thread successfully initializes COM, and it returns S_FALSE when that thread already had COM initialized with a compatible concurrency model, incrementing the same per-thread reference count either way, so both outcomes need a matching CoUninitialize call before the thread exits or moves on to unrelated work. TPDFlib itself follows this exact pattern: constructing a TPDFlib instance already calls CoInitialize and records whether a matching CoUninitialize is owed, using the identical S_OK-or-S_FALSE check. By the time SendDocumentByMail reaches its CDO provider and that provider calls CoInitializeEx again, COM is therefore already initialized on the thread in the ordinary case, so the provider almost always observes S_FALSE rather than S_OK. Treating S_FALSE as anything other than success is not a rare edge case in this library; it is the common path

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;

Why does CoInitializeEx return RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE means the current thread initialized COM earlier under a different concurrency model than the one this call is requesting, typically because the thread previously went multi-threaded (MTA) and CDO is now asking for single-threaded apartment (STA) semantics through COINIT_APARTMENTTHREADED. A thread picks its apartment model once, and nothing can change that model for the rest of the thread's life; retrying CoInitializeEx with different flags does not fix the mismatch, and calling CoUninitialize first would tear down an apartment other code on that thread may still depend on. PDFlibPas treats RPC_E_CHANGED_MODE as a condition to work with rather than an error to report: it skips the paired CoUninitialize, since the call never actually acquired a reference to release, and lets the send continue on the existing apartment

RPC_E_CHANGED_MODE shows up almost exclusively on reused threads: a thread-pool worker, an IIS or service-host thread, or any thread where earlier code such as ADO or WMI already called CoInitializeEx with COINIT_MULTITHREADED before the mail code got anywhere near it. A brand-new thread that does nothing but call SendDocumentByMail will not hit this path. A worker thread recycled thousands of times a day by a batch scheduler, and shared with other COM-based work, absolutely will, and it will do so intermittently, which is exactly the pattern that sends people looking at the SMTP server first and the threading model second

Keeping a mail attachment out of the wrong directory

PDFlibPas writes every outgoing attachment into a fresh directory named after a GUID it generates on every SendDocumentByMail call, specifically so concurrent sends can never collide on the same file name and so an attachment name cannot walk out of that directory. The name passed in as the attachment is not trusted as a path: it goes through PLSanitizeAttachmentName, which strips any directory component, rejects the empty string and the special . and .. names, and replaces every character Windows treats as illegal in a file name, along with any control character, with an underscore. Feed it ..\quarter:report.pdf, part directory traversal and part illegal colon, and what reaches disk is quarter_report.pdf: everything up to the last path separator is discarded, and the colon becomes an underscore because it cannot appear in a Windows file name

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;

A dedicated per-call directory is not just tidiness. SendDocumentByMail deletes the temporary file and removes its directory in a finally block after the message is sent, using the very same path it wrote to, so an attachment name that reached that code unsanitized would not just misplace the write. The same unsanitized path would then reach a cleanup step that calls DeleteFile without asking further questions, and on a shared temp folder, two concurrent sends could also silently overwrite each other's attachment under the same name before either delivery finishes. Sanitizing the name closes the traversal case, and the per-call GUID directory closes the collision case, and neither one alone would have been enough

Matching COM lifetime to thread lifetime in a worker pool

The most reliable fix for apartment-threading failures in a batch mailer is to stop treating every SendDocumentByMail call as its own isolated COM lifetime, and instead initialize COM once per worker thread, for the life of that thread. A worker that calls CoInitializeEx(nil, COINIT_APARTMENTTHREADED) when it starts, keeps that apartment for every SendDocumentByMail call it makes, and calls CoUninitialize exactly once when it exits will never see RPC_E_CHANGED_MODE from its own mail sends, because nothing else on that thread gets the chance to initialize COM in a conflicting mode first. Each individual SendDocumentByMail call still runs its own CoInitializeEx and CoUninitialize pair internally under this pattern, and that is harmless: with the apartment already established by the worker thread, every one of those internal calls now sees S_FALSE, increments and decrements the same reference count, and leaves the worker thread's own COM apartment untouched

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;

Diagnosing failures and testing without a live mailbox

GetLastMailError is the other half of this API worth building into logging from day one, because the 1-or-0 return value alone does not say whether a failed send was a COM initialization problem, an SMTP authentication rejection, or a missing attachment. The MailProvider property is what makes the whole path testable without a real mailbox: assign it an IPDFlibMailProvider implementation that records requests instead of sending them, run a batch job against that fake provider in a CI pipeline, and the same SendDocumentByMail call sites keep working unchanged once MailProvider is left unset and PDFlibPas falls back to the built-in CDO transport in production

A batch job that emails statements rarely stops at sending: the same pipeline often needs to validate and sign the PDF before it goes out, which is covered separately in the compliance and signing workbench article, since preflight and signature verification are a different concern from mail delivery even when both run back to back. When the documents being mailed are themselves the output of a large merge or split job rather than a single freshly built PDF, the large-PDF direct-access guide covers that generation step. SendDocumentByMail and the mail provider model described here are part of the standard PDFlibPas PDF Developer Library for Delphi and C++Builder, and the product page carries the full API reference alongside a trial download