Artículo técnico

Enviar PDF por correo mediante CDO en Delphi: trampas de apartamentos de hilos

PDFlibPas, la losLab PDF Developer Library 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 por defecto 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 multihilo es la inicialización de apartamento COM, no el SMTP

El escenario detrás de esta API es poco glamoroso y extremadamente común: un servicio renderiza un lote de PDF de extracto de fin de mes, uno por cliente, y tiene que enviarlos todos por correo sin que haya una persona en el bucle. Colocad ese trabajo en un pool de hilos para ganar 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, con el PDF, ni con el adjunto. El problema es lo que devuelve CoInitializeEx en un hilo que CDO no esperaba, y PDFlibPas está escrito para gestionar ese caso deliberadamente y no por accidente

Qué hace realmente SendDocumentByMail dentro de PDFlibPas

SendDocumentByMail es un orquestador ligero, no un cliente de correo por derecho propio. TPDFlib.SendDocumentByMail guarda el documento cargado actualmente en su propio PDF temporal, empaqueta los ajustes SMTP y el texto del mensaje en un registro TPDFlibMailRequest, entrega ese registro a cualquiera que sea la implementación de IPDFlibMailProvider, y elimina de nuevo el archivo temporal en cuanto el proveedor regresa. La interfaz del proveedor es el cliente de correo real, y PDFlibPas distribuye exactamente una implementación integrada: un proveedor basado en CDO que solo compila en Windows. Llamad a SendDocumentByMail sin asignar antes la propiedad MailProvider, y PDFlibPas recae automáticamente en ese proveedor por defecto. El valor de retorno se mantiene deliberadamente estrecho en todo momento: 1 para aceptado, 0 para cualquier otra cosa, ya sea un campo obligatorio ausente, un fallo de escritura de archivo temporal, o el proveedor rechazando el mensaje, con la razón real disponible solo a través de GetLastMailError después

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 es eso un fallo?

S_FALSE de CoInitializeEx no es un fallo, y el código que lo trata como tal reporta fallos en hilos donde realmente no ha pasado nada malo. 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 recuento de referencias por hilo en ambos casos, así que ambos resultados necesitan una llamada correspondiente a CoUninitialize antes de que el hilo termine o pase a trabajo sin relación. El propio TPDFlib sigue exactamente este patrón: construir una instancia TPDFlib ya llama a CoInitialize y registra si se debe una llamada correspondiente a CoUninitialize, usando la comprobación idéntica de S_OK-o-S_FALSE. Para cuando SendDocumentByMail llega a su proveedor CDO y ese proveedor vuelve a llamar a CoInitializeEx, COM por 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 vía 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 del que esta llamada está solicitando, típicamente porque el hilo anteriormente se volvió multihilo (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 durante el resto de la vida del hilo; reintentar CoInitializeEx con indicadores distintos no arregla el desajuste, y llamar primero a CoUninitialize desmontarí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 llegó a adquirir realmente una referencia que liberar, y deja que el envío continúe sobre 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 haya llamado a CoInitializeEx con COINIT_MULTITHREADED antes de que el código de correo llegara siquiera cerca. Un hilo completamente nuevo que no hace más que llamar a SendDocumentByMail no se topará con esta vía. 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 hace que la gente mire primero al servidor SMTP y al modelo de hilos en segundo lugar

Mantener un adjunto de correo fuera del directorio equivocado

PDFlibPas escribe cada adjunto saliente en un directorio nuevo con nombre a partir de un GUID que genera en cada llamada a SendDocumentByMail, específicamente para que los envíos concurrentes nunca puedan chocar por el mismo nombre de archivo y para que un nombre de adjunto no pueda salirse de ese directorio. El nombre que se pasa como adjunto no se trata como una ruta de confianza: pasa por PLSanitizeAttachmentName, que elimina cualquier componente de directorio, rechaza la cadena vacía y los nombres especiales . y .., y sustituye cada carácter que Windows trata como ilegal en un nombre de archivo, junto con cualquier carácter de control, por un guion bajo. Entregadle ..\quarter:report.pdf, en parte transitar directorios y en parte dos puntos ilegales, 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 por orden. SendDocumentByMail elimina el archivo temporal y quita su directorio en un bloque finally después de enviar el mensaje, usando exactamente la misma ruta en la que escribió, así que un nombre de adjunto que llegara a ese código sin desinfectar no solo desubicaría la escritura. Esa misma ruta sin desinfectar llegaría entonces 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 sobrescribirse silenciosamente el adjunto del otro bajo el mismo nombre antes de que terminara cualquiera de las dos entregas. Desinfectar el nombre cierra el caso de transitar directorios, y el directorio GUID por llamada cierra el caso de colisión, y ninguno de los dos por sí solo habría bastado

Hacer coincidir la vida de COM con la vida del hilo en un pool de trabajo

La solución más fiable para los fallos de apartamento de hilos en un remitente de correo por lotes es dejar de tratar cada llamada a SendDocumentByMail como su propia vida de COM aislada, 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) al arrancar, conserva ese apartamento en cada llamada a SendDocumentByMail que haga, y llama a CoUninitialize exactamente una vez al salir nunca verá RPC_E_CHANGED_MODE desde sus propios envíos de correo, porque nada más en ese hilo tiene ocasión de inicializar COM en un modo conflictivo primero. Cada llamada individual a SendDocumentByMail sigue ejecutando internamente su propio par de 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 recuento 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 real

GetLastMailError es la otra mitad de esta API que merece 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 COM, un rechazo de autenticación SMTP, o un adjunto ausente. La propiedad MailProvider es lo que hace toda la vía comprobable sin un buzón real: asignadle una implementación de IPDFlibMailProvider que registre las solicitudes en lugar de enviarlas, ejecutad un trabajo por lotes contra ese proveedor simulado en un pipeline de CI, y los mismos puntos de llamada a SendDocumentByMail siguen funcionando sin cambios en cuanto se deja MailProvider sin establecer y PDFlibPas recae en el transporte CDO integrado en producción

Un trabajo por lotes que envía extractos 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, algo cubierto por separado en el artículo sobre el banco 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 a su vez la salida de un gran trabajo de fusión o división en lugar de un único PDF recién construido, la guía de acceso directo a PDF de gran tamaño cubre ese paso de generación. SendDocumentByMail y el modelo de proveedor de correo descrito aquí forman parte de la PDFlibPas PDF Developer Library estándar para Delphi y C++Builder, y la página de producto recoge la referencia completa de la API junto con una descarga de prueba