EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / ดูดเมลเข้าระบบโดยไม่ทำลายกล่องจดหมายของคนอื่น
Linux

ดูดเมลเข้าระบบโดยไม่ทำลายกล่องจดหมายของคนอื่น

โดย Ton-Or ·13 ต.ค. 2026·อ่าน 10 นาที
ดูดเมลเข้าระบบโดยไม่ทำลายกล่องจดหมายของคนอื่น

โจทย์ฟังดูง่ายมาก ให้ระบบไปอ่านเมลในกล่องกลางแล้วเอาเข้าคิวงาน ที่ยากคือกล่องนั้นมีคนนั่งใช้อยู่จริง และเขาไม่ควรรู้สึกเลยว่ามีโปรแกรมมาแอบอ่านอยู่ด้วย

พอดีมีงานที่ต้องให้แอปไปดึงเมลจากกล่องกลางของทีมเข้ามาเป็นรายการงานในระบบ ฟังดูเป็นงานเล็กครับ ต่อ IMAP ดึงเมลใหม่ บันทึกลงฐาน จบ แต่พอเริ่มออกแบบจริงถึงเจอว่าเงื่อนไขข้อแรกมันหนักกว่าที่คิด คือกล่องนั้นมีคนนั่งใช้งานอยู่จริงทุกวัน เปิดอ่านทั้งบนมือถือทั้งบนเว็บเมล จัดโฟลเดอร์เอง ลบเมลขยะเอง แปลว่าโปรแกรมของผมห้ามไปเปลี่ยนสถานะอะไรในกล่องนั้นเลยแม้แต่ตัวเดียว และห้ามพลาดเมลด้วย ผมเลยเขียนสรุปไว้ทั้งกับดักที่เกือบตกและวิธีที่ใช้อยู่ตอนนี้

ชื่อโดเมน ชื่อบัญชี และบริบทงานในบทความเป็นของสมมติทั้งหมด ส่วนตรรกะและโครงตารางคือของที่ใช้จริง

กับดักข้อแรก: อย่าใช้ UNSEEN เป็นตัวชี้ว่าอะไรใหม่

ตัวอย่างที่หาเจอทั่วอินเทอร์เน็ตเกือบทั้งหมดสอนแบบเดียวกัน คือค้นเมลที่ยังไม่ถูกอ่านแล้วดึงมาประมวลผล

// วิธีที่เจอบ่อยที่สุด และเป็นวิธีที่พังกับกล่องที่มีคนใช้จริง
$messages = $folder->query()->unseen()->get();
ลำดับเหตุการณ์ที่ทำให้เมลหายเงียบเมลเข้ากล่อง 08:57สถานะยังไม่อ่าน->เจ้าของเปิดอ่านบนมือถือ 08:58สถานะกลายเป็นอ่านแล้ว->cron ทำงาน 09:00ค้นด้วย UNSEEN ไม่เจอฉบับนี้->เมลไม่เข้าระบบไม่มี error ไม่มีใครรู้
สัญญาณที่มนุษย์เป็นเจ้าของ ห้ามเอามาใช้เป็นสถานะของโปรแกรม

สถานะอ่านหรือยังไม่อ่านเป็นของเจ้าของกล่อง เขาเปลี่ยนมันเมื่อไหร่ก็ได้ด้วยเหตุผลของเขาเอง โปรแกรมที่เอาสัญญาณนี้มาใช้เป็นตัวชี้ว่า "อะไรที่ยังไม่ได้ทำ" คือโปรแกรมที่ฝากความถูกต้องของตัวเองไว้กับพฤติกรรมของคน แล้วเวลาพลาดมันพลาดแบบเงียบที่สุด คือไม่มี error ไม่มี log ไม่มีอะไรทั้งนั้น รู้อีกทีตอนมีคนถามว่าทำไมงานนี้ไม่เข้าระบบ

