บทความเทคนิค

การส่งอีเมล PDF ผ่าน CDO ใน Delphi: ข้อผิดพลาดของ Apartment-Threading

PDFlibPas ไลบรารีนักพัฒนา PDF ของ losLab สำหรับ Delphi และ C++Builder ส่ง PDF ที่สร้างขึ้นเป็นไฟล์แนบอีเมลผ่านการเรียก API แบนราบเดียว คือ SendDocumentByMail บน Windows transport เริ่มต้นใช้ CDO (Collaboration Data Objects) คอมโพเนนต์อีเมลแบบ COM ที่มีอยู่ในตัวระบบปฏิบัติการ และรายละเอียดที่ทำให้งาน batch แบบ multi-threaded พังจริงๆ คือการเริ่มต้น COM apartment ไม่ใช่ SMTP

สถานการณ์เบื้องหลัง API นี้ไม่หรูหราเลยและพบได้บ่อยมาก service หนึ่งสร้าง PDF ใบแจ้งยอดสิ้นเดือนเป็น batch หนึ่งไฟล์ต่อลูกค้า และต้องส่งอีเมลออกไปแต่ละไฟล์โดยไม่มีคนคอยกำกับ push งานนั้นเข้า thread pool เพื่อ throughput แล้วส่วนหนึ่งของการส่งเริ่มล้มเหลวด้วย COM error ที่ไม่เคยเกิดซ้ำเมื่อโค้ดเดียวกันรันบน thread เดียว ไม่มีอะไรผิดพลาดกับ SMTP server, PDF หรือไฟล์แนบเลย ปัญหาคือสิ่งที่ CoInitializeEx คืนกลับมาบน thread ที่ CDO ไม่คาดคิด และ PDFlibPas ถูกเขียนขึ้นให้จัดการกรณีนั้นอย่างจงใจ ไม่ใช่โดยบังเอิญ

SendDocumentByMail ทำอะไรจริงๆ ภายใน PDFlibPas

SendDocumentByMail เป็นตัวประสานงานบางๆ ไม่ใช่ mail client ในตัวมันเอง TPDFlib.SendDocumentByMail บันทึกเอกสารที่โหลดอยู่ในขณะนั้นเป็น PDF ชั่วคราวของตัวเอง บรรจุการตั้งค่า SMTP และข้อความเข้า record TPDFlibMailRequest ส่ง record นั้นให้อะไรก็ตามที่ implement IPDFlibMailProvider และลบไฟล์ชั่วคราวอีกครั้งเมื่อ provider คืนค่ากลับมา provider interface คือ mail client จริงๆ และ PDFlibPas มาพร้อม implementation ในตัวพอดีหนึ่งตัว คือ provider แบบ CDO ที่ compile ได้แค่บน Windows เรียก SendDocumentByMail โดยไม่กำหนด property MailProvider ก่อน แล้ว PDFlibPas จะ fallback ไปยังค่าเริ่มต้นนั้นโดยอัตโนมัติ ค่าที่คืนกลับมายังคงแคบโดยตั้งใจตลอดทั้งหมด 1 สำหรับรับแล้ว 0 สำหรับอะไรก็ตามอื่น ไม่ว่าจะเป็นฟิลด์ที่จำเป็นหายไป, ความล้มเหลวในการเขียนไฟล์ชั่วคราว หรือ provider ปฏิเสธข้อความ โดยเหตุผลจริงหาได้แค่จาก 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 ไม่ใช่ความล้มเหลว และโค้ดที่ปฏิบัติต่อมันเป็นความล้มเหลวจะรายงานความล้มเหลวบน thread ที่ไม่มีอะไรผิดพลาดจริงๆ CoInitializeEx คืน S_OK ในครั้งแรกที่ thread หนึ่งเริ่มต้น COM สำเร็จ และมันคืน S_FALSE เมื่อ thread นั้นมี COM เริ่มต้นไว้แล้วด้วย concurrency model ที่เข้ากันได้ โดยเพิ่ม reference count ต่อ thread เดียวกันไม่ว่ากรณีไหน ดังนั้นทั้งสองผลลัพธ์ต้องการการเรียก CoUninitialize ที่ตรงกันก่อนที่ thread จะออกหรือย้ายไปทำงานอื่นที่ไม่เกี่ยวข้อง TPDFlib เองเดินตามรูปแบบนี้เป๊ะ การสร้าง instance TPDFlib เรียก CoInitialize ไปแล้วและบันทึกว่าต้องเรียก CoUninitialize ที่ตรงกันหรือไม่ โดยใช้การตรวจสอบ S_OK-หรือ-S_FALSE เดียวกัน เมื่อ SendDocumentByMail ไปถึง provider CDO ของมัน และ provider นั้นเรียก CoInitializeEx อีกครั้ง COM จึงเริ่มต้นไว้แล้วบน thread นั้นในกรณีปกติ ดังนั้น provider แทบจะสังเกตเห็น S_FALSE เสมอ แทนที่จะเป็น S_OK การปฏิบัติต่อ S_FALSE เป็นอะไรอื่นนอกเหนือจากความสำเร็จไม่ใช่ edge case หายากในไลบรารีนี้เลย มันคือเส้นทางทั่วไป

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 หมายความว่า thread ปัจจุบันเริ่มต้น COM ไปแล้วก่อนหน้าภายใต้ concurrency model ที่ต่างจากตัวที่การเรียกนี้กำลังขอ โดยทั่วไปเพราะ thread นั้นเคยเป็นแบบ multi-threaded (MTA) มาก่อน และ CDO ตอนนี้กำลังขอ semantics แบบ single-threaded apartment (STA) ผ่าน COINIT_APARTMENTTHREADED thread หนึ่งเลือก apartment model ของมันแค่ครั้งเดียว และไม่มีอะไรเปลี่ยน model นั้นได้ตลอดชีวิตที่เหลือของ thread การ retry CoInitializeEx ด้วย flag ต่างกันไม่ได้แก้ความไม่ตรงกัน และการเรียก CoUninitialize ก่อนจะรื้อ apartment ที่โค้ดอื่นบน thread นั้นอาจยังคงพึ่งพาอยู่ PDFlibPas ปฏิบัติต่อ RPC_E_CHANGED_MODE เป็นเงื่อนไขที่ต้องทำงานด้วย แทนที่จะเป็นข้อผิดพลาดที่ต้องรายงาน มันข้าม CoUninitialize ที่จับคู่กัน เพราะการเรียกไม่เคยได้รับ reference มาปล่อยจริงๆ และปล่อยให้การส่งดำเนินต่อบน apartment ที่มีอยู่แล้ว

