Artigo Técnico

Enviando PDFs por Email via CDO em Delphi: Armadilhas de Apartment Threading

O PDFlibPas, a losLab PDF Developer Library para Delphi e C++Builder, envia um PDF gerado como anexo de email por meio de uma única chamada de API plana, SendDocumentByMail. No Windows, o transporte padrão usa CDO (Collaboration Data Objects), o componente de correio COM embutido no sistema operacional, e o detalhe que de fato quebra jobs em lote multi-thread é a inicialização de apartment COM, não o SMTP

O cenário por trás dessa API é nada glamouroso e extremamente comum: um serviço renderiza um lote de PDFs de extrato de fim de mês, um por cliente, e precisa enviar cada um por email sem uma pessoa no meio. Empurre esse job para um pool de threads por vazão, e uma fração dos envios começa a falhar com um erro COM que nunca se reproduz quando o mesmo código roda em uma única thread. Nada está errado com o servidor SMTP, o PDF, ou o anexo. O problema é o que CoInitializeEx retorna em uma thread que o CDO não esperava, e o PDFlibPas é escrito para tratar esse caso deliberadamente, não por acidente

O que o SendDocumentByMail de fato faz dentro do PDFlibPas

SendDocumentByMail é um orquestrador fino, não um cliente de correio por conta própria. TPDFlib.SendDocumentByMail salva o documento atualmente carregado em seu próprio PDF temporário, empacota as configurações SMTP e o texto da mensagem em um registro TPDFlibMailRequest, entrega esse registro para o que quer que implemente IPDFlibMailProvider, e exclui o arquivo temporário novamente assim que o provedor retorna. A interface do provedor é o verdadeiro cliente de correio, e o PDFlibPas vem com exatamente uma implementação embutida: um provedor baseado em CDO que só compila no Windows. Chame SendDocumentByMail sem atribuir a propriedade MailProvider primeiro, e o PDFlibPas recai para esse padrão automaticamente. O valor de retorno permanece deliberadamente restrito ao longo de tudo: 1 para aceito, 0 para qualquer outra coisa, seja isso um campo obrigatório ausente, uma falha de escrita de arquivo temporário, ou o provedor rejeitando a mensagem, com o motivo real disponível apenas de GetLastMailError depois

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;

Por que o CoInitializeEx retorna S_FALSE, e isso é uma falha?

S_FALSE vindo de CoInitializeEx não é uma falha, e código que o trata como uma falha reporta falhas em threads onde nada de fato deu errado. CoInitializeEx retorna S_OK na primeira vez que uma thread inicializa o COM com sucesso, e retorna S_FALSE quando essa thread já tinha o COM inicializado com um modelo de concorrência compatível, incrementando o mesmo contador de referência por thread de qualquer forma, de modo que ambos os resultados precisam de uma chamada correspondente de CoUninitialize antes de a thread sair ou passar para trabalho não relacionado. O próprio TPDFlib segue exatamente esse padrão: construir uma instância TPDFlib já chama CoInitialize e registra se um CoUninitialize correspondente é devido, usando a mesma verificação idêntica de S_OK-ou-S_FALSE. No momento em que SendDocumentByMail alcança seu provedor CDO e esse provedor chama CoInitializeEx novamente, o COM já está, portanto, inicializado na thread no caso comum, de modo que o provedor quase sempre observa S_FALSE, em vez de S_OK. Tratar S_FALSE como algo diferente de sucesso não é um caso extremo raro nesta biblioteca; é o caminho comum

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;

Por que o CoInitializeEx retorna RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE significa que a thread atual inicializou o COM antes sob um modelo de concorrência diferente do que essa chamada está solicitando, tipicamente porque a thread anteriormente foi multi-thread (MTA) e o CDO agora está pedindo semântica de apartment single-thread (STA) por meio de COINIT_APARTMENTTHREADED. Uma thread escolhe seu modelo de apartment uma vez, e nada consegue mudar esse modelo pelo resto da vida da thread; tentar de novo CoInitializeEx com flags diferentes não corrige a incompatibilidade, e chamar CoUninitialize primeiro derrubaria um apartment do qual outro código naquela thread ainda pode depender. O PDFlibPas trata RPC_E_CHANGED_MODE como uma condição para trabalhar em vez de um erro para reportar: pula o CoUninitialize pareado, já que a chamada nunca de fato adquiriu uma referência para liberar, e deixa o envio continuar no apartment existente

RPC_E_CHANGED_MODE aparece quase exclusivamente em threads reutilizadas: um worker de pool de threads, uma thread de host de serviço ou IIS, ou qualquer thread onde código anterior, como ADO ou WMI, já chamou CoInitializeEx com COINIT_MULTITHREADED antes de o código de correio chegar perto dela. Uma thread totalmente nova que só chama SendDocumentByMail não vai bater nesse caminho. Uma worker thread reciclada milhares de vezes por dia por um agendador em lote, e compartilhada com outro trabalho baseado em COM, absolutamente vai, e vai fazer isso intermitentemente, que é exatamente o padrão que manda as pessoas olharem para o servidor SMTP primeiro e o modelo de threading depois