ที่แย่กว่านั้นคือถ้าโปรแกรมดึงเมลแล้วมาร์คว่าอ่านแล้วเพื่อกันดึงซ้ำ เท่ากับเราไปทำลายวิธีทำงานของเจ้าของกล่องด้วย เขาใช้สถานะยังไม่อ่านเป็นรายการสิ่งที่ต้องทำของเขาเอง อยู่ ๆ มันหายไปหมดทุกเช้าเพราะโปรแกรมเราไปกวาดก่อน อันนี้เป็นเรื่องที่ผมเจอมาแล้วในระบบอื่นและโดนบ่นยาว

ตัวชี้ที่ถูกต้องคือ UID แต่ต้องมาคู่กับ UIDVALIDITY

IMAP มีเลขประจำตัวเมลในโฟลเดอร์ชื่อ UID ซึ่งเพิ่มขึ้นเรื่อย ๆ และไม่ถูกใช้ซ้ำ เราเก็บเลขล่าสุดที่ประมวลผลสำเร็จไว้ แล้วรอบหน้าค้นเฉพาะตัวที่มากกว่านั้น

// ค้นด้วยช่วง UID ไม่ยุ่งกับสถานะอ่าน/ยังไม่อ่านเลย
$from = ((int) $account->last_uid) + 1;
$messages = $client->getFolder($account->folder)
    ->query()
    ->setFetchBody(false)
    ->getByUidSequence("{$from}:*", limit: 25);   // ทีละ 25 ฉบับ กัน memory บวม

แต่ UID เชื่อถือได้เฉพาะเมื่อ UIDVALIDITY ของโฟลเดอร์ไม่เปลี่ยน ค่านี้คือเลขรุ่นของโฟลเดอร์ ถ้าฝั่งเซิร์ฟเวอร์สร้างโฟลเดอร์ใหม่ ย้ายเมล หรือกู้คืนจากไฟล์สำรอง ค่านี้จะเปลี่ยน แล้ว UID ทั้งหมดที่เราเก็บไว้จะชี้ไปที่เมลคนละฉบับกับที่เราคิด

ใน transaction เดียวUIDVALIDITYตรวจทุกรอบค้นด้วย UIDUID > last_uidทีละ 25 ฉบับBODY.PEEKไม่ตั้งธง SeenบันทึกลงฐานinsertOrIgnorelast_uidอัปหลังบันทึกUIDVALIDITY เปลี่ยนreset last_uid = 0 แล้วดึงทั้งโฟลเดอร์เมื่อค่าเปลี่ยนดึงซ้ำทั้งโฟลเดอร์ได้ เพราะ dedupe_key แบบ UNIQUE กันแถวซ้ำ
หนึ่งรอบของงานดึงเมล ตรวจ UIDVALIDITY ก่อน ค้นเฉพาะ UID ที่ใหม่กว่า last_uid อ่านแบบ PEEK แล้วบันทึกกับอัปเดต last_uid ใน transaction เดียว ถ้าค่าโฟลเดอร์เปลี่ยนจะเริ่มนับใหม่และดึงทั้งโฟลเดอร์
// ต้องตรวจทุกรอบ ไม่ใช่ตรวจครั้งเดียวตอนตั้งค่า
$uidvalidity = $folder->getStatus()['uidvalidity'] ?? null;

if ($account->uidvalidity !== null && (int) $account->uidvalidity !== (int) $uidvalidity) {
    Log::warning('uidvalidity changed — reset last_uid', ['account' => $account->id]);
    $account->last_uid    = 0;          // เริ่มนับใหม่ทั้งโฟลเดอร์
    $account->uidvalidity = $uidvalidity;
    $account->save();
}
reset แล้วดึงใหม่ทั้งโฟลเดอร์ ต้องไม่ได้ข้อมูลซ้ำ

การ reset last_uid เป็นศูนย์แปลว่ารอบถัดไปเราจะดึงเมลทั้งโฟลเดอร์เข้ามาใหม่หมด ถ้าระบบกันซ้ำของเราอ่อน ผู้ใช้จะเห็นงานเดิมโผล่ซ้ำเป็นร้อยรายการในเช้าวันเดียว เพราะอย่างนี้การกันซ้ำต้องแข็งพอที่จะรองรับการดึงซ้ำทั้งโฟลเดอร์ได้ ถือเป็นเงื่อนไขบังคับ ไม่ใช่ของเสริม

