บทที่ 7backend|12 KB

บทที่ 7: Email Service และ OTP

บทที่ 7: Email Service และ OTP

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • ส่ง email ด้วย Nodemailer
  • สร้างระบบ OTP (One-Time Password)
  • ออกแบบ HTML email templates
  • จัดการ SMTP configuration
  • Handle email errors

📚 ทฤษฎีและแนวคิด

Email Service คืออะไร?

Email Service ใช้สำหรับ:

  • 📧 ส่ง OTP สำหรับ reset password
  • ✉️ Welcome email
  • 🔔 Notifications
  • 📊 Reports

OTP (One-Time Password)

OTP คือรหัสใช้ครั้งเดียวสำหรับยืนยันตัวตน

คุณสมบัติ:

  • ⏱️ หมดอายุ (5-10 นาที)
  • 🔢 6 หลัก (ตัวเลข)
  • 🔒 ใช้ได้ครั้งเดียว
  • 🎲 Random generation

💻 โค้ดจริงจาก QUOM App

1. Email Service Setup

ไฟล์: backend/src/services/email.js

import nodemailer from "nodemailer";

let transporter = null;

function getTransporter() {
  if (transporter) return transporter;

  // Production: Use real SMTP
  if (process.env.SMTP_HOST && process.env.SMTP_USER) {
    transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: parseInt(process.env.SMTP_PORT || "587"),
      secure: process.env.SMTP_SECURE === "true",
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS,
      },
    });
  } else {
    // Development: Log to console
    console.warn("⚠️  No SMTP configured. Emails logged to console.");
    transporter = nodemailer.createTransport({
      streamTransport: true,
      newline: "unix",
      buffer: true,
    });
  }

  return transporter;
}

อธิบายทีละบรรทัด (Imports):

  • import nodemailer from "nodemailer"
    • Library มาตรฐานสำหรับส่ง Email ใน Node.js (รองรับ Gmail, Outlook, AWS SES)

อธิบาย:

SMTP Configuration

{
  host: "smtp.gmail.com",      // SMTP server
  port: 587,                    // Port (587 = TLS, 465 = SSL)
  secure: false,                // true for 465, false for 587
  auth: {
    user: "your-email@gmail.com",
    pass: "your-app-password"
  }
}

Environment Variables

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM="QUOM App <noreply@quom.app>"

2. Send OTP Email

export async function sendOTPEmail(email, code) {
  try {
    const transport = getTransporter();

    const mailOptions = {
      from: process.env.SMTP_FROM || '"QUOM App" <noreply@quom.app>',
      to: email,
      subject: "รหัส OTP สำหรับรีเซ็ตรหัสผ่าน - QUOM",
      html: `
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="utf-8">
          <style>
            body { 
              font-family: 'Prompt', sans-serif; 
              background: #f8fafc; 
              margin: 0; 
              padding: 20px; 
            }
            .container { 
              max-width: 600px; 
              margin: 0 auto; 
              background: white; 
              border-radius: 16px; 
              box-shadow: 0 4px 6px rgba(0,0,0,0.1); 
            }
            .header { 
              background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%); 
              padding: 40px 20px; 
              text-align: center; 
            }
            .header h1 { 
              color: white; 
              margin: 0; 
              font-size: 28px; 
            }
            .content { padding: 40px 30px; }
            .otp-box { 
              background: #EEF2FF; 
              border: 2px dashed #3B82F6; 
              border-radius: 12px; 
              padding: 30px; 
              text-align: center; 
              margin: 30px 0; 
            }
            .otp-code { 
              font-size: 42px; 
              font-weight: 900; 
              color: #3B82F6; 
              letter-spacing: 8px; 
              font-family: 'Courier New', monospace; 
            }
            .footer { 
              background: #f8fafc; 
              padding: 20px; 
              text-align: center; 
              color: #64748B; 
              font-size: 14px; 
            }
            .warning { 
              background: #FEF2F2; 
              border-left: 4px solid #EF4444; 
              padding: 15px; 
              margin: 20px 0; 
              color: #991B1B; 
            }
          </style>
        </head>
        <body>
          <div class="container">
            <div class="header">
              <h1>🔐 QUOM Password Reset</h1>
            </div>
            <div class="content">
              <h2>รหัส OTP ของคุณ</h2>
              <p>
                คุณได้ขอรีเซ็ตรหัสผ่านสำหรับบัญชี QUOM 
                กรุณาใช้รหัส OTP ด้านล่าง:
              </p>
              
              <div class="otp-box">
                <div class="otp-code">${code}</div>
                <p>รหัสนี้จะหมดอายุใน <strong>5 นาที</strong></p>
              </div>

              <div class="warning">
                <strong>⚠️ คำเตือน:</strong> 
                หากคุณไม่ได้ขอรีเซ็ตรหัสผ่าน กรุณาเพิกเฉยต่ออีเมลนี้
              </div>

              <p>เพื่อความปลอดภัย อย่าแชร์รหัส OTP นี้กับผู้อื่น</p>
            </div>
            <div class="footer">
              <p>© 2026 QUOM App. All rights reserved.</p>
              <p>This is an automated message, please do not reply.</p>
            </div>
          </div>
        </body>
        </html>
      `,
      text: `
รหัส OTP สำหรับรีเซ็ตรหัสผ่าน QUOM

รหัส OTP ของคุณคือ: ${code}

รหัสนี้จะหมดอายุใน 5 นาที

หากคุณไม่ได้ขอรีเซ็ตรหัสผ่าน กรุณาเพิกเฉยต่ออีเมลนี้

© 2026 QUOM App
      `,
    };

    const info = await transport.sendMail(mailOptions);

    // Development mode logging
    if (!process.env.SMTP_HOST) {
      console.log("📧 OTP Email (DEV MODE):");
      console.log("To:", email);
      console.log("OTP Code:", code);
    } else {
      console.log("✅ OTP email sent to:", email);
    }

    return true;
  } catch (error) {
    console.error("❌ Failed to send OTP email:", error);
    throw new Error("Failed to send OTP email");
  }
}

