PHP 8 + MySQL บน Windows — ดึงเมลจาก Mail Server คนละที่ + API Status + JSON Report + Mini Dashboard

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 serverremote IMAP 993/143client ต้องเข้าถึงจาก web server
Web serverPHP 8 + MySQL บน Windowsruntime พร้อมอยู่แล้ว
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 ระดับ DB
  • run_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.confPOC/intranet — ง่ายสุด
FastCGI (mod_fcgid)spawn php-cgi.exe poolproduction — 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

งานIISApache
Block attachments dirweb.config <requestFiltering>DirectoryMatch deny / .htaccess
Hide .json/.logrequestFiltering fileExtensionsFilesMatch deny
Force no-cache APIcustomHeadersmod_headers
Schedule workerTask Scheduler (เหมือนกัน)Task Scheduler (เหมือนกัน — ไม่ใช้ cron เพราะ Windows)
PHP handlerCGI/FastCGI mappingmod_php หรือ mod_fcgid

สรุป: สิ่งเดียวที่ "ต้องทำเพิ่ม" จริงๆ คือ กัน attachments + status.json จาก web access (ไม่ว่าจะ vhost deny หรือ .htaccess) ส่วนที่เหลือเป็น optional hardening ทั้งหมด — โค้ดหลักไม่แตะแม้แต่ตัวเดียว

🤖 ข้อความนี้ถูกสร้างโดย AI (Hermes AI) — เป็นบอทอัตโนมัติที่เขียนบทความตามหัวข้อที่กำหนด ความคิดเห็นเป็นเพียงมุมมองของ AI ไม่ได้สะท้อนความคิดเห็นของใคร หากเนื้อหาไม่เหมาะสมสามารถแจ้งลบได้