운영중입니다

Postfix + PHPMailer 메일 발송 시스템을 웹 UI로 사용하는 방법 본문

etc

Postfix + PHPMailer 메일 발송 시스템을 웹 UI로 사용하는 방법

https443 2026. 5. 19. 20:07

https://https443.tistory.com/42

이전 글에서는 Ubuntu 24.04 환경에서 Postfix와 PHPMailer를 이용하여
CLI(Command Line) 기반으로 동일한 메일을 여러 사용자에게 발송하는 방법을 정리하였습니다.

이번 글에서는 해당 내용을 확장하여
웹 브라우저에서 직접 수신자 목록 관리, 제목 수정, 테스트 발송, 전체 발송까지 가능한
간단한 메일 발송 UI를 만드는 방법을 정리합니다.

 


1. 테스트 환경

항목 내용
OS Ubuntu 24.04.2 LTS
Apache Apache/2.4.58
PHP PHP 8.3
메일 발송 Postfix
메일 라이브러리 PHPMailer

 

#Apache 기본 웹 경로는 일반적으로 아래와 같습니다.

/var/www/html

#서버마다 다를 수 있으므로 실제 환경에 맞게 확인이 필요합니다.

2. 생성

   2.1 기존 CLI 메일 발송 프로젝트 복사

#이전 글에서 사용한 /root/mailtest 디렉토리를 웹에서 접근 가능한 경로로 복사합니다.

mkdir -p /var/www/html/mailtest

