Artigo Técnico

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

O PDF Library for Delphi, a losLab PDF Developer Library para Delphi e C++Builder, envia um PDF gerado como anexo de email através de uma única chamada de API direta, SendDocumentByMail. No Windows, o transporte predefinido usa o CDO (Collaboration Data Objects), o componente COM de correio incorporado no sistema operativo, e o pormenor que efetivamente quebra tarefas em lote multithread é a inicialização de apartment do COM, não o SMTP

O cenário por trás desta API é pouco glamoroso e extremamente comum: um serviço renderiza um lote de PDFs de extrato de fim de mês, um por cliente, e tem de enviar cada um por email sem uma pessoa no processo. Empurrar essa tarefa para um conjunto de threads para ganhar débito, e uma fração dos envios começa a falhar com um erro COM que nunca se reproduz quando o mesmo código corre numa única thread. Nada está errado com o servidor SMTP, o PDF, ou o anexo. O problema é o que CoInitializeEx devolve numa thread que o CDO não esperava, e o PDF Library for Delphi está escrito para tratar esse caso deliberadamente, não por acidente

O que o SendDocumentByMail efetivamente faz dentro do PDF Library for Delphi

O SendDocumentByMail é um orquestrador fino, não um cliente de correio por direito próprio. O TPDFlib.SendDocumentByMail grava o documento atualmente carregado no seu próprio PDF temporário, empacota as definições SMTP e o texto da mensagem num registo TPDFlibMailRequest, entrega esse registo a quem quer que implemente IPDFlibMailProvider, e apaga de novo o ficheiro temporário assim que o provedor regressa. A interface do provedor é o verdadeiro cliente de correio, e o PDF Library for Delphi distribui exatamente uma implementação incorporada: um provedor baseado em CDO que só compila no Windows. Chamar SendDocumentByMail sem antes atribuir a propriedade MailProvider faz o PDF Library for Delphi recorrer automaticamente a essa predefinição. O valor de retorno mantém-se deliberadamente restrito ao longo de tudo: 1 para aceite, 0 para qualquer outra coisa, seja isso um campo obrigatório em falta, uma falha de escrita de ficheiro temporário, ou o provedor a rejeitar a mensagem, com a razão real disponível só depois através de GetLastMailError

Diagrama do pipeline de SendDocumentByMail: o documento é guardado num PDF temporário com nome GUID e empacotado num record TPDFlibMailRequest, um IPDFlibMailProvider despacha para o transporte CDO integrado ou para um duplo de teste injetado, e quem chama recebe um ou zero, com GetLastMailError a transportar o motivo
O transporte CDO integrado responde à mesma interface restrita que um fake de CI implementa
var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // uma instância nova já contém um documento em branco
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... desenhar o extrato: fonts, texto, totais ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // porta 0 com SSL 1 recai para 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');                    // nome de apresentação do anexo
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

Porque é que o CoInitializeEx devolve S_FALSE, e isso é uma falha?

O S_FALSE vindo de CoInitializeEx não é uma falha, e código que o trate como tal reporta falhas em threads onde na realidade nada correu mal. O CoInitializeEx devolve S_OK na primeira vez que uma thread inicializa o COM com sucesso, e devolve S_FALSE quando essa thread já tinha o COM inicializado com um modelo de concorrência compatível, incrementando de qualquer forma a mesma contagem de referências por thread, pelo que ambos os resultados precisam de uma chamada correspondente a CoUninitialize antes de a thread terminar ou avançar para trabalho não relacionado. O próprio TPDFlib segue exatamente este padrão: construir uma instância de TPDFlib já chama CoInitialize e regista se um CoUninitialize correspondente fica em dívida, usando a mesma verificação idêntica de S_OK-ou-S_FALSE. Quando o SendDocumentByMail chega ao seu provedor CDO e esse provedor chama CoInitializeEx de novo, o COM já está, portanto, inicializado na thread no caso comum, pelo 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 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;

Porque é que o CoInitializeEx devolve RPC_E_CHANGED_MODE?

O RPC_E_CHANGED_MODE significa que a thread atual inicializou o COM anteriormente sob um modelo de concorrência diferente daquele que esta chamada está a pedir, tipicamente porque a thread se tornou anteriormente multithread (MTA) e o CDO está agora a pedir semântica de apartment de thread única (STA) através de COINIT_APARTMENTTHREADED. Uma thread escolhe o seu modelo de apartment uma única vez, e nada consegue mudar esse modelo pelo resto da vida da thread; repetir CoInitializeEx com flags diferentes não corrige a incompatibilidade, e chamar CoUninitialize primeiro destruiria um apartment de que outro código nessa thread possa ainda depender. O PDF Library for Delphi trata RPC_E_CHANGED_MODE como uma condição com que trabalhar, em vez de um erro a reportar: salta o CoUninitialize emparelhado, já que a chamada nunca chegou a adquirir uma referência para libertar, e deixa o envio continuar sobre o apartment existente

O RPC_E_CHANGED_MODE surge quase exclusivamente em threads reutilizadas: um worker de conjunto de threads, uma thread de IIS ou de anfitrião de serviço, ou qualquer thread onde código anterior como ADO ou WMI já tenha chamado CoInitializeEx com COINIT_MULTITHREADED antes de o código de correio sequer se aproximar. Uma thread inteiramente nova que não faça mais nada além de chamar SendDocumentByMail não esbarra neste caminho. Uma thread de trabalho reciclada milhares de vezes por dia por um agendador de lotes, e partilhada com outro trabalho baseado em COM, esbarra sem dúvida, e fá-lo de forma intermitente, exatamente o padrão que leva as pessoas a olhar primeiro para o servidor SMTP e só depois para o modelo de threading