Mantendo um anexo de email fora do diretório errado

O PDFlibPas escreve todo anexo de saída em um diretório novo, nomeado a partir de um GUID que gera em toda chamada de SendDocumentByMail, especificamente para que envios concorrentes nunca colidam no mesmo nome de arquivo e para que um nome de anexo não consiga sair desse diretório. O nome passado como o anexo não é confiado como um caminho: passa por PLSanitizeAttachmentName, que remove qualquer componente de diretório, rejeita a string vazia e os nomes especiais . e .., e substitui todo caractere que o Windows trata como ilegal em um nome de arquivo, junto com qualquer caractere de controle, por um sublinhado. Alimente-o com ..\quarter:report.pdf, parte travessia de diretório e parte dois-pontos ilegal, e o que chega ao disco é quarter_report.pdf: tudo até o último separador de caminho é descartado, e o dois-pontos vira um sublinhado porque não pode aparecer em um nome de arquivo do 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;

Um diretório dedicado por chamada não é só arrumação. SendDocumentByMail exclui o arquivo temporário e remove seu diretório em um bloco finally depois que a mensagem é enviada, usando exatamente o mesmo caminho em que escreveu, de modo que um nome de anexo que chegasse a esse código sem sanitização não apenas colocaria a escrita no lugar errado. O mesmo caminho sem sanitização então chegaria a uma etapa de limpeza que chama DeleteFile sem fazer mais perguntas, e em uma pasta temporária compartilhada, dois envios concorrentes também poderiam silenciosamente sobrescrever o anexo um do outro sob o mesmo nome antes de qualquer entrega terminar. Sanitizar o nome fecha o caso de travessia, e o diretório GUID por chamada fecha o caso de colisão, e nenhum dos dois sozinho teria sido suficiente

Casando o tempo de vida do COM com o tempo de vida da thread em um pool de workers

A correção mais confiável para falhas de apartment threading em um mailer em lote é parar de tratar toda chamada de SendDocumentByMail como seu próprio tempo de vida COM isolado, e em vez disso inicializar o COM uma vez por worker thread, pela vida daquela thread. Um worker que chama CoInitializeEx(nil, COINIT_APARTMENTTHREADED) quando começa, mantém aquele apartment para toda chamada de SendDocumentByMail que faz, e chama CoUninitialize exatamente uma vez quando termina, nunca vai ver RPC_E_CHANGED_MODE de seus próprios envios de correio, porque nada mais naquela thread tem a chance de inicializar o COM em um modo conflitante primeiro. Cada chamada individual de SendDocumentByMail ainda roda seu próprio par de CoInitializeEx e CoUninitialize internamente sob esse padrão, e isso é inofensivo: com o apartment já estabelecido pela worker thread, cada uma dessas chamadas internas agora vê S_FALSE, incrementa e decrementa o mesmo contador de referência, e deixa o próprio apartment COM da worker thread intocado

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;

Diagnosticando falhas e testando sem uma caixa de correio ao vivo

GetLastMailError é a outra metade dessa API que vale a pena construir em log desde o primeiro dia, porque o valor de retorno 1-ou-0 sozinho não diz se um envio falho foi um problema de inicialização COM, uma rejeição de autenticação SMTP, ou um anexo ausente. A propriedade MailProvider é o que torna todo o caminho testável sem uma caixa de correio real: atribua a ela uma implementação de IPDFlibMailProvider que registra requisições em vez de enviá-las, rode um job em lote contra esse provedor falso em um pipeline de CI, e os mesmos pontos de chamada SendDocumentByMail continuam funcionando sem mudanças assim que MailProvider é deixado sem definir e o PDFlibPas recai para o transporte CDO embutido em produção

Um job em lote que envia extratos por email raramente para no envio: o mesmo pipeline frequentemente precisa validar e assinar o PDF antes de sair, que é coberto separadamente em o artigo da bancada de conformidade e assinatura, já que preflight e verificação de assinatura são uma preocupação diferente da entrega de correio, mesmo quando ambas rodam uma atrás da outra. Quando os documentos sendo enviados por email são eles mesmos a saída de um grande job de mesclagem ou divisão, em vez de um único PDF recém-construído, o guia de acesso direto para PDFs grandes cobre essa etapa de geração. SendDocumentByMail e o modelo de provedor de correio descritos aqui fazem parte da PDFlibPas PDF Developer Library padrão para Delphi e C++Builder, e a página do produto traz a referência de API completa ao lado de um download de avaliação