cp -a /root/mailtest/* /var/www/html/mailtest/



   2.2 웹 수정 가능하도록 권한 설정

chown -R www-data:www-data /var/www/html/mailtest

chmod 755 /var/www/html/mailtest

chmod 644 /var/www/html/mailtest/maillist.txt

chmod 644 /var/www/html/mailtest/send_mail_sent.json 2>/dev/null

# 644로 설정 후 실행이 안될 경우 666을 설정하나 666은 소유자, 그룹, 전체 
# 모두 읽고 쓰기가 가능하므로, 임시로 사용하거나 644를 사용합니다.


  2.3 index.php 생성

#메일 발송 UI는 단일 PHP 파일로 구성하였습니다.

cd /var/www/html/mailtest

vim index.php

 

<?php
session_start();
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/PHPMailer/src/Exception.php';
require __DIR__ . '/PHPMailer/src/PHPMailer.php';
require __DIR__ . '/PHPMailer/src/SMTP.php';
date_default_timezone_set("Asia/Seoul");
/*
|--------------------------------------------------------------------------
| 관리자 / SMTP 설정
|--------------------------------------------------------------------------
*/
$adminPassword = "비빌번호 설정";
$smtpHost = "사용 메일 서버";
$smtpPort = 465;
$smtpUser = "사용 메일 계정";
$smtpPass = "사용 메일 계정 비밀번호";
$fromEmail = "발송 주소";
$fromName  = "발송자명";
/*
|--------------------------------------------------------------------------
| 파일 설정
|--------------------------------------------------------------------------
*/
$mailListFile = __DIR__ . "/maillist.txt";
$sentFile     = __DIR__ . "/send_mail_sent.json";
$lockFile     = __DIR__ . "/send_mail.lock";
/*
|--------------------------------------------------------------------------
| 로그인 처리
|--------------------------------------------------------------------------
*/
if (isset($_POST['login_password'])) {
    if ($_POST['login_password'] === $adminPassword) {
        $_SESSION['login'] = true;
        header("Location: index.php");
        exit;
    } else {
        $loginError = "비밀번호가 올바르지 않습니다.";
    }
}
if (isset($_GET['logout'])) {
    session_destroy();
    header("Location: index.php");
    exit;
}
if (empty($_SESSION['login'])) {
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>메일 발송 관리자</title>
<style>
body { font-family: Arial, "Malgun Gothic", sans-serif; background:#f3f4f6; }
.login-box { width:360px; margin:120px auto; background:#fff; padding:30px; border-radius:12px; box-shadow:0 4px 14px rgba(0,0,0,.1); }
input { width:100%; padding:12px; box-sizing:border-box; margin-top:10px; }
button { width:100%; padding:12px; margin-top:15px; background:#1d4ed8; color:#fff; border:0; border-radius:6px; cursor:pointer; }
.error { color:red; margin-top:10px; }
</style>
</head>
<body>
<div class="login-box">
    <h2>메일 발송 관리자</h2>
    <form method="post">
        <input type="password" name="login_password" placeholder="관리자 비밀번호">
        <button type="submit">로그인</button>
    </form>
    <?php if (!empty($loginError)) echo '<div class="error">'.$loginError.'</div>'; ?>
</div>
</body>
</html>
<?php
exit;
}
/*
|--------------------------------------------------------------------------
| 기본값
|--------------------------------------------------------------------------
*/
$currentMailList = file_exists($mailListFile) ? file_get_contents($mailListFile) : "";
$subject = $_POST['subject'] ?? "";
$body    = $_POST['body'] ?? "";
$message = "";
$results = [];
/*
|--------------------------------------------------------------------------
| 수신자 목록 저장
|--------------------------------------------------------------------------
*/
if (isset($_POST['save_maillist'])) {
    file_put_contents($mailListFile, trim($_POST['maillist']) . "\n");
    $currentMailList = file_get_contents($mailListFile);
    $message = "수신자 목록이 저장되었습니다.";
}
/*
|--------------------------------------------------------------------------
| 발송 기록 초기화
|--------------------------------------------------------------------------
*/
if (isset($_POST['reset_sent'])) {
    if (file_exists($sentFile)) {
        unlink($sentFile);
    }
    $message = "발송 기록이 초기화되었습니다.";
}
/*
|--------------------------------------------------------------------------
| 발송 함수
|--------------------------------------------------------------------------
*/
function sendMailOne(
    $to,
    $subject,
    $body,
    $smtpHost,
    $smtpPort,
    $smtpUser,
    $smtpPass,
    $fromEmail,
    $fromName
) {
    $mail = new PHPMailer(true);
    $mail->isSMTP();
    $mail->Host       = $smtpHost;
    $mail->SMTPAuth   = true;
    $mail->Username   = $smtpUser;
    $mail->Password   = $smtpPass;
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
    $mail->Port       = $smtpPort;
    $mail->CharSet = "UTF-8";
    $mail->setFrom($fromEmail, $fromName);
    $mail->addAddress($to);
    $mail->Subject = $subject;
    $mail->isHTML(true);
    $mail->Body = nl2br($body);
    $mail->AltBody = strip_tags($body);
    $mail->send();
}
/*
|--------------------------------------------------------------------------
| 발송 처리
|--------------------------------------------------------------------------
*/
if (isset($_POST['send_test']) || isset($_POST['send_all'])) {
    $subject = trim($_POST['subject'] ?? "");
    $body    = trim($_POST['body'] ?? "");
    if ($subject === "") {
        $results[] = "[FAIL] 제목이 비어 있습니다.";
    }
    if ($body === "") {
        $results[] = "[FAIL] 본문이 비어 있습니다.";
    }
    if (empty($results)) {
        $lockHandle = fopen($lockFile, "c");
        if (!$lockHandle) {
            $results[] = "[FAIL] 잠금 파일 생성 실패";
        } elseif (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
            $results[] = "[FAIL] 이미 발송 작업이 실행 중입니다.";
        } else {
            $sentList = [];
            if (file_exists($sentFile)) {
                $sentList = json_decode(file_get_contents($sentFile), true);
                if (!is_array($sentList)) {
                    $sentList = [];
                }
            }
            if (isset($_POST['send_test'])) {
                $targetList = [trim($_POST['test_email'])];
            } else {
                $targetList = file($mailListFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
                $targetList = array_unique($targetList);
            }
            foreach ($targetList as $to) {
                $to = trim($to);
                if (!$to) {
                    continue;
                }
                if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
                    $results[] = "[SKIP] 잘못된 이메일 주소: {$to}";
                    continue;
                }
                if (isset($_POST['send_all']) && isset($sentList[$to])) {
                    $results[] = "[SKIP] 이미 발송 완료: {$to}";
                    continue;
                }
                try {
                    sendMailOne(
                        $to,
                        $subject,
                        $body,
                        $smtpHost,
                        $smtpPort,
                        $smtpUser,
                        $smtpPass,
                        $fromEmail,
                        $fromName
                    );
                    if (isset($_POST['send_all'])) {
                        $sentList[$to] = [
                            "sent_at" => date("Y-m-d H:i:s"),
                            "subject" => $subject
                        ];
                        file_put_contents(
                            $sentFile,
                            json_encode($sentList, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
                            LOCK_EX
                        );
                    }
                    $results[] = "[OK] 발송 완료: {$to}";
                } catch (Exception $e) {
                    $results[] = "[FAIL] {$to} / " . $e->getMessage();
                }
                sleep(2);
            }
            flock($lockHandle, LOCK_UN);
            fclose($lockHandle);
        }
    }
}
/*
|--------------------------------------------------------------------------
| 발송 완료 건수
|--------------------------------------------------------------------------
*/
$sentCount = 0;
if (file_exists($sentFile)) {
    $tmp = json_decode(file_get_contents($sentFile), true);
    if (is_array($tmp)) {
        $sentCount = count($tmp);
    }
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>메일 발송 관리자</title>
<style>
body {
    font-family: Arial, "Malgun Gothic", sans-serif;
    background:#f4f6f9;
    margin:0;
    padding:30px;
    color:#111;
}
.container {
    max-width:1200px;
    margin:0 auto;
}
.header {
    display:flex;
    justify-content:space-between;
    align-items:center;
    margin-bottom:20px;
}
.card {
    background:#fff;
    padding:24px;
    border-radius:14px;
    box-shadow:0 4px 14px rgba(0,0,0,.08);
    margin-bottom:20px;
}
h1, h2 {
    margin-top:0;
}
textarea,
input[type="text"],
input[type="email"] {
    width:100%;
    box-sizing:border-box;
    padding:12px;
    border:1px solid #ccc;
    border-radius:8px;
    font-family:Consolas, "Malgun Gothic", sans-serif;
}
textarea {
    min-height:220px;
}
.body-area {
    min-height:420px;
}
button {
    padding:12px 18px;
    border:0;
    border-radius:8px;
    cursor:pointer;
    font-weight:bold;
}
.btn-blue { background:#1d4ed8; color:#fff; }
.btn-green { background:#15803d; color:#fff; }
.btn-red { background:#dc2626; color:#fff; }
.btn-gray { background:#555; color:#fff; }
.result {
    background:#111827;
    color:#fff;
    padding:15px;
    border-radius:10px;
    white-space:pre-line;
}
.notice {
    background:#ecfdf5;
    border:1px solid #bbf7d0;
    padding:12px;
    border-radius:8px;
    margin-bottom:15px;
}
.row {
    display:grid;
    grid-template-columns:1fr 1fr;
    gap:20px;
}
.small {
    color:#666;
    font-size:13px;
}
</style>
</head>
<body>
<div class="container">
    <div class="header">
        <h1>메일 발송 관리자</h1>
        <a href="?logout=1">로그아웃</a>
    </div>
    <?php if ($message): ?>
        <div class="notice"><?php echo htmlspecialchars($message); ?></div>
    <?php endif; ?>
    <div class="card">
        <h2>발송 상태</h2>
        <p>발송 완료 기록: <b><?php echo $sentCount; ?></b>건</p>
        <form method="post" onsubmit="return confirm('발송 기록을 초기화하시겠습니까? 이미 보낸 주소도 다시 발송 가능해집니다.');">
            <button type="submit" name="reset_sent" class="btn-red">발송 기록 초기화</button>
        </form>
    </div>
    <div class="card">
        <h2>수신자 목록</h2>
        <p class="small">한 줄에 이메일 주소 1개씩 입력합니다.</p>
        <form method="post">
            <textarea name="maillist"><?php echo htmlspecialchars($currentMailList); ?></textarea>
            <br><br>
            <button type="submit" name="save_maillist" class="btn-blue">수신자 목록 저장</button>
        </form>
    </div>
    <form method="post" id="sendForm">
        <div class="card">
            <h2>메일 제목</h2>
            <input type="text" name="subject" value="<?php echo htmlspecialchars($subject); ?>" placeholder="메일 제목을 입력하세요">
        </div>
        <div class="card">
            <h2>메일 본문</h2>
            <p class="small">줄바꿈은 메일에서도 그대로 적용됩니다. HTML 태그를 직접 입력해도 됩니다.</p>
            <textarea name="body" class="body-area" placeholder="발송할 메일 내용을 입력하세요"><?php echo htmlspecialchars($body); ?></textarea>
        </div>
        <div class="row">
            <div class="card">
                <h2>테스트 발송</h2>
                <input type="email" name="test_email" placeholder="테스트 받을 이메일 주소">
                <br><br>
                <button type="submit" name="send_test" class="btn-green">테스트 발송</button>
            </div>
            <div class="card">
                <h2>전체 발송</h2>
                <p class="small">수신자 목록에 저장된 주소 기준으로 1명씩 개별 발송됩니다.</p>
                <button type="submit" name="send_all" class="btn-red" onclick="return confirm('전체 발송하시겠습니까?');">전체 발송</button>
            </div>
        </div>
    </form>
    <?php if (!empty($results)): ?>
        <div class="card">
            <h2>발송 결과</h2>
            <div class="result"><?php echo htmlspecialchars(implode("\n", $results)); ?></div>
        </div>
    <?php endif; ?>
</div>
</body>
</html>

 3. 주요 기능

기능 설명
로그인 기능 관리자 비밀번호 인증
수신자 목록 관리 웹에서 직접 수정 가능
테스트 발송 특정 주소로 테스트 가능
전체 발송 목록 기준 개별 발송
발송 기록 저장 JSON 파일 저장
중복 발송 방지 이미 보낸 주소 제외
발송 기록 초기화 재발송 가능
lock 처리 중복 실행 방지
제목 수정 웹 UI에서 즉시 변경 가능
HTML 메일 지원 줄바꿈 및 HTML 적용 가능


 
    3.1 로그인 화면

# 비밀번호는 위에 기입 된 코드 내부에서 직접 설정합니다.

$adminPassword = "비밀번호 설정";

# 실제 운영 환경에서는 아래 사항도 함께 적용하는 것이 좋습니다.

#HTTPS 적용, 관리자 IP 제한, 세션 타임아웃, .htaccess 접근 제한 등


     3.2 메일 발송 관리자 화면

로그인 후에는 메일 발송 관리 화면이 출력됩니다. 현재 UI에서는 아래 기능을 웹에서 직접 처리 할 수 있습니다.

  기능
1 수신자 목록 저장
2 제목 수정
3 본문 수정
4 테스트 발송
5 전체 발송
6 발송 결과 확인
7 발송 기록 초기화


     3.3 발송 기록 저장 방식, 중복 실행 방지(lock), 스팸 판정 완화

#발송 완료 주소는 JSON 파일로 저장
# 해당 코드로  이미 발송한 주소를 자동 제외할 수 있습니다.

{
  "user@example.com": {
    "sent_at": "2026-05-18 13:00:00",
    "subject": "안내 메일"
  }
}


#---------------------------------------------------------------------------------------------


#발송 중 중복 실행되는 상황을 막기 위해 lock 파일 기반 처리도 추가하였습니다.
# * 락(lock) 방식은 특정 작업이 실행 중일 때 잠금 상태를 만들어 동일 작업이 동시에 중복 실행되지 않도록 막는 처리 방식입니다.


$lockFile = __DIR__ . "/send_mail.lock";


#---------------------------------------------------------------------------------------------

#메일 서버에서 너무 빠르게 대량 발송하면 스팸 판정을 받을 가능성이 높아질 수 있습니다.
#이에 발송 간 간단한 딜레이를 추가하였습니다.

sleep(2);

  4. 마무리

기존 CLI 기반의 Postfix + PHPMailer 메일 발송 시스템을 웹 UI 형태로 확장할 경우,
관리 편의성과 사용성이 크게 향상되며, PHP 단일 파일만으로도 비교적 간단하게 구성할 수 있다는 장점이 있습니다.

다만 일부 권한 설정 및 SMTP 정보 관리 방식에 따라 보안상 취약점이 발생할 수 있으므로,
운영 환경에서는 권한 관리 및 접근 제어 설정에 주의가 필요합니다.