O PDFlibPas, 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 PDFlibPas está escrito para tratar esse caso deliberadamente, não por acidente
O que o SendDocumentByMail efetivamente faz dentro do PDFlibPas
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 PDFlibPas 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 PDFlibPas 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
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;
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 PDFlibPas 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
Manter um anexo de correio fora do diretório errado
O PDFlibPas 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); // 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 é 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
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 PDFlibPas 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 PDFlibPas 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