Artículo técnico

Enviar PDF por correo vía CDO en Delphi: trampas de apartment-threading

PDFlibPas, la biblioteca de desarrollo PDF de losLab para Delphi y C++Builder, envía un PDF generado como adjunto de correo mediante una única llamada de API plana, SendDocumentByMail. En Windows, el transporte predeterminado usa CDO (Collaboration Data Objects), el componente de correo COM integrado en el sistema operativo, y el detalle que realmente rompe los trabajos por lotes multi-hilo es la inicialización de apartamento COM, no SMTP

El escenario detrás de esta API no es glamoroso y es extremadamente común: un servicio renderiza un lote de PDF de estado de cuenta de fin de mes, uno por cliente, y tiene que enviarlos por correo sin una persona en el circuito. Empuje ese trabajo a un pool de hilos para obtener rendimiento, y una fracción de los envíos empieza a fallar con un error COM que nunca se reproduce cuando el mismo código se ejecuta en un solo hilo. No hay nada mal con el servidor SMTP, el PDF, o el adjunto. El problema es lo que devuelve CoInitializeEx en un hilo que CDO no esperaba, y PDFlibPas está escrito para manejar ese caso deliberadamente en lugar de por accidente

Qué hace realmente SendDocumentByMail dentro de PDFlibPas

SendDocumentByMail es un orquestador delgado, no un cliente de correo por derecho propio. TPDFlib.SendDocumentByMail guarda el documento actualmente cargado en su propio PDF temporal, empaqueta la configuración SMTP y el texto del mensaje en un registro TPDFlibMailRequest, entrega ese registro a cualquiera que sea que implemente IPDFlibMailProvider, y elimina de nuevo el archivo temporal en cuanto el proveedor retorna. La interfaz del proveedor es el cliente de correo real, y PDFlibPas incluye exactamente una implementación integrada: un proveedor basado en CDO que solo compila en Windows. Llame a SendDocumentByMail sin asignar primero la propiedad MailProvider, y PDFlibPas recurre a ese valor predeterminado automáticamente. El valor de retorno permanece deliberadamente estrecho en todo momento: 1 para aceptado, 0 para cualquier otra cosa, ya sea un campo requerido faltante, un fallo de escritura de archivo temporal, o el proveedor rechazando el mensaje, con la razón real disponible solo después mediante 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;

¿Por qué devuelve CoInitializeEx S_FALSE, y eso es un fallo?

S_FALSE de CoInitializeEx no es un fallo, y el código que lo trata como uno reporta fallos en hilos donde en realidad no salió mal nada. CoInitializeEx devuelve S_OK la primera vez que un hilo inicializa COM con éxito, y devuelve S_FALSE cuando ese hilo ya tenía COM inicializado con un modelo de concurrencia compatible, incrementando el mismo conteo de referencias por hilo en cualquiera de los dos casos, así que ambos resultados necesitan una llamada correspondiente a CoUninitialize antes de que el hilo termine o pase a trabajo no relacionado. El propio TPDFlib sigue exactamente este patrón: construir una instancia de TPDFlib ya llama a CoInitialize y registra si se debe una llamada correspondiente a CoUninitialize, usando la misma comprobación idéntica de S_OK-o-S_FALSE. Para cuando SendDocumentByMail llega a su proveedor CDO y ese proveedor llama de nuevo a CoInitializeEx, COM por lo tanto ya está inicializado en el hilo en el caso ordinario, así que el proveedor casi siempre observa S_FALSE en lugar de S_OK. Tratar S_FALSE como cualquier cosa que no sea éxito no es un caso límite raro en esta biblioteca; es la ruta común

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 qué devuelve CoInitializeEx RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE significa que el hilo actual inicializó COM antes bajo un modelo de concurrencia distinto al que solicita esta llamada, típicamente porque el hilo previamente se volvió multi-hilo (MTA) y CDO ahora está pidiendo semántica de apartamento de un solo hilo (STA) mediante COINIT_APARTMENTTHREADED. Un hilo elige su modelo de apartamento una vez, y nada puede cambiar ese modelo por el resto de la vida del hilo; reintentar CoInitializeEx con banderas distintas no arregla el desajuste, y llamar primero a CoUninitialize derribaría un apartamento del que otro código en ese hilo todavía podría depender. PDFlibPas trata RPC_E_CHANGED_MODE como una condición con la que trabajar en lugar de un error que reportar: se salta el CoUninitialize emparejado, ya que la llamada nunca realmente adquirió una referencia que liberar, y deja que el envío continúe en el apartamento existente

RPC_E_CHANGED_MODE aparece casi exclusivamente en hilos reutilizados: un trabajador de pool de hilos, un hilo de IIS o de host de servicio, o cualquier hilo donde código anterior como ADO o WMI ya llamó a CoInitializeEx con COINIT_MULTITHREADED antes de que el código de correo se acercara siquiera. Un hilo completamente nuevo que no hace nada más que llamar a SendDocumentByMail no se topará con esta ruta. Un hilo de trabajo reciclado miles de veces al día por un planificador por lotes, y compartido con otro trabajo basado en COM, absolutamente sí lo hará, y lo hará de forma intermitente, que es exactamente el patrón que envía a la gente a mirar primero al servidor SMTP y en segundo lugar al modelo de hilos