BODY.PEEK คืออ่านโดยไม่ทิ้งรอย

คำสั่ง FETCH ปกติที่ขอ BODY จะตั้งธง Seen ให้อัตโนมัติ ตัวที่ต้องใช้คือ BODY.PEEK ซึ่งทำงานเหมือนกันทุกอย่างยกเว้นไม่แตะธง ไลบรารีที่ดีจะมีสวิตช์ให้ตั้ง แต่ต้องไปหาให้เจอเพราะค่าเริ่มต้นของหลายตัวไม่ใช่อันนี้

// เปิดโหมด peek ให้ชัด และปิดทุกทางที่จะเขียนกลับ mailbox
$client = Client::make([
    'host'          => 'mail.example.com',
    'port'          => 993,
    'encryption'    => 'ssl',
    'validate_cert' => true,
    'username'      => env('MAIL_INGEST_USER'),
    'password'      => env('MAIL_INGEST_PASS'),
    'protocol'      => 'imap',
    'options'       => [
        'fetch'          => \Webklex\PHPIMAP\IMAP::FT_PEEK,  // ไม่ตั้งธง Seen
        'soft_fail'      => true,
        'fetch_flags'    => true,
        'message_key'    => 'uid',
    ],
]);

ส่วนการบังคับว่าโค้ดของเราไม่มีทางเขียนกลับ ผมไม่ใช้วิธีอ่านเอาเองว่าเขียนไว้ถูกแล้ว แต่ใส่เป็นขั้นตอนในการตรวจงานไปเลย grep หาคำสั่งที่เปลี่ยนสถานะทั้งหมด ผลต้องว่าง

# ด่านตรวจก่อน deploy: ผลต้องไม่มีสักบรรทัด
grep -rn --include='*.php' \
  -e 'setFlag' -e 'addFlag' -e '\\Seen' -e '->delete(' -e 'expunge' -e 'moveTo' \
  app/Services/MailIngest app/Console/Commands | grep -v 'test'
POP3 แบบ leave-on-server

ทำได้ แต่มีเงื่อนไข

ตั้งไม่ให้ลบหลังอ่านก็ทำงานคล้ายกัน แต่ POP3 ไม่มีแนวคิดโฟลเดอร์และตัวชี้ตำแหน่งอ่อนกว่ามาก บางเซิร์ฟเวอร์จัดลำดับเมลใหม่ทุกครั้งที่ต่อ แถมส่วนขยายที่ใช้ต่อ POP3 ใน PHP ถูกถอดออกจากตัวหลักไปแล้ว

IMAP อ่านอย่างเดียว

ได้ผลเหมือนกันแต่คุมได้มากกว่า

UID ที่ไม่ซ้ำ คู่กับ UIDVALIDITY ที่บอกได้ว่าโฟลเดอร์ถูกสร้างใหม่ บวก BODY.PEEK ที่อ่านโดยไม่ทิ้งรอย ได้ผลลัพธ์เท่ากับ POP3 ที่เก็บสำเนาไว้ แต่เราคุมได้ละเอียดกว่าและย้ายผู้ให้บริการง่ายกว่า ผมเลือกทางนี้

ส่วนขยาย IMAP ที่เคยอยู่ในตัวหลัก ไม่อยู่แล้ว

ตรงนี้เป็นเรื่องที่ทำให้แผนเดิมต้องเปลี่ยนครับ ส่วนขยาย imap ของ PHP ถูกถอดออกจากตัวหลักตั้งแต่ 8.4 กลายเป็นของที่ต้องไปหาลงเองจาก PECL และบนดิสโทรใหม่ ๆ ก็ลงไม่ง่าย ทางออกที่ผมเลือกคือใช้ไลบรารีที่เขียน IMAP เองล้วน ๆ ด้วย PHP ไม่ต้องพึ่งส่วนขยายเลย

