Artículo técnico

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

PDF Library for Delphi, 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 PDF Library for Delphi está escrito para gestionar ese caso deliberadamente y no por accidente

Qué hace realmente SendDocumentByMail dentro de PDF Library for Delphi

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 PDF Library for Delphi 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 PDF Library for Delphi 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

Diagrama del pipeline de SendDocumentByMail: el documento se guarda en un PDF temporal con nombre GUID y se empaqueta en un registro TPDFlibMailRequest, un IPDFlibMailProvider dirige al transporte CDO integrado o a un doble de prueba inyectado, y quien llama recibe uno o cero con GetLastMailError portando el motivo
El transporte CDO integrado responde a la misma interfaz reducida que implementa un doble de pruebas en CI
var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // una instancia nueva ya trae un documento en blanco
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... dibujar el extracto: fuentes, texto, totales ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // puerto 0 con SSL 1 cae al 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');                    // nombre visible del adjunto
    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. PDF Library for Delphi 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

Diagrama de decisión de PDF Library for Delphi de los resultados de CoInitializeEx en un hilo de trabajo: S_OK crea el apartamento, S_FALSE solo incrementa un recuento de referencias compatible y es la vía común, RPC_E_CHANGED_MODE mantiene vivo un apartamento MTA anterior, y cualquier otro fallo aborta el envío
Tratar S_FALSE como un fallo rechaza la vía ordinaria que siguen los subprocesos ya inicializados

Mantener un adjunto de correo fuera del directorio equivocado

PDF Library for Delphi 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);       // quita cualquier parte de directorio
  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

PDF Library for Delphi: Diagrama de saneamiento que convierte el nombre de adjunto hostil ..\quarter:report.pdf en quarter_report.pdf dentro de un directorio nuevo con nombre GUID sobre el que los envíos de correo concurrentes no pueden colisionar
Eliminar los componentes de la ruta y sustituir los caracteres no válidos se combina con una carpeta GUID por llamada
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 PDF Library for Delphi 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 PDF Library for Delphi 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