อธิบาย:

HTML Email Template

  • Inline CSS: Email clients ไม่รองรับ <style> tag
  • Table Layout: ใช้ <table> สำหรับ layout (เก่าแต่ทำงานได้ทุกที่)
  • Fallback Text: มี plain text version

OTP Display

<div class="otp-code">${code}</div>
  • ฟอนต์ใหญ่ (42px)
  • Letter spacing (8px)
  • Monospace font

3. OTP Generation

function generateOTP() {
  return Math.floor(100000 + Math.random() * 900000).toString();
}

// ตัวอย่าง: "123456", "789012"

อธิบาย:

  • Math.random(): 0.0 - 0.999999
  • * 900000: 0 - 899999
  • + 100000: 100000 - 999999
  • Math.floor(): ตัดทศนิยม
  • .toString(): แปลงเป็น string

4. OTP Storage (In-Memory)

// Simple in-memory store
const otpStore = new Map(); // email -> { code, expiresAt }

function saveOTP(email, code) {
  const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
  otpStore.set(email, { code, expiresAt });
}

function verifyOTP(email, code) {
  const stored = otpStore.get(email);
  
  if (!stored) {
    return { valid: false, reason: "OTP not found" };
  }
  
  if (Date.now() > stored.expiresAt) {
    otpStore.delete(email);
    return { valid: false, reason: "OTP expired" };
  }
  
  if (stored.code !== code) {
    return { valid: false, reason: "Invalid OTP" };
  }
  
  // Valid - delete after use
  otpStore.delete(email);
  return { valid: true };
}

5. Forgot Password Flow

// Step 1: Request OTP
authRouter.post("/forgot-password", async (req, res) => {
  const { email } = req.body;
  
  const user = await User.findOne({ email });
  if (!user) {
    // Don't reveal if email exists
    return res.json({ ok: true, message: "OTP sent if email exists" });
  }
  
  const code = generateOTP();
  saveOTP(email, code);
  
  await sendOTPEmail(email, code);
  
  res.json({ ok: true, message: "OTP sent" });
});

// Step 2: Verify OTP & Reset Password
authRouter.post("/reset-password", async (req, res) => {
  const { email, otp, newPassword } = req.body;
  
  const result = verifyOTP(email, otp);
  if (!result.valid) {
    return res.status(400).json({ ok: false, message: result.reason });
  }
  
  const user = await User.findOne({ email });
  if (!user) {
    return res.status(404).json({ ok: false, message: "User not found" });
  }
  
  user.passwordHash = await bcrypt.hash(newPassword, 10);
  await user.save();
  
  res.json({ ok: true, message: "Password reset successful" });
});

🔒 Security Best Practices

1. Rate Limiting

// Limit OTP requests
const otpRequests = new Map(); // email -> count

function canRequestOTP(email) {
  const count = otpRequests.get(email) || 0;
  if (count >= 3) {
    return false; // Max 3 requests per hour
  }
  otpRequests.set(email, count + 1);
  setTimeout(() => otpRequests.delete(email), 60 * 60 * 1000);
  return true;
}

2. Don't Reveal Email Existence

// ✅ ดี
return res.json({ message: "OTP sent if email exists" });

// ❌ อันตราย
if (!user) {
  return res.json({ message: "Email not found" });
}

3. Short Expiration

// ✅ ดี - 5 minutes
const expiresAt = Date.now() + 5 * 60 * 1000;

// ❌ อันตราย - 1 hour
const expiresAt = Date.now() + 60 * 60 * 1000;

📝 แบบฝึกหัด

โจทย์ที่ 1: Welcome Email

// TODO: ส่ง welcome email เมื่อ register
// - มี username
// - มีลิงก์ไปยังแอป

โจทย์ที่ 2: Database OTP Storage

// TODO: เก็บ OTP ใน MongoDB แทน in-memory
// - สร้าง OTP model
// - Auto-delete หมดอายุ (TTL index)

โจทย์ที่ 3: Email Queue

// TODO: ใช้ Bull Queue สำหรับส่ง email
// - ส่งแบบ async
// - Retry ถ้า fail

📌 สรุป

✅ Nodemailer - ส่ง email
✅ SMTP - Configuration
✅ OTP - Generation และ verification
✅ HTML Templates - สวยงาม responsive
✅ Security - Rate limiting, expiration

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 8: Middleware และ Error Handling


📚 แหล่งเรียนรู้เพิ่มเติม