# ตรวจก่อนว่าเครื่องเป้าหมายมีอะไรบ้าง จะได้ไม่ออกแบบบนสมมติฐานที่ผิด
php -m | grep -i -E '^(imap|openssl|mbstring|iconv)$'
php -r 'echo function_exists("imap_open") ? "ext-imap ok\n" : "ext-imap ไม่มี\n";'
ข้อดีที่ตามมาโดยไม่ได้ตั้งใจ

พอไม่ต้องพึ่งส่วนขยายระดับระบบ การย้ายเครื่องกับการอัปเกรด PHP ก็ง่ายขึ้นมาก ไลบรารีเป็นแพ็กเกจธรรมดาที่ไปกับโค้ด ไม่ใช่ของที่ต้องไปขอแอดมินลงให้ทีละเครื่อง ส่วนตัวผมเจอมาหลายรอบแล้วว่าสิ่งที่ทำให้ระบบย้ายเครื่องไม่ได้ มักไม่ใช่ตัวโค้ด แต่เป็นรายการส่วนขยายที่ต้องลงให้ครบก่อนโค้ดจะรันได้

กันซ้ำต้องทำฝั่งเรา และห้ามพึ่ง Message-ID อย่างเดียว

หัวข้อ Message-ID เป็นสิ่งที่ควรมีในเมลทุกฉบับตามมาตรฐาน แต่ในโลกจริงมีเมลที่ไม่มีมันจริง ๆ ส่วนใหญ่มาจากระบบที่ส่งเมลเองแบบง่าย ๆ และมักจะเป็นเมลประเภทที่เราอยากดึงเข้าระบบพอดี

// dedupe key: ใช้ Message-ID ถ้ามี ไม่มีก็สร้างจากสิ่งที่คงที่พอ
$msgId = trim((string) $message->getMessageId());

$dedupeKey = $msgId !== ''
    ? mb_substr($msgId, 0, 190)
    : hash('sha256', implode('|', [
        (string) $message->getFrom()[0]->mail,
        $message->getDate()->format('c'),
        (string) $message->getSubject(),
      ]));

ตรงนี้มีกับดักของฐานข้อมูลซ้อนอยู่อีกชั้น คือถ้าเราปล่อยให้คอลัมน์นี้เป็น NULL ได้เมื่อไม่มี Message-ID แล้วหวังพึ่ง UNIQUE index กันซ้ำ มันกันไม่ได้

-- UNIQUE ปล่อย NULL ซ้ำได้กี่แถวก็ได้ — กับดักคลาสสิก
INSERT INTO mail_messages (mail_account_id, dedupe_key) VALUES (1, NULL);
INSERT INTO mail_messages (mail_account_id, dedupe_key) VALUES (1, NULL);
INSERT INTO mail_messages (mail_account_id, dedupe_key) VALUES (1, NULL);
-- ผ่านทั้งสามแถว ไม่มี error สักตัว

วิธีแก้คือบังคับให้คอลัมน์นี้ NOT NULL แล้วเติมค่า fallback ที่คำนวณเองเสมอ ไม่มีเคสที่ปล่อยว่าง

CREATE TABLE mail_messages (
  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
  mail_account_id BIGINT       NOT NULL,
  dedupe_key      VARCHAR(190) NOT NULL,              -- ห้าม NULL เด็ดขาด
  imap_uid        BIGINT       NOT NULL,
  uidvalidity     BIGINT       NOT NULL,
  from_addr       VARCHAR(255) NOT NULL,
  subject         VARCHAR(255) NULL,
  sent_at         DATETIME     NULL,
  status          VARCHAR(16)  NOT NULL DEFAULT 'new',
  created_at      DATETIME     NOT NULL,
  UNIQUE KEY mm_acct_dedupe (mail_account_id, dedupe_key),
  KEY mm_acct_uid (mail_account_id, imap_uid)
) ENGINE=InnoDB;