RPC_E_CHANGED_MODE ปรากฏเกือบเฉพาะบน thread ที่ถูกใช้ซ้ำ คือ worker ของ thread-pool, thread ของ IIS หรือ service-host หรือ thread ใดก็ตามที่โค้ดก่อนหน้าอย่าง ADO หรือ WMI เรียก CoInitializeEx ด้วย COINIT_MULTITHREADED ไปแล้วก่อนที่โค้ดอีเมลจะเข้าใกล้มัน thread ใหม่เอี่ยมที่ไม่ทำอะไรนอกจากเรียก SendDocumentByMail จะไม่ชนเส้นทางนี้เลย worker thread ที่ถูกรีไซเคิลหลายพันครั้งต่อวันโดย batch scheduler และแชร์กับงานที่อิง COM อื่น จะชนแน่นอน และมันจะทำแบบเป็นระยะๆ ซึ่งเป็นรูปแบบพอดีที่ทำให้คนมองไปที่ SMTP server ก่อนและ threading model ทีหลัง

การเก็บไฟล์แนบอีเมลไม่ให้อยู่ในไดเรกทอรีที่ผิด

PDFlibPas เขียนไฟล์แนบขาออกทุกไฟล์เข้าไดเรกทอรีใหม่ที่ตั้งชื่อตาม GUID ที่มันสร้างขึ้นในทุกการเรียก SendDocumentByMail โดยเฉพาะเพื่อไม่ให้การส่งพร้อมกันชนกันบนชื่อไฟล์เดียวกันได้เลย และเพื่อไม่ให้ชื่อไฟล์แนบเดินออกจากไดเรกทอรีนั้นได้ ชื่อที่ส่งเข้ามาเป็นไฟล์แนบไม่ถูกเชื่อถือเป็น path เลย มันวิ่งผ่าน PLSanitizeAttachmentName ซึ่งตัด component ไดเรกทอรีใดๆ ออก ปฏิเสธ string ว่างเปล่าและชื่อพิเศษ . กับ .. และแทนที่ทุกตัวอักษรที่ Windows ถือว่าผิดกฎหมายในชื่อไฟล์ พร้อมกับตัวควบคุมใดๆ ด้วยขีดล่าง ป้อน ..\quarter:report.pdf เข้าไป ส่วนหนึ่งเป็นการเดินข้ามไดเรกทอรีและส่วนหนึ่งเป็นทวิภาคที่ผิดกฎหมาย แล้วสิ่งที่ไปถึงดิสก์คือ quarter_report.pdf ทุกอย่างจนถึงตัวคั่น path สุดท้ายถูกทิ้งไป และทวิภาคกลายเป็นขีดล่างเพราะมันปรากฏในชื่อไฟล์ 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 ลบไฟล์ชั่วคราวและเอาไดเรกทอรีของมันออกใน block finally หลังจากข้อความถูกส่งแล้ว โดยใช้ path เดียวกันเป๊ะที่มันเขียนไป ดังนั้นชื่อไฟล์แนบที่ไปถึงโค้ดนั้นโดยไม่ถูก sanitize จะไม่ใช่แค่วางไฟล์ผิดที่เท่านั้น path ที่ไม่ถูก sanitize เดียวกันนั้นจะไปถึงขั้นตอนล้างข้อมูลที่เรียก DeleteFile โดยไม่ถามคำถามเพิ่มเติม และบนโฟลเดอร์ temp ที่แชร์กัน การส่งพร้อมกันสองครั้งยังสามารถเขียนทับไฟล์แนบของกันและกันอย่างเงียบๆ ภายใต้ชื่อเดียวกันก่อนที่การส่งฝ่ายใดฝ่ายหนึ่งจะเสร็จสิ้นด้วย การ sanitize ชื่อปิดกรณีการเดินข้าม และไดเรกทอรี GUID ต่อการเรียกปิดกรณีการชนกัน และตัวใดตัวหนึ่งเพียงลำพังจะไม่เพียงพอ

