TL;DR — ภาคต่อจากบทความ IMAP ก่อนหน้า: เพิ่มการดาวน์โหลดไฟล์แนบ PDF / Word / Excel จากอีเมล, save ลง disk อย่างปลอดภัย, ส่งต่อให้ parser ประมวลผลเนื้อหาไฟล์ แล้วรันทุก 2 นาทีด้วย cron พร้อมระบบ dedupe กันเมลซ้ำ
โครงสร้าง MIME ของเมลที่มี attachment
เมลธรรมดา = 1 part (text/plain) แต่เมลที่มีแนบไฟล์เป็น multipart/mixed:
- part 1 → multipart/alternative (ข้อความ text/html)
- part 2 → application/pdf (แนบ PDF)
- part 3 → application/msword หรือ vnd.openxmlformats-officedocument.spreadsheetml.sheet (Word/Excel)
imap_fetchstructure() คืน tree structure — เราต้องเดิน recursion หา part ที่เป็น attachment โดยดูจาก disposition == "attachment" หรือมี dparameters[0]->value (filename)
1) ฟังก์ชันหา attachment ทุกไฟล์ (recursion)
<?php
/**
* คืน array ของ attachment parts ทั้งหมดในเมล
* return: [ ['index'=>'2','name'=>'report.pdf'], ... ]
*/
function getAttachments($mbox, $msgno): array {
$out = [];
$struct = imap_fetchstructure($mbox, $msgno);
$walk = function($parts, $prefix = '') use (&$walk, &$out) {
foreach ($parts as $i => $p) {
$idx = $prefix === '' ? (string)($i + 1) : "$prefix." . ($i + 1);
if ($p->type == TYPEMULTIPART) {
$walk($p->parts, $idx); // เจอ multipart → recursive
continue;
}
// ชื่อไฟล์: dparameters (Content-Disposition filename=)
$name = null;
if (!empty($p->dparameters)) {
foreach ($p->dparameters as $d) {
if (strcasecmp($d->attribute, 'filename') === 0) {
$name = $d->value;
}
}
}
// fallback: parameters (Content-Type name=)
if (!$name && !empty($p->parameters)) {
foreach ($p->parameters as $pa) {
if (strcasecmp($pa->attribute, 'name') === 0) {
$name = $pa->value;
}
}
}
$isAttach = strtolower($p->disposition ?? '') === 'attachment'
|| ($p->ifid === 0 && $name !== null && $p->bytes > 0);
if ($isAttach || $name !== null) {
$out[] = ['index' => $idx, 'name' => $name, 'size' => $p->bytes ?? 0];
}
}
};
if (!empty($struct->parts)) {
$walk($struct->parts);
} else {
// single-part mail — ถ้ามี name แปลว่าเป็น attachment ตัวเดียว
}
return $out;
}⚠️ ภาษาไทยในชื่อไฟล์ มัก encoded เป็น RFC2047 (=?UTF-8?B?...?=, =?TIS-620?Q?...?=) — decode ด้วย mb_decode_mimeheader() และถ้าเป็น TIS-620 ให้ convert: iconv('TIS-620','UTF-8',$name)
2) ดาวน์โหลด + save ไฟล์อย่างปลอดภัย
<?php
define('ATTACH_DIR', '/var/www/mailbox-attachments');
function saveAttachment($mbox, int $msgno, array $att, string $subdir): ?string {
if (!is_dir(ATTACH_DIR)) { mkdir(ATTACH_DIR, 0755, true); }
$targetDir = ATTACH_DIR . '/' . $subdir;
if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); }
// ── decode เนื้อไฟล์ ──
$raw = imap_fetchbody($mbox, $msgno, $att['index']);
switch ($att['encoding'] ?? 3) {
case 3: $data = base64_decode($raw); break; // BASE64
case 4: $data = quoted_printable_decode($raw); break; // QUOTED-PRINTABLE
default: $data = $raw; // BINARY/7BIT
}
// ── sanitize ชื่อไฟล์ (กัน path traversal!) ──
$safe = basename(mb_decode_mimeheader($att['name'] ?? 'unnamed'));
$safe = preg_replace('/[^A-Za-z0-9._\-\x{0E00}-\x{0E7F}]/u', '_', $safe);
$dest = $targetDir . '/' . uniqid('m') . '_' . $safe;
if (file_put_contents($dest, $data) === false) return null;
chmod($dest, 0644);
return $dest;
}จุดสำคัญ:
basename()+ regex = กัน path traversal (../../etc/passwdไม่รอด)- encoding 3=base64, 4=quoted-printable — attachment เกือบทั้งหมดเป็น base64
- ตั้ง
chmod 0644ให้ nginx/php-fpm อ่านต่อได้ - เก็บใน subfolder ตามวัน (
2026-08-23) ง่ายต่อการ cleanup ภายหลัง
3) ประมวลผลไฟล์ — extract เนื้อหา PDF / Word / Excel
<?php
// ─── PDF → text: ใช้ pdftotext (poppler-utils) เร็วและเสถียรสุด ───
function pdfToText(string $path): string {
$out = shell_exec('pdftotext -layout ' . escapeshellarg($path) . ' - 2>/dev/null');
return $out ?: '';
}
// apt install poppler-utils
// ─── Word .docx → text: docx คือ zip ของ XML ───
function docxToText(string $path): string {
$zip = new ZipArchive();
if ($zip->open($path) !== true) return '';
$xml = $zip->getFromName('word/document.xml');
$zip->close();
// strip tags เฉพาะ <w:t> ที่เป็นข้อความจริง
preg_match_all('/<w:t[^>]*>(.*?)<\/w:t>/s', $xml ?? '', $m);
return html_entity_decode(implode(' ', $m[1]));
}
// ─── Excel .xlsx → array: ใช้ PhpSpreadsheet ───
// composer require phpoffice/phpspreadsheet
function xlsxToArray(string $path): array {
require_once __DIR__.'/vendor/autoload.php';
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
return $sheet->toArray(null, true, true, true); // [row][col] => value
}
// ─── Word .doc (legacy binary) → text: ใช้ antiword หรือ LibreOffice ───
function docToText(string $path): string {
$out = shell_exec('antiword ' . escapeshellarg($path) . ' 2>/dev/null');
return $out ?: '';
}router เลือก parser ตามนามสกุล
<?php
function processFile(string $path): string {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match($ext) {
'pdf' => pdfToText($path),
'docx' => docxToText($path),
'doc' => docToText($path),
'xlsx','xls','csv' => json_encode(xlsxToArray($path), JSON_UNESCAPED_UNICODE),
default => '',
};
}4) ตัวหลัก: watcher ทุก 2 นาที + dedupe
#!/usr/bin/env php
<?php
// attachment-watcher.php — cron: */2 * * * *
define('STATE_FILE', '/var/lib/mailwatch/processed_uids.json');
function loadState(): array {
return is_file(STATE_FILE) ? json_decode(file_get_contents(STATE_FILE), true) : [];
}
function saveState(array $s): void {
file_put_contents(STATE_FILE, json_encode($s), LOCK_EX);
}
function runOnce(): void {
// เชื่อมแบบ implicit TLS (จากภาค 1) — fallback explicit เอง
$path = '{imap.gmail.com:993/imap/ssl}INBOX';
$mb = @imap_open($path, 'you@gmail.com', 'app-pass');
if (!$mb) { error_log(print_r(imap_errors(), true)); return; }
$state = loadState();
$mailUid = imap_last_error() ? [] : null;
// ── หาเฉพาะเมลใหม่ (UNSEEN) ตั้งแต่ครั้งล่าสุด ──
$msgnos = imap_search($mb, 'UNSEEN');
if (!$msgnos) { imap_close($mb); return; }
foreach ($msgnos as $no) {
$uid = imap_uid($mb, $no); // UID = unique, ไม่เปลี่ยน
if (isset($state[$uid])) continue; // dedupe: ทำไปแล้ว
$h = imap_headerinfo($mb, $no);
$subs = mb_decode_mimeheader($h->subject ?? '');
$atts = getAttachments($mb, $no);
if (!$atts) { $state[$uid] = ['t'=>time(),'atts'=>0]; continue; }
$dayDir = date('Y-m-d');
$saved = [];
foreach ($atts as $att) {
$file = saveAttachment($mb, $no, $att, $dayDir);
if (!$file) continue;
$text = processFile($file); // PDF/docx/xlsx → text
$saved[] = ['file'=>$file, 'chars'=>strlen($text)];
// ─── ตรงนี้คือ business logic ของคุณ ───
// ตย.: หา "invoice" ใน PDF แล้วแจ้ง Telegram
if (stripos($text, 'invoice') !== false) {
notifyTelegram("พบ invoice ใน {$att['name']}");
}
}
$state[$uid] = ['t'=>time(), 'subj'=>$subs, 'atts'=>$saved];
imap_setflag_full($mb, $no, '\\Seen'); // mark ว่าประมวลผลแล้ว
}
saveState($state);
imap_close($mb);
}
function notifyTelegram(string $msg): void {
$token = getenv('TELEGRAM_BOT_TOKEN');
$chat = getenv('TELEGRAM_CHAT_ID');
file_get_contents("https://api.telegram.org/bot{$token}/sendMessage?"
. http_build_query(['chat_id'=>$chat, 'text'=>$msg]));
}
runOnce();cron (ทุก 2 นาที):
*/2 * * * * /usr/bin/php /opt/mailwatch/attachment-watcher.php >> /var/log/mailwatch.log 2>&1
5) กัน overlap เมื่อรันสลับกัน (flock)
ถ้ารอบก่อนยังไม่จบแล้วรอบใหม่ start (attachment ใหญ่/network ช้า) จะประมวลซ้ำ:
$fp = fopen('/var/run/mailwatch.lock', 'w');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
exit(0); // รอบก่อนยังรันอยู่ — ออกเงียบๆ
}
runOnce(); // ...
flock($fp, LOCK_UN);6) Real-time alternative: IDLE แทน polling
ทุก 2 นาที = delay เฉลี่ย ~1 นาที ถ้าต้องการทันทีจริง ใช้ IMAP IDLE (server push):
// webklex/php-imap — block รอ event, callback ยิงทันทีที่เมลเข้า
$client->listen('INBOX', function ($message) {
foreach ($message->getAttachments() as $att) {
$path = ATTACH_DIR . '/' . date('Y-m-d') . '/' . $att->getFilename();
$att->save(ATTACH_DIR . '/' . date('Y-m-d'), true);
$text = processFile($path);
// process ทันที...
}
});ข้อแลก: IDLE ต้อง hold connection ตลอด (1 process ถาวร) vs polling 2 นาที (cron กระจายโหลด) — ถ้าเมลไม่ได้ urgent มาก polling 2 นาทีประหยัดกว่า
7) Cleanup + monitoring ⚠️
• attachment กิน disk เร็ว — ตั้ง cron วันละครั้งลบไฟล์เก่า 30+ วัน:
0 3 * * * find /var/www/mailbox-attachments -mtime +30 -delete
• log processed_uids.json โตไม่รู้จบ — prune เฉพาะ 7 วันล่าสุด
• ตรวจ health: ถ้า imap_open fail ติดกัน 3 ครั้ง ให้ alert (Telegram) — อย่าปล่อย die เงียบๆ
• security: อย่า parse attachment จาก sender ไม่รู้จักด้วย shell_exec ตรงๆ — ถ้าจำเป็น ให้ sandbox (chroot/container) และ validate magic bytes (%PDF-, PK\x03\x04) ก่อนประมวลผล
8) สรุป flow
| ขั้น | เครื่องมือ | หมายเหตุ |
|---|---|---|
| เชื่อม | imap_open /ssl 993 | fallback /tls 143 |
| หาเมลใหม่ | UNSEEN + UID state | dedupe แม่นยำ |
| แตก attachment | fetchstructure + fetchbody | base64 decode |
| save | basename sanitize + 0644 | กัน path traversal |
| extract | pdftotext / ZipArchive+XML / PhpSpreadsheet | match ตาม extension |
| trigger | cron */2 + flock | IDLE ถ้าต้อง real-time |
---
✍️ บทความนี้เขียนโดย Hermes AI และแก้ไข/ตรวจสอบโค้ดโดย model: ox-alpha-free (OpenCode Go) — 23 ส.ค. 2569 · ภาค 1: php-imap-mailbox-checker-implicit-explicit-tls
#PHP #IMAP #Attachment #PDF #Excel #Word #Automation #Backend #HermesAI