Diagrama de decisão da PDF Library for Delphi dos resultados de CoInitializeEx numa thread de trabalho: S_OK cria o apartment, S_FALSE apenas incrementa uma contagem de referências compatível e é o caminho comum, RPC_E_CHANGED_MODE mantém vivo um apartment MTA anterior, e qualquer outra falha aborta o envio
Tratar S_FALSE como uma falha rejeita o caminho comum que as threads já inicializadas seguem

Manter um anexo de correio fora do diretório errado

O PDF Library for Delphi escreve cada anexo de saída num diretório novo, nomeado a partir de um GUID que gera em cada chamada a SendDocumentByMail, especificamente para que envios concorrentes nunca possam colidir sobre o mesmo nome de ficheiro e para que um nome de anexo não consiga sair desse diretório. O nome passado como anexo não é tratado como caminho de confiança: passa por PLSanitizeAttachmentName, que remove qualquer componente de diretório, rejeita a cadeia vazia e os nomes especiais . e .., e substitui cada carácter que o Windows trate como ilegal num nome de ficheiro, juntamente com qualquer carácter de controlo, por um sublinhado. Ao fornecer ..\quarter:report.pdf, parte travessia de diretório e parte dois-pontos ilegal, o que chega ao disco é quarter_report.pdf: tudo até ao último separador de caminho é descartado, e os dois-pontos tornam-se um sublinhado porque não podem aparecer num nome de ficheiro Windows

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // remove qualquer parte de diretório
  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 é apenas uma questão de arrumação. O SendDocumentByMail apaga o ficheiro temporário e remove o seu diretório num bloco finally depois de a mensagem ser enviada, usando esse mesmo caminho onde escreveu, pelo que um nome de anexo que chegasse a esse código sem sanitização não se limitaria a colocar mal a escrita. Esse mesmo caminho não sanitizado chegaria depois a um passo de limpeza que chama DeleteFile sem fazer mais perguntas, e, numa pasta temporária partilhada, dois envios concorrentes também poderiam silenciosamente sobrescrever um o anexo do outro sob o mesmo nome antes de qualquer uma das entregas 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

Fazer corresponder o tempo de vida do COM ao tempo de vida da thread num conjunto de workers

A correção mais fiável para falhas de apartment-threading num mailer em lote é deixar de tratar cada chamada a SendDocumentByMail como o seu próprio tempo de vida de COM isolado, e, em vez disso, inicializar o COM uma vez por thread de trabalho, durante toda a vida dessa thread. Um worker que chame CoInitializeEx(nil, COINIT_APARTMENTTHREADED) quando arranca, mantém esse apartment para cada chamada a SendDocumentByMail que faça, e chama CoUninitialize exatamente uma vez quando termina, nunca verá RPC_E_CHANGED_MODE a partir dos seus próprios envios de correio, porque nada mais nessa thread tem oportunidade de inicializar o COM num modo conflituoso primeiro. Cada chamada individual a SendDocumentByMail continua a correr o seu próprio par interno de CoInitializeEx e CoUninitialize sob este padrão, e isso é inofensivo: com o apartment já estabelecido pela thread de trabalho, cada uma dessas chamadas internas passa agora a ver S_FALSE, incrementa e decrementa a mesma contagem de referências, e deixa intocado o próprio apartment COM da thread de trabalho

PDF Library for Delphi: Diagrama de sanitização que transforma o nome de anexo hostil ..\quarter:report.pdf em quarter_report.pdf dentro de um diretório novo com nome GUID, sobre o qual envios de correio concorrentes não podem colidir
Remover as partes do caminho e substituir os caracteres ilegais combina com uma pasta GUID por chamada
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;

Diagnosticar falhas e testar sem uma caixa de correio real

O GetLastMailError é a outra metade desta API que vale a pena incorporar no registo de eventos desde o primeiro dia, porque o valor de retorno 1-ou-0 sozinho não diz se um envio falhado foi um problema de inicialização de COM, uma rejeição de autenticação SMTP, ou um anexo em falta. A propriedade MailProvider é o que torna todo o caminho testável sem uma caixa de correio real: atribua-lhe uma implementação de IPDFlibMailProvider que registe pedidos em vez de os enviar, corra uma tarefa em lote contra esse provedor falso num pipeline de CI, e os mesmos pontos de chamada a SendDocumentByMail continuam a funcionar sem alterações assim que MailProvider for deixado por atribuir e o PDF Library for Delphi recorrer ao transporte CDO incorporado em produção

Uma tarefa em lote que envia extratos por email raramente pára no envio: o mesmo pipeline frequentemente precisa de validar e assinar o PDF antes de este sair, tema abordado separadamente no artigo sobre a bancada de conformidade e assinatura, já que o preflight e a verificação de assinatura são uma preocupação diferente da entrega de correio, mesmo quando ambos correm um a seguir ao outro. Quando os documentos a enviar por email são, eles próprios, o resultado de uma grande tarefa de fusão ou divisão em vez de um único PDF recém-construído, o guia de acesso direto a PDFs grandes aborda esse passo de geração. O SendDocumentByMail e o modelo de provedor de correio aqui descritos fazem parte da PDF Library for Delphi PDF Developer Library padrão para Delphi e C++Builder, e a página do produto contém a referência completa da API a par de uma versão de avaliação para download