TL;DR — Use case: mail server อยู่ที่อื่น (remote IMAP TLS) แต่ web server เป็น PHP 8 + MySQL บน Windows — เลือก PHP ฝั่งเดียวจบ: worker.php ดึงเมลด้วย webklex/php-imap (เพราะ php_imap.dll ไม่มีบน Windows PHP 8 แล้ว) → save attachment → ลง MySQL → เขียน status.json ทุกรอบ → เปิด API endpoint ให้ระบบอื่น query → HTML mini-dashboard โชว์จำนวน + เวลาตรวจล่าสุด — รันทุก 2 นาทีด้วย Task Scheduler
เงื่อนไขและการเลือก solution
| ปัจจัย | ค่า | ผลต่อการเลือก |
|---|---|---|
| Mail server | remote IMAP 993/143 | client ต้องเข้าถึงจาก web server |
| Web server | PHP 8 + MySQL บน Windows | runtime พร้อมอยู่แล้ว |
| Zero-install? | ไม่ได้ห้าม (PHP มีอยู่แล้ว) | PHP ชนะเลย |
| ต้องการ API + report | ระบบอื่น query ได้ | www root เดียว = ง่ายสุด |
เหตุผลที่เลือก PHP: stack PHP+MySQL มีคนดูแลอยู่แล้ว worker/API/dashboard เป็นภาษาเดียว deploy รวม 1 folder PDO คุย MySQL ตรง — Go จะเจ๋งก็ต่อเมื่อ zero-install (บทความก่อน)
ข้อควรระวังสำคัญ: ext-imap บน Windows PHP 8 ไม่มีแล้ว (ถอดตั้งแต่ 8.0) → ใช้ webklex/php-imap (pure PHP)
Architecture
[Mail Server ที่อื่น]
│ IMAP TLS 993
▼
[Task Scheduler ทุก 2 นาที]
▼
worker.php ──► save attachments ──► INSERT MySQL (mail_log)
│
├──► เขียน status.json ทุกรอบ
▼
[API] api/status.php ──► JSON healthcheck
▼
[Dashboard] index.html ──► fetch API ทุก 30 วิ1) ตาราง MySQL
CREATE DATABASE mailwatch CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mailwatch;
CREATE TABLE mail_log (
id INT AUTO_INCREMENT PRIMARY KEY,
uid BIGINT NOT NULL,
subject VARCHAR(500),
from_addr VARCHAR(255),
attach_name VARCHAR(500),
attach_path VARCHAR(500),
status ENUM('ok','error','skipped') DEFAULT 'ok',
error_msg VARCHAR(500) DEFAULT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_uid_attach (uid, attach_name(190)),
KEY idx_fetched (fetched_at)
);
CREATE TABLE run_log (
id INT AUTO_INCREMENT PRIMARY KEY,
started_at DATETIME,
ended_at DATETIME,
mails_seen INT DEFAULT 0,
mails_new INT DEFAULT 0,
files_ok INT DEFAULT 0,
files_err INT DEFAULT 0,
run_status ENUM('running','done','failed') DEFAULT 'running'
);mail_log— unique key (uid, attach_name) = dedupe ระดับ DBrun_log— ประวัติทุกรอบ ใช้ทำ report/graph ภายหลัง
2) Worker — worker.php
<?php
// worker.php — Task Scheduler ทุก 2 นาที
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Webklex\PHPIMAP\ClientManager;
const ATTACH_DIR = __DIR__ . '/attachments';
const STATUS_JSON = __DIR__ . '/status.json';
$DB = new PDO('mysql:host=localhost;dbname=mailwatch;charset=utf8mb4',
'mailuser', getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 5,
]);
function logLine(string $lvl, string $msg): void {
fprintf(STDERR, "%s %-7s %s\n", date('H:i:s'), strtoupper($lvl), $msg);
}
// ── mark run start ──
$DB->prepare("INSERT INTO run_log (started_at, run_status) VALUES (NOW(),'running')")
->execute();
$runId = (int)$DB->lastInsertId();
try {
// ── connect remote mail server (implicit TLS) ──
$cm = new ClientManager([]);
$client = $cm->make([
'host' => 'mail.other-company.co.th',
'port' => 993,
'encryption' => 'ssl',
'validate_cert' => true,
'username' => 'bot@ourcompany.com',
'password' => getenv('MAIL_PASS'),
'protocol' => 'imap',
]);
$client->connect();
logLine('info', 'connected');
$folder = $client->getFolder('INBOX');
$messages = $folder->query()->unseen()->limit(50)->get();
$newMails = 0; $filesOk = 0; $filesErr = 0;
foreach ($messages as $msg) {
$uid = $msg->getUid();
$subject = mb_decode_mimeheader((string)$msg->getSubject());
$fromObj = $msg->getFrom()[0] ?? null;
$from = $fromObj ? ($fromObj->mail ?? '') : '';
foreach ($msg->getAttachments() as $att) {
$name = mb_decode_mimeheader($att->getName() ?? 'unnamed');
$safe = preg_replace('/[^\w.\-\x{0E00}-\x{0E7F}]/u', '_',
basename($name));
$dayDir = ATTACH_DIR . '/' . date('Y-m-d');
if (!is_dir($dayDir)) { mkdir($dayDir, 0755, true); }
$dest = "$dayDir/" . uniqid('m_') . '_' . $safe;
try {
$att->save($dayDir, true);
@rename("$dayDir/{$att->getName()}", $dest);
$DB->prepare("INSERT IGNORE INTO mail_log
(uid,subject,from_addr,attach_name,attach_path,status)
VALUES (?,?,?,?,?,?)")
->execute([$uid,$subject,$from,$name,$dest,'ok']);
$filesOk++;
} catch (Throwable $e) {
$DB->prepare("INSERT IGNORE INTO mail_log
(uid,subject,from_addr,attach_name,status,error_msg)
VALUES (?,?,?,?,?,?)")
->execute([$uid,$subject,$from,$name,'error',
substr($e->getMessage(),0,480)]);
$filesErr++;
}
}
$msg->setFlag('Seen');
$newMails++;
}
$client->disconnect();
$DB->prepare("UPDATE run_log SET ended_at=NOW(), run_status='done',
mails_seen=?, mails_new=?, files_ok=?, files_err=? WHERE id=?")
->execute([count($messages), $newMails, $filesOk, $filesErr, $runId]);
writeStatusJson(count($messages), $newMails, $filesOk, $filesErr);
logLine('info', "done mails=$newMails ok=$filesOk err=$filesErr");
} catch (Throwable $e) {
$DB->prepare("UPDATE run_log SET ended_at=NOW(), run_status='failed',
error_msg=? WHERE id=?")
->execute([substr($e->getMessage(),0,480), $runId]);
writeStatusJson(0,0,0,0, substr($e->getMessage(),0,200));
logLine('error', $e->getMessage());
exit(1);
}
function writeStatusJson(int $seen,int $new,int $ok,int $err,string $lastErr='') {
global $DB;
$prev = is_file(STATUS_JSON)
? json_decode(file_get_contents(STATUS_JSON), true) : [];
$q = $DB->query("SELECT
(SELECT COUNT(*) FROM mail_log WHERE status='ok') AS tf,
(SELECT COUNT(*) FROM mail_log WHERE status='error') AS te,
(SELECT COUNT(DISTINCT uid) FROM mail_log) AS tm")->fetch();
$data = [
'last_run_at' => date('c'),
'last_run_ok' => $lastErr === '',
'last_error' => $lastErr,
'mails_seen' => $seen,
'mails_new' => $new,
'files_ok' => $ok,
'files_err' => $err,
'total_files' => (int)$q['tf'],
'total_errors' => (int)$q['te'],
'total_mails' => (int)$q['tm'],
'uptime_checks' => ($prev['uptime_checks'] ?? 0) + 1,
];
file_put_contents(STATUS_JSON,
json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
LOCK_EX);
chmod(STATUS_JSON, 0644);
}3) API — api/status.php
<?php
// api/status.php — GET คืน JSON สถานะล่าสุด
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$root = dirname(__DIR__);
$statusFile = $root . '/status.json';
if (!is_file($statusFile)) {
http_response_code(503);
echo json_encode(['ok'=>false,'error'=>'worker never ran yet']);
exit;
}
$s = json_decode(file_get_contents($statusFile), true);
// healthy = worker ต้องรันภายใน 10 นาทีล่าสุด (2 นาที interval + buffer)
$lastTs = strtotime($s['last_run_at']);
$healthy = (time() - $lastTs) < 600;
echo json_encode([
'ok' => $healthy && ($s['last_run_ok'] ?? false),
'healthy' => $healthy,
'last_run_at' => $s['last_run_at'],
'seconds_ago' => time() - $lastTs,
'total_mails' => $s['total_mails'],
'total_files' => $s['total_files'],
'total_errors' => $s['total_errors'],
'uptime_checks' => $s['uptime_checks'],
], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE);response ตัวอย่าง:
{
"ok": true,
"healthy": true,
"last_run_at": "2026-08-23T22:30:04+07:00",
"seconds_ago": 42,
"total_mails": 187,
"total_files": 342,
"total_errors": 2,
"uptime_checks": 741
}ระบบอื่นเรียก: curl https://intranet.local/mailwatch/api/status.php — เอาไปผูก Nagios/Zabbix/healthcheck ได้เลย
4) Dashboard — index.html
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<title>Mail Watcher Status</title>
<style>
body{font-family:'Segoe UI',Tahoma,sans-serif;background:#0d1117;color:#c9d1d9;
display:flex;justify-content:center;padding:40px;margin:0}
.card{background:#161b22;border:1px solid #30363d;border-radius:12px;
padding:24px 32px;min-width:340px;box-shadow:0 4px 16px rgba(0,0,0,.4)}
h1{margin:0 0 8px;font-size:1.1rem;color:#58a6ff}
.row{display:flex;justify-content:space-between;margin:10px 0;font-size:.95rem}
.val{font-weight:700;color:#f0f6fc}
.pill{padding:3px 12px;border-radius:999px;font-weight:700;font-size:.85rem}
.ok{background:#12331f;color:#3fb950}
.down{background:#3d1418;color:#f85149}
.time{margin-top:14px;font-size:.8rem;color:#8b949e;text-align:center}
</style>
</head>
<body>
<div class="card">
<h1>📬 Mail Watcher</h1>
<div style="text-align:right"><span id="pill" class="pill ok">● loading…</span></div>
<div class="row"><span>📧 เมลทั้งหมด</span><span class="val" id="tm">–</span></div>
<div class="row"><span>📎 ไฟล์แนบทั้งหมด</span><span class="val" id="tf">–</span></div>
<div class="row"><span>⚠️ Error</span><span class="val" id="te">–</span></div>
<div class="row"><span>🔄 ตรวจแล้ว (ครั้ง)</span><span class="val" id="uc">–</span></div>
<div class="time">ตรวจล่าสุด: <b id="la">–</b><br>(refresh อัตโนมัติทุก 30 วินาที)</div>
</div>
<script>
async function load(){
try{
const r = await fetch('api/status.php',{cache:'no-store'});
const d = await r.json();
tm.textContent = d.total_mails.toLocaleString();
tf.textContent = d.total_files.toLocaleString();
te.textContent = d.total_errors.toLocaleString();
uc.textContent = d.uptime_checks.toLocaleString();
la.textContent = new Date(d.last_run_at).toLocaleString('th-TH')
+ ' (' + d.seconds_ago + ' วิที่แล้ว)';
pill.className = 'pill ' + (d.ok ? 'ok' : 'down');
pill.textContent = d.ok ? '● ONLINE' : '● PROBLEM';
}catch(e){
pill.className='pill down'; pill.textContent='● API unreachable';
}
}
load(); setInterval(load, 30000);
</script>
</body>
</html>วางใน www root → เปิด http://server/mailwatch/ — dashboard refresh ตัวเองทุก 30 วิ
5) Task Scheduler ทุก 2 นาที
$action = New-ScheduledTaskAction -Execute "C:\php8\php.exe" `
-Argument "C:\inetpub\wwwroot\mailwatch\worker.php >> watcher.log 2>&1"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 2)
$set = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName "MailWatcher" `
-Action $action -Trigger $trigger -Settings $set `
-User "SYSTEM" -RunLevel Highest- ExecutionTimeLimit 5m = กัน worker ค้างซ้อนรอบ (เทียบเท่า flock)
- StartWhenAvailable = ถ้าเครื่อง sleep/miss trigger จะ run ทันทีที่กลับมา
8) กรณี web server เป็น Apache (แทน IIS) — ต้องทำอะไรเพิ่ม
ข่าวดี: โค้ด PHP ทั้งหมดไม่ต้องแก้แม้บรรทัดเดียว — worker.php, API, dashboard, MySQL, Task Scheduler เหมือนเดิมทั้งหมด เพราะเป็น pure PHP + PDO ไม่ผูกกับ IIS สิ่งที่ต้องปรับคือ "ฝั่ง Apache" ล้วนๆ
8.1) VirtualHost พื้นฐาน (httpd-vhosts.conf)
<VirtualHost *:80>
ServerName mailwatch.local
DocumentRoot "C:/Apache24/htdocs/mailwatch"
<Directory "C:/Apache24/htdocs/mailwatch">
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# ── ห้ามเข้า attachments จาก web โดยตรง (สำคัญสุด!) ──
<DirectoryMatch "^C:/Apache24/htdocs/mailwatch/attachments">
Require all denied
</DirectoryMatch>
ErrorLog "logs/mailwatch-error.log"
CustomLog "logs/mailwatch-access.log" combined
</VirtualHost>Options -Indexes= ไม่ให้ list ไฟล์ใน directory<DirectoryMatch ...attachments>= block direct download ไฟล์แนบ — ถ้าจะให้ดาวน์โหลด ต้องผ่าน download.php ที่ validate path เสมอ- บน Linux path เปลี่ยนเป็น
/var/www/mailwatchและ user ของ process คือwww-data
8.2) .htaccess (แบบ portability สูงกว่า DirectoryMatch)
วางไว้ที่ root project:
# .htaccess — mailwatch/
Options -Indexes
# block attachment folder
RewriteRule ^attachments/ - [F,L]
# block sensitive files
<FilesMatch "\.(json|log|sql|env)$">
Require all denied
</FilesMatch>
# API: force JSON content-type + no-cache
<IfModule mod_headers.c>
<Files "status.php">
Header set Cache-Control "no-store, no-cache"
</Files>
</IfModule>⚠️ ข้อแตกต่างสำคัญจาก IIS: .htaccess ต้องเปิด AllowOverride All ใน vhost ก่อน (ด้านบนทำแล้ว) ไม่งั้น rewrite rules เงียบไม่ทำงาน
8.3) กัน status.json โดน web เข้าถึง — 2 ทางเลือก
ทาง A (แนะนำ): ย้าย status.json ออกนอก DocumentRoot
C:/apps/mailwatch/status.json ← อยู่นอก htdocs C:/Apache24/htdocs/mailwatch/ ← web root (worker/api/dashboard)
แก้ใน worker.php: define('STATUS_JSON', 'C:/apps/mailwatch/status.json');
แก้ใน api/status.php: $statusFile = 'C:/apps/mailwatch/status.json';
ทาง B: คงที่เดิม + block ด้วย FilesMatch (.json ถูก deny แล้วจาก .htaccess ด้านบน) — สะดวกแต่พึ่ง config ทั้งหมด
8.4) PHP บน Apache — mod_php vs FastCGI
| แบบ | วิธี | เหมาะกับ |
|---|---|---|
| mod_php (แพร่หลายสุดบน Windows) | LoadModule php_module ใน httpd.conf | POC/intranet — ง่ายสุด |
| FastCGI (mod_fcgid) | spawn php-cgi.exe pool | production — isolate ดีกว่า, restart PHP ได้ไม่กระทบ Apache |
worker.php รันผ่าน CLI (php.exe worker.php) โดยตรงจาก Task Scheduler — ไม่เกี่ยวกับ Apache mode ใดๆ ดังนั้นเลือกแบบไหนก็ได้ตามมาตรฐานองค์กร
8.5) HTTPS ถ้า API อยู่นอก intranet
<VirtualHost *:443>
ServerName mailwatch.example.com
SSLEngine on
SSLCertificateFile "C:/Apache24/conf/ssl/mailwatch.crt"
SSLCertificateKeyFile "C:/Apache24/conf/ssl/mailwatch.key"
# ... DocumentRoot/Directory เหมือน vhost 80 ...
</VirtualHost>
# redirect 80 → 443
<VirtualHost *:80>
ServerName mailwatch.example.com
Redirect permanent / https://mailwatch.example.com/
</VirtualHost>API ที่ส่ง status/attachment info ควรอยู่หลัง HTTPS เสมอ ถ้ามีการ call ข้าม network
8.6) Checklist ต่างจาก IIS
| งาน | IIS | Apache |
|---|---|---|
| Block attachments dir | web.config <requestFiltering> | DirectoryMatch deny / .htaccess |
| Hide .json/.log | requestFiltering fileExtensions | FilesMatch deny |
| Force no-cache API | customHeaders | mod_headers |
| Schedule worker | Task Scheduler (เหมือนกัน) | Task Scheduler (เหมือนกัน — ไม่ใช้ cron เพราะ Windows) |
| PHP handler | CGI/FastCGI mapping | mod_php หรือ mod_fcgid |
สรุป: สิ่งเดียวที่ "ต้องทำเพิ่ม" จริงๆ คือ กัน attachments + status.json จาก web access (ไม่ว่าจะ vhost deny หรือ .htaccess) ส่วนที่เหลือเป็น optional hardening ทั้งหมด — โค้ดหลักไม่แตะแม้แต่ตัวเดียว