การจับคู่อายุของ COM ให้ตรงกับอายุของ Thread ใน Worker Pool

ทางแก้ที่เชื่อถือได้ที่สุดสำหรับความล้มเหลวด้าน apartment-threading ใน mailer แบบ batch คือหยุดปฏิบัติต่อทุกการเรียก SendDocumentByMail เป็นอายุ COM ที่แยกตัวของตัวเอง แล้วเริ่มต้น COM ครั้งเดียวต่อ worker thread แทน ตลอดอายุของ thread นั้น worker ที่เรียก CoInitializeEx(nil, COINIT_APARTMENTTHREADED) เมื่อมันเริ่มต้น รักษา apartment นั้นไว้สำหรับทุกการเรียก SendDocumentByMail ที่มันทำ และเรียก CoUninitialize แค่ครั้งเดียวเมื่อมันออก จะไม่เห็น RPC_E_CHANGED_MODE จากการส่งอีเมลของตัวเองเลย เพราะไม่มีอะไรอื่นบน thread นั้นได้โอกาสเริ่มต้น COM ในโหมดที่ขัดแย้งกันก่อน แต่ละการเรียก SendDocumentByMail แต่ละครั้งยังคงรันคู่ CoInitializeEx และ CoUninitialize ของตัวเองภายในภายใต้รูปแบบนี้ และนั่นไม่เป็นอันตราย ด้วย apartment ที่ worker thread สร้างไว้แล้ว ทุกการเรียกภายในเหล่านั้นตอนนี้เห็น S_FALSE เพิ่มและลด reference count เดียวกัน และทิ้ง COM apartment ของ worker thread เองไว้โดยไม่ถูกแตะ

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 นี้ที่ควรค่าแก่การสร้างเข้าไปใน logging ตั้งแต่วันแรก เพราะค่าที่คืนกลับมาแค่ 1-หรือ-0 เพียงอย่างเดียวไม่ได้บอกว่าการส่งที่ล้มเหลวเป็นปัญหาการเริ่มต้น COM, การปฏิเสธการยืนยันตัวตน SMTP หรือไฟล์แนบที่หายไป property MailProvider คือสิ่งที่ทำให้เส้นทางทั้งหมดทดสอบได้โดยไม่มีกล่องเมลจริง กำหนด implementation IPDFlibMailProvider ที่บันทึกคำขอแทนที่จะส่งมันให้กับมัน รันงาน batch กับ provider ปลอมนั้นใน CI pipeline และจุดเรียก SendDocumentByMail เดียวกันยังคงทำงานต่อไปโดยไม่เปลี่ยนแปลง เมื่อ MailProvider ถูกปล่อยไว้ไม่กำหนดและ PDFlibPas fallback ไปยัง transport CDO ในตัวใน production

งาน batch ที่ส่งอีเมลใบแจ้งยอดแทบไม่หยุดแค่การส่งเลย pipeline เดียวกันมักต้องตรวจสอบและเซ็น PDF ก่อนที่มันจะออกไป ซึ่งครอบคลุมแยกต่างหากในบทความเวิร์กเบนช์ด้านความสอดคล้องและการเซ็น เพราะ preflight และการตรวจสอบลายเซ็นเป็นเรื่องที่ต่างจากการส่งอีเมล แม้เมื่อทั้งคู่รันติดกันก็ตาม เมื่อเอกสารที่ถูกส่งอีเมลเองเป็นเอาต์พุตของงานรวมหรือแยกขนาดใหญ่ แทนที่จะเป็น PDF ที่สร้างขึ้นใหม่ไฟล์เดียว คู่มือ direct-access สำหรับ PDF ขนาดใหญ่ ครอบคลุมขั้นตอนการสร้างนั้น SendDocumentByMail และโมเดล mail provider ที่อธิบายตรงนี้เป็นส่วนหนึ่งของไลบรารีนักพัฒนา PDF PDFlibPasรุ่นมาตรฐานสำหรับ Delphi และ C++Builder และหน้าผลิตภัณฑ์มีเอกสารอ้างอิง API แบบเต็มควบคู่ไปกับดาวน์โหลดทดลองใช้