PDFlibPas, PDF Developer Library от losLab для Delphi и C++Builder, отправляет сгенерированный PDF как вложение email через один плоский вызов API, SendDocumentByMail. На Windows транспорт по умолчанию использует CDO (Collaboration Data Objects), встроенный в операционную систему COM-компонент почты, и деталь, что реально ломает многопоточные пакетные задания, — инициализация апартамента COM, а не SMTP
Сценарий за этим API непритязателен и крайне распространён: служба отрисовывает партию PDF-выписок конца месяца, по одной на клиента, и должна разослать каждую без человека в цепочке. Отправьте это задание на пул потоков ради пропускной способности, и часть отправок начнёт проваливаться с ошибкой COM, что никогда не воспроизводится, когда тот же код выполняется в одном потоке. Ничего не так с сервером SMTP, PDF или вложением. Проблема в том, что возвращает CoInitializeEx в потоке, которого CDO не ожидал, и PDFlibPas написан так, чтобы обрабатывать этот случай намеренно, а не случайно
Что на самом деле делает SendDocumentByMail внутри PDFlibPas
SendDocumentByMail — тонкий оркестратор, а не почтовый клиент сам по себе. TPDFlib.SendDocumentByMail сохраняет текущий загруженный документ в собственный временный PDF, упаковывает настройки SMTP и текст сообщения в запись TPDFlibMailRequest, передаёт эту запись тому, что реализует IPDFlibMailProvider, и удаляет временный файл снова, как только провайдер возвращает управление. Интерфейс провайдера — реальный почтовый клиент, и PDFlibPas поставляется ровно с одной встроенной реализацией: провайдером на основе CDO, компилируемым только на Windows. Вызовите SendDocumentByMail, не назначив сначала свойство MailProvider, и PDFlibPas автоматически откатывается к этому значению по умолчанию. Возвращаемое значение остаётся намеренно узким на всём протяжении: 1 для принятого, 0 для чего угодно ещё, будь то отсутствующее обязательное поле, отказ записи временного файла или отклонение сообщения провайдером, а реальная причина доступна только из 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;
Почему CoInitializeEx возвращает S_FALSE, и является ли это отказом?
S_FALSE от CoInitializeEx — не отказ, и код, что трактует его как отказ, сообщает об отказах в потоках, где на самом деле ничего плохого не произошло. CoInitializeEx возвращает S_OK при первой успешной инициализации COM в потоке, а S_FALSE — когда в этом потоке COM уже был инициализирован с совместимой моделью параллелизма, в любом случае увеличивая один и тот же счётчик ссылок на поток, так что оба исхода нуждаются в соответствующем вызове CoUninitialize перед завершением потока или переходом к не связанной работе. Сам TPDFlib следует этому же паттерну: конструирование экземпляра TPDFlib уже вызывает CoInitialize и записывает, причитается ли соответствующий CoUninitialize, используя ту же проверку S_OK-или-S_FALSE. К тому моменту, когда SendDocumentByMail добирается до своего провайдера CDO, и тот провайдер вызывает CoInitializeEx заново, COM в потоке уже, следовательно, инициализирован в обычном случае, так что провайдер почти всегда наблюдает S_FALSE, а не S_OK. Трактовка S_FALSE как чего-либо, кроме успеха, — не редкий пограничный случай в этой библиотеке; это обычный путь
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;
Почему CoInitializeEx возвращает RPC_E_CHANGED_MODE?
RPC_E_CHANGED_MODE означает, что текущий поток инициализировал COM ранее под другой моделью параллелизма, нежели та, что запрашивает этот вызов, обычно потому, что поток ранее перешёл в многопоточный режим (MTA), а CDO теперь запрашивает семантику однопоточного апартамента (STA) через COINIT_APARTMENTTHREADED. Поток выбирает свою модель апартамента один раз, и ничто не может изменить эту модель до конца жизни потока; повторная попытка CoInitializeEx с другими флагами не исправляет несовпадение, а вызов CoUninitialize сначала разрушил бы апартамент, от которого другой код в этом потоке всё ещё может зависеть. PDFlibPas трактует RPC_E_CHANGED_MODE как условие, с которым нужно работать, а не как ошибку для сообщения: он пропускает парный CoUninitialize, поскольку вызов на самом деле никогда не получал ссылки для освобождения, и позволяет отправке продолжаться на существующем апартаменте
RPC_E_CHANGED_MODE проявляется почти исключительно на переиспользуемых потоках: рабочем потоке пула потоков, потоке IIS или хоста службы или любом потоке, где более ранний код, например ADO или WMI, уже вызвал CoInitializeEx с COINIT_MULTITHREADED прежде, чем почтовый код вообще до него добрался. Совершенно новый поток, что ничего не делает, кроме вызова SendDocumentByMail, не наткнётся на этот путь. Рабочий поток, тысячи раз в день переиспользуемый пакетным планировщиком и разделяемый с другой работой на основе COM, абсолютно наткнётся, и будет делать это с перебоями, — именно тот паттерн, что заставляет людей смотреть сначала на сервер SMTP, а на модель потоков только во вторую очередь
Удержание вложения email вне неправильного каталога
PDFlibPas записывает каждое исходящее вложение в свежий каталог, названный по GUID, что он генерирует при каждом вызове SendDocumentByMail, именно для того, чтобы параллельные отправки никогда не могли столкнуться на одном имени файла, и чтобы имя вложения не могло выйти за пределы этого каталога. Имя, переданное как вложение, не воспринимается как доверенный путь: оно проходит через PLSanitizeAttachmentName, что отрезает любой компонент каталога, отклоняет пустую строку и специальные имена . и .., а также заменяет каждый символ, который Windows считает недопустимым в имени файла, наряду с любым управляющим символом, на подчёркивание. Скормите ей ..\quarter:report.pdf, отчасти обход каталога, отчасти недопустимое двоеточие, и то, что попадёт на диск, — quarter_report.pdf: всё до последнего разделителя пути отбрасывается, а двоеточие становится подчёркиванием, потому что не может появиться в имени файла 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;
Выделенный каталог на вызов — не просто аккуратность. SendDocumentByMail удаляет временный файл и удаляет его каталог в блоке finally после отправки сообщения, используя тот же путь, по которому писал, так что несанированное имя вложения, дошедшее до этого кода, испортило бы не только запись. Тот же несанированный путь затем дошёл бы до шага очистки, что вызывает DeleteFile, не задавая дальнейших вопросов, а на общей временной папке две параллельные отправки также могли бы молча перезаписать вложение друг друга под одним именем прежде, чем завершится хоть одна доставка. Санация имени закрывает случай обхода, а каталог с GUID на вызов закрывает случай столкновения, и ни одного из них по отдельности было бы недостаточно
Согласование времени жизни COM со временем жизни потока в пуле воркеров
Самое надёжное исправление для отказов апартаментной модели потоков в пакетном рассыльщике — перестать относиться к каждому вызову SendDocumentByMail как к собственному изолированному времени жизни COM, а вместо этого инициализировать COM один раз на рабочий поток, на всё время жизни этого потока. Воркер, что вызывает CoInitializeEx(nil, COINIT_APARTMENTTHREADED) при запуске, удерживает этот апартамент для каждого вызова SendDocumentByMail, что делает, и вызывает CoUninitialize ровно один раз при выходе, никогда не увидит RPC_E_CHANGED_MODE от собственных отправок почты, потому что ничему другому в этом потоке не выпадает шанс сначала инициализировать COM в конфликтующем режиме. Каждый отдельный вызов SendDocumentByMail по-прежнему выполняет собственную пару CoInitializeEx и CoUninitialize внутри себя при этом паттерне, и это безвредно: с уже установленным рабочим потоком апартаментом каждый из этих внутренних вызовов теперь видит S_FALSE, увеличивает и уменьшает тот же счётчик ссылок и оставляет собственный апартамент COM рабочего потока нетронутым
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;
Диагностика отказов и тестирование без живого почтового ящика
GetLastMailError — вторая половина этого API, которую стоит встроить в логирование с первого дня, потому что одного возвращаемого значения 1-или-0 недостаточно, чтобы сказать, была ли неудавшаяся отправка проблемой инициализации COM, отклонением аутентификации SMTP или отсутствующим вложением. Свойство MailProvider — то, что делает весь путь тестируемым без реального почтового ящика: назначьте ему реализацию IPDFlibMailProvider, что записывает запросы вместо их отправки, прогоните пакетное задание против этого поддельного провайдера в конвейере CI, и те же места вызова SendDocumentByMail продолжают работать без изменений, как только MailProvider остаётся неустановленным, и PDFlibPas откатывается к встроенному транспорту CDO в продакшене
Пакетное задание, рассылающее выписки, редко останавливается на отправке: тот же конвейер часто должен проверить и подписать PDF перед отправкой, что описано отдельно в статье об инструментарии соответствия и подписания, поскольку предпечатная проверка и проверка подписи — забота, отдельная от доставки почты, даже когда оба шага выполняются один за другим. Когда отправляемые по почте документы сами являются выводом крупного задания слияния или разбиения, а не единственным свежесозданным PDF, шаг их генерации описывает руководство по прямому доступу для крупных PDF. SendDocumentByMail и описанная здесь модель почтового провайдера — часть стандартной PDFlibPas PDF Developer Library для Delphi и C++Builder, и на странице продукта представлен полный справочник API наряду с пробной загрузкой