การเก็บ imap_uid กับ uidvalidity ไว้ด้วยไม่ได้เอาไว้กันซ้ำ แต่เอาไว้ตอนสืบสวนว่าเมลฉบับนี้มาจากรอบไหน ตอน UIDVALIDITY เปลี่ยนแล้วดึงใหม่ทั้งโฟลเดอร์ ค่าคู่นี้ช่วยให้ไล่ดูได้ว่าอะไรถูกดึงซ้ำบ้าง

จุดที่ต้องระวังตอนบันทึก: ลำดับของ last_uid

// อัปเดต last_uid หลังจากที่แถวถูกบันทึกสำเร็จแล้วเท่านั้น
DB::transaction(function () use ($batch, $account) {
    $maxUid = (int) $account->last_uid;
    foreach ($batch as $m) {
        DB::table('mail_messages')->insertOrIgnore($this->toRow($m, $account));
        $maxUid = max($maxUid, (int) $m->getUid());
    }
    DB::table('mail_accounts')->where('id', $account->id)->update([
        'last_uid'        => $maxUid,
        'last_fetched_at' => now(),
    ]);
});
อย่าอัปเดต last_uid ก่อนบันทึกสำเร็จ

ถ้าเขียน last_uid ไว้ก่อนแล้วค่อยวนบันทึกทีละฉบับ วันที่ process ตายกลางทางเพราะไฟล์แนบใหญ่เกินหรือหน่วยความจำหมด เมลที่ยังไม่ถูกบันทึกจะถูกข้ามไปถาวร เพราะรอบหน้าเราค้นจาก UID ที่มากกว่าค่าที่เขียนไว้แล้ว ให้คร่อม transaction ต่อหนึ่งก้อนแล้วปล่อยให้รอบหน้าดึงซ้ำได้ ดีกว่าเสี่ยงกับการหายถาวร

ไฟล์แนบคือจุดที่อันตรายที่สุดในทั้งระบบ

เมลจากข้างนอกแนบอะไรมาก็ได้ และคนส่งไม่จำเป็นต้องเป็นคนที่เราไว้ใจ กฎที่ผมใช้มีสี่ข้อและต้องครบทั้งสี่

const ALLOW_EXT  = ['pdf', 'jpg', 'jpeg', 'png', 'docx', 'xlsx'];
const ALLOW_MIME = ['application/pdf', 'image/jpeg', 'image/png',
                    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];

public function accept(string $name, string $mime, int $bytes): bool
{
    $parts = explode('.', strtolower($name));
    array_shift($parts);                          // ทิ้งชื่อฐาน เหลือแต่นามสกุลทุกชั้น
    if (count($parts) > 1) return false;          // นามสกุลซ้อน เช่น .pdf.exe ตัดทิ้งทันที
    if (!in_array($parts[0] ?? '', self::ALLOW_EXT, true)) return false;
    if (!in_array($mime, self::ALLOW_MIME, true)) return false;
    return $bytes <= 10 * 1024 * 1024;
}

ข้อที่สำคัญที่สุดคือที่เก็บไฟล์ ต้องอยู่นอกโฟลเดอร์ที่เว็บเซิร์ฟเวอร์เสิร์ฟได้ แล้วให้ผ่าน controller ที่ตรวจสิทธิ์ก่อนส่งออกเสมอ ชื่อไฟล์บนดิสก์ใช้ชื่อสุ่ม ไม่ใช่ชื่อที่มากับเมล

// เก็บนอก webroot ชื่อสุ่ม เก็บ sha256 ไว้เทียบ ส่วนชื่อเดิมเก็บไว้แค่ในฐานเพื่อแสดงผล
$disk = Storage::build(['driver' => 'local', 'root' => '/srv/app/var/mail-att']);
$path = $account->id . '/' . now()->format('Y/m') . '/' . Str::random(32);
$disk->put($path, $attachment->getContent(), ['visibility' => 'private']);
25 ฉบับต่อหนึ่ง batch
10 MBเพดานไฟล์แนบ
6 นามสกุลที่อนุญาต
0 คำสั่งที่เขียนกลับ mailbox