Mantener un adjunto de correo fuera del directorio equivocado

PDFlibPas escribe cada adjunto saliente en un directorio nuevo nombrado según un GUID que genera en cada llamada a SendDocumentByMail, específicamente para que los envíos concurrentes nunca puedan chocar en el mismo nombre de archivo y para que un nombre de adjunto no pueda salir de ese directorio. El nombre pasado como el adjunto no se trata como una ruta de confianza: pasa por PLSanitizeAttachmentName, que despoja cualquier componente de directorio, rechaza la cadena vacía y los nombres especiales . y .., y reemplaza cada carácter que Windows trata como ilegal en un nombre de archivo, junto con cualquier carácter de control, con un guion bajo. Entréguele ..\quarter:report.pdf, parte recorrido de directorio y parte dos puntos ilegal, y lo que llega a disco es quarter_report.pdf: todo hasta el último separador de ruta se descarta, y los dos puntos se convierten en un guion bajo porque no puede aparecer en un nombre de archivo de 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;

Un directorio dedicado por llamada no es solo prolijidad. SendDocumentByMail elimina el archivo temporal y remueve su directorio en un bloque finally después de que se envía el mensaje, usando la misma ruta a la que escribió, así que un nombre de adjunto que hubiera llegado a ese código sin sanear no solo habría descolocado la escritura. Esa misma ruta sin sanear luego llegaría a un paso de limpieza que llama a DeleteFile sin hacer más preguntas, y en una carpeta temporal compartida, dos envíos concurrentes también podrían silenciosamente sobrescribirse el adjunto entre sí bajo el mismo nombre antes de que termine cualquiera de las dos entregas. Sanear el nombre cierra el caso de recorrido, y el directorio GUID por llamada cierra el caso de colisión, y ninguno de los dos por sí solo habría sido suficiente

Hacer coincidir el tiempo de vida de COM con el del hilo en un pool de trabajadores

La corrección más confiable para los fallos de apartment-threading en un enviador de correo por lotes es dejar de tratar cada llamada a SendDocumentByMail como su propio tiempo de vida de COM aislado, y en su lugar inicializar COM una vez por hilo de trabajo, durante toda la vida de ese hilo. Un trabajador que llama a CoInitializeEx(nil, COINIT_APARTMENTTHREADED) cuando empieza, conserva ese apartamento para cada llamada a SendDocumentByMail que hace, y llama a CoUninitialize exactamente una vez cuando sale, nunca verá RPC_E_CHANGED_MODE de sus propios envíos de correo, porque nada más en ese hilo tiene la oportunidad de inicializar COM en un modo conflictivo primero. Cada llamada individual a SendDocumentByMail sigue ejecutando internamente su propio par CoInitializeEx y CoUninitialize bajo este patrón, y eso es inofensivo: con el apartamento ya establecido por el hilo de trabajo, cada una de esas llamadas internas ahora ve S_FALSE, incrementa y decrementa el mismo conteo de referencias, y deja intacto el propio apartamento COM del hilo de trabajo

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 fallos y probar sin un buzón en vivo

GetLastMailError es la otra mitad de esta API que vale la pena incorporar al registro desde el primer día, porque el valor de retorno 1-o-0 por sí solo no dice si un envío fallido fue un problema de inicialización de COM, un rechazo de autenticación SMTP, o un adjunto faltante. La propiedad MailProvider es lo que hace que toda la ruta sea comprobable sin un buzón real: asígnele una implementación de IPDFlibMailProvider que registre las solicitudes en lugar de enviarlas, ejecute un trabajo por lotes contra ese proveedor falso en un pipeline de CI, y los mismos sitios de llamada a SendDocumentByMail siguen funcionando sin cambios en cuanto MailProvider se deja sin establecer y PDFlibPas recurre al transporte CDO integrado en producción

Un trabajo por lotes que envía estados de cuenta por correo rara vez se detiene en el envío: el mismo pipeline a menudo necesita validar y firmar el PDF antes de que salga, que se cubre por separado en el artículo sobre el banco de trabajo de conformidad y firma, ya que el preflight y la verificación de firma son una preocupación distinta de la entrega de correo incluso cuando ambos se ejecutan uno tras otro. Cuando los documentos que se envían por correo son ellos mismos la salida de un trabajo de fusión o división grande en lugar de un solo PDF recién construido, la guía de acceso directo para PDF grandes cubre ese paso de generación. SendDocumentByMail y el modelo de proveedor de correo descrito aquí son parte de la biblioteca de desarrollo PDF PDFlibPas estándar para Delphi y C++Builder, y la página del producto lleva la referencia de API completa junto con una descarga de prueba