tinker ส่งเมลผ่าน แต่เว็บส่งไม่ออก — proc_open ที่ปิดอยู่ใน FPM
การทดสอบที่ผ่านในสภาพแวดล้อมที่ไม่ใช่ของจริง ให้ผลร้ายกว่าการไม่ทดสอบเลย เพราะมันทำให้เราขีดฆ่าผู้ต้องสงสัยตัวจริงออกจากรายชื่อตั้งแต่ชั่วโมงแรก
สรุปใน 1 นาที
- อาการ/ปัญหา
- ส่งเมลจาก tinker ผ่าน แต่กดจากฟอร์มจริงไม่มีเมลออก และ mail log ไม่มีสักบรรทัด
- ใครควรอ่าน
- คนที่ส่งเมลจาก Laravel บนโฮสต์ DirectAdmin แล้วเงียบ
- ทางแก้แบบสั้น
- CLI กับ PHP-FPM เป็น PHP คนละตัว ทดสอบโดยบูตแอปจริงในนามผู้ใช้เว็บ ส่งผ่าน SMTP AUTH ตั้ง From เป็นโดเมนตัวเอง อีเมลผู้กรอกเป็น Reply-To
พอดีผมเพิ่งไล่เคสฟอร์มติดต่อบนเว็บ Laravel ตัวหนึ่งที่วางอยู่บนโฮสต์ DirectAdmin แล้วเมลไม่ออก ซึ่งเป็นงานที่ควรจบใน 20 นาทีแต่กินเวลาไปครึ่งวัน สาเหตุที่กินเวลาไม่ใช่เพราะมันยาก แต่เพราะผมทดสอบด้วย php artisan tinker ตั้งแต่ต้นแล้วมันส่งผ่าน เมลเข้ากล่องจริง ผมเลยตัดเรื่อง transport ทิ้งจากรายชื่อผู้ต้องสงสัยไปเลย แล้วไปไล่หาที่อื่นอยู่นาน
สรุปคือ transport นั่นแหละคือตัวปัญหา และ tinker ก็ไม่ได้โกหก มันบอกความจริงของสภาพแวดล้อมที่มันอยู่ ซึ่งไม่ใช่สภาพแวดล้อมที่เว็บทำงานอยู่ บทความนี้ผมเล่าเฉพาะชั้น transport ส่วนด่านตรวจบอทที่ขวางอยู่ก่อนหน้านั้น ผมเขียนแยกไว้แล้วที่ Laravel หลัง proxy เห็น IP เป็น 127.0.0.1 เล่มนั้นหยุดที่ก่อนจะถึงบรรทัด Mail::send เล่มนี้คือสิ่งที่เกิดขึ้นหลังจากนั้น
ชื่อโฮสต์ โดเมน และกล่องเมลในบทความเป็นค่าตัวอย่างทั้งหมด
อาการ: ผ่านจาก CLI แต่เงียบจากเว็บ
สิ่งที่ผมเห็นตอนนั้นคือ ฟอร์มตอบ 302 กลับมาหน้าเดิม ไม่มีข้อความผิดพลาด ไม่มีเมล และที่ทำให้หลงทางที่สุดคือ mail log ของเซิร์ฟเวอร์ไม่มีบรรทัดเดียวในช่วงเวลานั้น ปกติถ้าแอปส่งออกไปแล้วโดนปฏิเสธ มันจะต้องมีร่องรอยว่ามีคนพยายามส่ง การที่ไม่มีอะไรเลยแปลว่าคำสั่งส่งไม่เคยเดินทางออกจากตัวแอป
# มีคำขอเข้ามาถึงแอปไหม แล้วแอปตอบอะไร
grep 'POST /contact' ~/domains/app.example.com/logs/*access.log | tail -3
# "POST /contact HTTP/2.0" 302 ...
# มีร่องรอยการส่งไหม
sudo grep '14:1[0-9]' /var/log/exim/mainlog | grep -i contact
# (ว่างเปล่า)
ทำไม CLI กับ FPM ไม่ใช่ PHP ตัวเดียวกัน
PHP บนเครื่องเดียวกันอ่านไฟล์ตั้งค่าคนละชุดตามวิธีที่มันถูกเรียกใช้ ตัวที่รันจากบรรทัดคำสั่งอ่านชุดของ CLI ส่วนตัวที่เสิร์ฟเว็บอ่านชุดของ pool ที่บัญชีนั้นใช้อยู่ ค่าที่ต่างกันบ่อยที่สุดบนโฮสต์ที่แชร์กันหลายบัญชีคือ disable_functions ซึ่งผู้ดูแลจะปิดฟังก์ชันที่เปิดโปรเซสลูกได้ทิ้งไปจากฝั่งเว็บ เพื่อไม่ให้สคริปต์ที่ถูกฝังไว้ในเว็บของใครสักคนเรียกคำสั่งระบบได้
# ฝั่ง CLI
php -i | grep '^disable_functions'
# disable_functions => no value => no value
# ฝั่งเว็บ ต้องถามผ่าน request เท่านั้น
printf '<?php var_dump(function_exists("proc_open"));' > ~/domains/app.example.com/public_html/_p.php
curl -s https://app.example.com/_p.php # bool(false)
rm -f ~/domains/app.example.com/public_html/_p.php
ทีนี้ Laravel ที่ตั้ง MAIL_MAILER=sendmail จะส่งเมลด้วยการเปิดโปรแกรม sendmail ของเครื่องขึ้นมาแล้วป้อนข้อความเข้าทาง stdin ซึ่งต้องใช้ proc_open พอฟังก์ชันนั้นไม่มีในฝั่งเว็บ มันเลยล้มตั้งแต่ก่อนจะได้คุยกับระบบเมลของเครื่อง — ตรงกับที่ mail log ว่างเปล่าพอดี
tinker, artisan command, และ cron ทุกตัวรันบน CLI ซึ่งมีฟังก์ชันครบและมักรันในนามผู้ใช้คนละคนกับเว็บ อะไรที่เกี่ยวกับ สิทธิ์ไฟล์ ฟังก์ชันที่ถูกปิด ตัวแปรสภาพแวดล้อม หรือขีดจำกัดหน่วยความจำ ห้ามสรุปจากผลทดสอบฝั่ง CLI เด็ดขาด ต้องทดสอบผ่านเส้นทางเดียวกับที่คนใช้จริง
ทดสอบให้ถูกทาง — บูตแอปจริงแล้วรันในนามผู้ใช้ของเว็บ
วิธีที่ผมใช้แทน tinker คือเขียนสคริปต์สั้น ๆ ที่บูต framework เต็มรูปแบบแล้วเรียกเส้นทางส่งเมลจริง จากนั้นรันในนามผู้ใช้เจ้าของเว็บ แบบนี้ได้ใกล้ของจริงกว่ามาก และยังอ่าน exception ที่โยนออกมาได้ตรง ๆ
<?php
// _mailtest.php — วางในโฟลเดอร์แอป ใช้เสร็จลบ
require __DIR__ . '/vendor/autoload.php';
$app = require __DIR__ . '/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
try {
Illuminate\Support\Facades\Mail::raw('ทดสอบเส้นทางส่งเมลของแอป', function ($m) {
$m->to('[email protected]')->subject('MAILTEST');
});
echo "sent\n";
} catch (\Throwable $e) {
echo 'ERR: ' . get_class($e) . ': ' . $e->getMessage() . "\n";
}
su - appuser -c 'php ~/app/_mailtest.php'
# ERR: Symfony\Component\Mailer\Exception\TransportException:
# Call to undefined function Symfony\Component\Process\proc_open()
rm -f ~appuser/app/_mailtest.php
บรรทัดนั้นคือคำตอบทั้งหมดของเคสนี้ และมันไม่เคยโผล่มาให้เห็นเลยตอนกดจากฟอร์ม เพราะ controller มี try/catch ครอบไว้แล้วเขียน log ด้วยระดับ info ส่วน .env ตั้ง LOG_LEVEL=error ไว้ ข้อความเลยถูกทิ้งไปเงียบ ๆ อีกชั้นหนึ่ง
ถ้าโค้ดวินิจฉัยที่เราเพิ่งใส่เข้าไป "ไม่ขึ้นอะไรเลย" ให้เช็ค LOG_LEVEL ก่อนสรุปว่าโค้ดไม่ถูกเรียก ตั้งเป็น debug ชั่วคราว ทดสอบให้จบ แล้วตั้งกลับทันที เพราะ debug บนเครื่องจริงเขียน log โตเร็วมากและมีข้อมูลของผู้ใช้ปนอยู่
สามทางที่ลองแล้วไม่ผ่าน
ก่อนจะได้สูตรที่ใช้ได้ ผมไล่ผิดมาสามแบบ เก็บไว้ให้ดูว่าแต่ละแบบล้มด้วยอาการอะไร จะได้ไม่ต้องเสียเวลาซ้ำ
สามสูตรที่คนแนะนำกันบ่อย
sendmail ล้มที่ proc_open ถูกปิด ไม่มีบรรทัดใน mail log · smtp ไป 127.0.0.1 พอร์ต 25 ล้มตอนยกระดับเป็น TLS เพราะชื่อในใบรับรองเป็นชื่อเครื่อง ไม่ใช่ 127.0.0.1 · smtp ไปชื่อเครื่องพอร์ต 25 แบบไม่ล็อกอิน ถูกปฏิเสธด้วย 550 เพราะการต่อจากขา IP สาธารณะไม่ได้รับความไว้ใจให้ส่งต่อ
SMTP AUTH พอร์ต 587
ต่อไปที่ชื่อเครื่องเมลตามที่ระบุในใบรับรอง ยกระดับเป็น TLS แล้วล็อกอินด้วยบัญชีกล่องเมลของโดเมนเราเอง ระบบจะถือว่าเป็นผู้ส่งที่ยืนยันตัวแล้ว และยอมส่งต่อให้ตามปกติ
อาการของสองแบบกลางเป็นแบบนี้ ซึ่งพอเห็นข้อความแล้วมันชี้ทางเองเลยว่าต้องแก้อะไร
# ต่อ 127.0.0.1:25 แล้วสั่ง STARTTLS
Peer certificate CN=`mail.example.com' did not match expected CN=`127.0.0.1'
# ต่อพอร์ต 25 จากขานอก โดยไม่ล็อกอิน
550 Sender verify failed
550 relay not permitted
สูตรที่ใช้ได้จริงบนโฮสต์ DirectAdmin
MAIL_MAILER=smtp
MAIL_HOST=mail.example.com # ต้องตรงกับชื่อในใบรับรองของเซิร์ฟเวอร์เมล
MAIL_PORT=587
MAIL_ENCRYPTION=tls
[email protected]
MAIL_PASSWORD=<PASSWORD>
[email protected]
MAIL_FROM_NAME="${APP_NAME}"
[email protected]
ทดสอบซ้ำด้วยสคริปต์เดิม แล้วไปยืนยันที่ mail log ว่ามันเดินครบวง อย่าหยุดที่คำว่า sent บนหน้าจอ
su - appuser -c 'php ~/app/_mailtest.php' # sent
sudo grep 'MAILTEST' -A2 /var/log/exim/mainlog | tail -6
# <= [email protected] H=(app) P=esmtpsa A=login:[email protected]
# => [email protected] R=send_to_gateway T=remote_smtp ... C="250 OK id=..."
สองบรรทัดนี้คือหลักฐานที่ผมยอมรับ บรรทัดแรกคือระบบเมลรับเรื่องไว้พร้อมระบุว่าเป็นการส่งแบบล็อกอินแล้ว บรรทัดที่สองคือส่งต่อออกไปข้างนอกสำเร็จพร้อมรหัสตอบรับจากปลายทาง ถ้ามีแค่บรรทัดแรกแปลว่าค้างอยู่ในคิวของเครื่องเรา คนละปัญหากันแล้ว
From ต้องเป็นโดเมนเรา อีเมลผู้กรอกเป็นแค่ Reply-To
จุดที่คนทำฟอร์มติดต่อพลาดกันประจำ คือเอาอีเมลที่ผู้ใช้กรอกมาใส่ช่อง From เพื่อให้กดตอบกลับได้สะดวก ซึ่งได้ผลตรงข้ามกับที่ตั้งใจ เพราะเซิร์ฟเวอร์ของเราไม่มีสิทธิ์ส่งในนามโดเมนของคนอื่น พอปลายทางตรวจสอบก็จะพบว่าไม่ผ่าน แล้วโยนเข้าถังขยะหรือปฏิเสธไปเลย ผลคือ "ฟอร์มส่งแล้วแต่ไม่เคยได้รับ" ที่หาสาเหตุยากมาก เพราะฝั่งเราบอกว่าส่งสำเร็จทุกครั้ง
ทางที่ถูกคือ From เป็นกล่องของโดเมนเราเสมอ แล้วใส่อีเมลผู้กรอกไว้ที่ Reply-To ซึ่งให้ประสบการณ์เหมือนกันตอนกดตอบ แต่ไม่ไปแตะเรื่องสิทธิ์การส่งในนามโดเมน
// app/Mail/ContactMessage.php
public function envelope(): Envelope
{
return new Envelope(
from: new Address(
config('mail.from.address'),
config('app.name'),
),
replyTo: [new Address($this->data['email'], $this->data['name'])],
subject: 'ข้อความจากฟอร์มติดต่อ',
);
}
ทำบัญชีส่งอย่างเดียว แล้วเก็บรหัสให้ถูกที่
ผมไม่ใช้กล่องเมลของคนจริงมาเป็นบัญชีส่งของแอป เพราะรหัสมันต้องไปอยู่ในไฟล์ตั้งค่าบนเครื่อง และวันที่เปลี่ยนรหัสกล่องนั้น แอปจะเงียบไปทันทีโดยไม่มีใครรู้ ผมทำบัญชีแยกต่างหากที่ใช้ส่งอย่างเดียว ตั้งรหัสยาว ๆ แล้วไม่เอาไปผูกกับอะไรอีก
- สร้างกล่องแยกชื่อ noreply ของโดเมนนั้น ตั้งรหัสสุ่มยาวอย่างน้อย 24 ตัวอักษร ไม่ต้องใช้ซ้ำกับที่ไหน และไม่ต้องให้ใครจำ เพราะไม่มีคนเข้าใช้กล่องนี้
- ตั้งให้เมลที่ตอบกลับมาที่กล่องนี้ถูกส่งต่อไปกล่องจริง ไม่งั้นเมลเด้งกลับจะกองอยู่ในกล่องที่ไม่มีใครเปิดจนเต็มโควตา แล้วลามไปทำให้ทั้งโดเมนรับเมลไม่ได้
- เก็บรหัสไว้ใน .env ที่สิทธิ์ 600 เท่านั้น อย่าใส่ในโค้ด อย่า commit และอย่าวางในไฟล์บันทึกงานที่แชร์กัน เวลาเขียนบทความหรือส่งให้คนอื่นดู ให้แทนด้วยตัวยึดตำแหน่งทุกครั้ง
chmod 600 ~/app/.env
grep -c '^MAIL_PASSWORD=' ~/app/.env # 1
php artisan config:cache # ค่าเมลถูกอ่านผ่าน config ไม่ใช่ env() สด
บทเรียนที่ผมเก็บจากเคสนี้มีข้อเดียวครับ คือ ทดสอบในเส้นทางเดียวกับที่คนใช้จริงเท่านั้น เครื่องมือที่สะดวกอย่าง tinker เหมาะกับการลองตรรกะ ไม่เหมาะกับการยืนยันว่าสิ่งที่พึ่งพาสภาพแวดล้อมทำงานได้ ส่วนใครที่ฟอร์มยังเงียบอยู่ทั้งที่ transport ถูกแล้ว ให้ย้อนไปดูด่านก่อนหน้า Mail::send ตามที่ผมเขียนไว้ในเล่มที่ลิงก์ไว้ข้างบน เพราะสองเคสนี้ให้อาการหน้าเว็บเหมือนกันเป๊ะ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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