เทสต์ที่ต้องผ่านก่อนเปิดใช้จริง

ชุดนี้ผมเขียนไว้เป็นรายการตรวจแล้วรันจริงกับกล่องทดสอบทุกครั้งที่แก้โค้ดส่วนนี้ ไม่ใช่รันครั้งเดียวตอนทำเสร็จ

  1. ส่งเมลทดสอบสองฉบับเข้ากล่อง โดยหนึ่งฉบับมีไฟล์แนบ
  2. เปิดอ่านฉบับหนึ่งผ่านเว็บเมลก่อน แล้วค่อยสั่ง fetch — ต้องได้ครบทั้งสองฉบับ นี่คือจุดที่การใช้ UNSEEN จะพลาด
  3. กลับไปดูสถานะอ่านหรือยังไม่อ่านของทั้งสองฉบับบนเว็บเมล ต้องไม่เปลี่ยนจากเดิมเลย
  4. สั่ง fetch ซ้ำอีกรอบทันที ต้องได้แถวใหม่ศูนย์แถว
  5. แก้ค่า uidvalidity ในตารางให้ต่างจากของจริง แล้ว fetch ใหม่ ต้องดึงทั้งโฟลเดอร์และยังได้แถวใหม่ศูนย์แถว
  6. ส่งเมลที่ไม่มี Message-ID เข้าไป ต้องบันทึกได้และกันซ้ำได้ด้วย fallback key
  7. ส่งไฟล์แนบนามสกุลซ้อนและไฟล์เกินเพดานขนาด ต้องถูกปฏิเสธและบันทึกไว้ว่าปฏิเสธเพราะอะไร
  8. grep หาคำสั่งเปลี่ยนสถานะในโค้ดทั้งโมดูล ผลต้องว่าง

ข้อสุดท้ายเรื่องตัวอักษรไทยขอเพิ่มอีกนิด เมลไทยจากระบบเก่า ๆ ยังมีที่ส่งหัวข้อมาเป็นชุดอักขระเดิมอยู่ ถ้าเก็บลงฐานตรง ๆ จะได้ตัวประหลาด ต้องแปลงก่อนเสมอและอย่าลืมว่าหัวข้อกับเนื้อเมลอาจใช้คนละชุดอักขระกันในฉบับเดียวกัน

// แปลงชุดอักขระของหัวข้อก่อนเก็บ และตัดความยาวด้วยฟังก์ชันที่รู้จักหลายไบต์
$subject = $message->getSubject()->toString();
if (!mb_check_encoding($subject, 'UTF-8')) {
    $subject = mb_convert_encoding($subject, 'UTF-8', 'TIS-620,ISO-8859-11,UTF-8');
}
$subject = mb_substr($subject, 0, 250);
# cron: ให้ scheduler เป็นคนคุมจังหวะ อย่าเขียนหลายบรรทัดใน crontab
* * * * * appuser cd /srv/app && php artisan schedule:run >> /dev/null 2>&1

สรุปสั้น ๆ สำหรับคนที่กำลังจะทำครับ หัวใจมีสามข้อ หนึ่งคือห้ามใช้สัญญาณที่มนุษย์เป็นเจ้าของมาเป็นสถานะของโปรแกรม สองคือตัวชี้ตำแหน่งต้องมาคู่กับตัวบอกรุ่นของโฟลเดอร์เสมอ และสามคือกันซ้ำต้องแข็งพอที่จะเจอการดึงซ้ำทั้งโฟลเดอร์ได้โดยไม่สะเทือน ส่วนเรื่องไฟล์แนบนั้นคิดเสียว่ากำลังรับไฟล์จากคนแปลกหน้าอยู่ เพราะความจริงมันก็เป็นแบบนั้นจริง ๆ ใครทำระบบแนวนี้แล้วเจอกับดักอื่นที่ผมยังไม่เจอ เอามาเล่าให้ฟังได้ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย