diff --git a/data/Smarty/templates/default/mailmaga/unsubscribe.tpl b/data/Smarty/templates/default/mailmaga/unsubscribe.tpl new file mode 100644 index 0000000000..011deacab7 --- /dev/null +++ b/data/Smarty/templates/default/mailmaga/unsubscribe.tpl @@ -0,0 +1,62 @@ + + +
diff --git a/data/class/SC_SendMail.php b/data/class/SC_SendMail.php index acb51fc45b..c9b848de99 100644 --- a/data/class/SC_SendMail.php +++ b/data/class/SC_SendMail.php @@ -44,6 +44,8 @@ class SC_SendMail public $from; /** @var string */ public $reply_to; + /** @var array */ + protected $customHeaders; /** * コンストラクタ @@ -58,8 +60,9 @@ public function __construct() $this->body = ''; $this->cc = ''; $this->bcc = ''; - $this->replay_to = ''; + $this->reply_to = ''; $this->return_path = ''; + $this->customHeaders = []; $this->backend = MAIL_BACKEND; $this->host = SMTP_HOST; $this->port = SMTP_PORT; @@ -144,6 +147,47 @@ public function setReturnPath($return_path) $this->return_path = $return_path; } + /** + * カスタムヘッダーを追加 + * + * @param string $name ヘッダー名 + * @param string $value ヘッダー値 + */ + public function addCustomHeader($name, $value) + { + // ヘッダー名の形式チェック(RFC 7230 token) + if (!is_string($name) || $name === '' || !preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + trigger_error('ヘッダー名の形式が不正です。', E_USER_WARNING); + + return; + } + + // ヘッダーインジェクション対策 + if (preg_match('/[\r\n]/', $name) || preg_match('/[\r\n]/', $value)) { + trigger_error('ヘッダーに改行文字は使用できません。', E_USER_WARNING); + + return; + } + + // 重要なヘッダーの上書きを防止 + $protectedHeaders = ['from', 'to', 'subject', 'cc', 'bcc', 'reply-to', 'return-path', 'date', 'mime-version', 'content-type', 'content-transfer-encoding']; + if (in_array(strtolower($name), $protectedHeaders, true)) { + trigger_error('保護されたヘッダーは上書きできません: '.$name, E_USER_WARNING); + + return; + } + + $this->customHeaders[$name] = $value; + } + + /** + * カスタムヘッダーをクリア + */ + public function clearCustomHeaders() + { + $this->customHeaders = []; + } + // 件名の設定 public function setSubject($subject) { @@ -284,6 +328,11 @@ public function getBaseHeader() $arrHeader['Date'] = date('D, j M Y H:i:s O'); $arrHeader['Content-Transfer-Encoding'] = '7bit'; + // カスタムヘッダーをマージ + foreach ($this->customHeaders as $name => $value) { + $arrHeader[$name] = $value; + } + return $arrHeader; } diff --git a/data/class/batch/SC_Batch_CleanupMailmagaToken.php b/data/class/batch/SC_Batch_CleanupMailmagaToken.php new file mode 100644 index 0000000000..4696e24440 --- /dev/null +++ b/data/class/batch/SC_Batch_CleanupMailmagaToken.php @@ -0,0 +1,117 @@ +start('メルマガ登録解除トークンクリーンアップバッチ'); + + try { + $count = SC_Helper_Mailmaga_Ex::cleanupExpiredTokens(); + + if ($count > 0) { + $msg = "期限切れ・使用済みトークン {$count} 件を削除しました。"; + $this->printLog($msg); + } else { + $msg = 'クリーンアップ対象のトークンはありませんでした。'; + $this->printLog($msg); + } + + $this->end(); + } catch (Exception $e) { + $msg = 'エラーが発生しました: '.$e->getMessage(); + $this->printLog($msg); + GC_Utils_Ex::gfPrintLog($msg, ERROR_LOG_REALFILE); + $this->end(true); + } + } + + /** + * バッチ開始ログを出力する + * + * @param string $message バッチ名 + * + * @return void + */ + protected function start($message = '') + { + $msg = '========================================'; + $this->printLog($msg); + $msg = $message.' 開始: '.date('Y-m-d H:i:s'); + $this->printLog($msg); + } + + /** + * バッチ終了ログを出力する + * + * @param bool $error エラー終了フラグ + * + * @return void + */ + protected function end($error = false) + { + if ($error) { + $msg = 'バッチ処理がエラーで終了しました: '.date('Y-m-d H:i:s'); + } else { + $msg = 'バッチ処理が正常に終了しました: '.date('Y-m-d H:i:s'); + } + $this->printLog($msg); + $msg = '========================================'; + $this->printLog($msg); + } + + /** + * ログメッセージを出力する + * + * @param string $message ログメッセージ + * + * @return void + */ + protected function printLog($message) + { + GC_Utils_Ex::gfPrintLog($message, LOG_REALFILE); + echo $message.PHP_EOL; + } +} diff --git a/data/class/helper/SC_Helper_Mail.php b/data/class/helper/SC_Helper_Mail.php index c836e48131..dc9b9713c3 100644 --- a/data/class/helper/SC_Helper_Mail.php +++ b/data/class/helper/SC_Helper_Mail.php @@ -531,6 +531,16 @@ public static function sfSendMailmagazine($send_id) $subjectBody = preg_replace('/{name}/', $customerName, $arrMail['subject']); $mailBody = preg_replace('/{name}/', $customerName, $arrMail['body']); + // ワンクリック登録解除トークンの生成 + $token = SC_Helper_Mailmaga_Ex::generateUnsubscribeToken( + $arrDestination['customer_id'], + $send_id, + $arrDestination['email'] + ); + + // ワンクリック登録解除URLの生成 + $unsubscribeUrl = SC_Helper_Mailmaga_Ex::getUnsubscribeUrl($token); + $objMail->setItem( $arrDestination['email'], $subjectBody, @@ -542,16 +552,23 @@ public static function sfSendMailmagazine($send_id) $objSite['email04'] // errors_to ); + // RFC 8058 ヘッダーの追加 + $objMail->addCustomHeader('List-Unsubscribe', '<'.$unsubscribeUrl.'>'); + $objMail->addCustomHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + // テキストメール配信の場合 if ($arrMail['mail_method'] == 2) { - $sendResut = $objMail->sendMail(); + $sendResult = $objMail->sendMail(); // HTMLメール配信の場合 } else { - $sendResut = $objMail->sendHtmlMail(); + $sendResult = $objMail->sendHtmlMail(); } + // カスタムヘッダーをクリア(次の送信のため) + $objMail->clearCustomHeaders(); + // 送信完了なら1、失敗なら2をメール送信結果フラグとしてDBに挿入 - if (!$sendResut) { + if (!$sendResult) { $sendFlag = '2'; } else { // 完了を 1 増やす @@ -584,10 +601,10 @@ public static function sfSendMailmagazine($send_id) // テキストメール配信の場合 if ($arrMail['mail_method'] == 2) { - $sendResut = $objMail->sendMail(); + $sendResult = $objMail->sendMail(); // HTMLメール配信の場合 } else { - $sendResut = $objMail->sendHtmlMail(); + $sendResult = $objMail->sendHtmlMail(); } return; diff --git a/data/class/helper/SC_Helper_Mailmaga.php b/data/class/helper/SC_Helper_Mailmaga.php new file mode 100644 index 0000000000..378c962ef5 --- /dev/null +++ b/data/class/helper/SC_Helper_Mailmaga.php @@ -0,0 +1,173 @@ +nextVal('dtb_mailmaga_unsubscribe_token_mailmaga_unsubscribe_token_id'); + + $objQuery->insert('dtb_mailmaga_unsubscribe_token', $sqlval); + + return $token; + } + + /** + * ワンクリック登録解除URLを生成 + * + * @param string $token トークン文字列 + * + * @return string 完全なURL + */ + public static function getUnsubscribeUrl($token) + { + return HTTPS_URL.'mailmaga/unsubscribe/index.php?token='.urlencode($token); + } + + /** + * トークンの検証と取得 + * + * @param string $token トークン文字列 + * + * @return array|false トークン情報の配列 or false + */ + public static function validateToken($token) + { + $objQuery = SC_Query_Ex::getSingletonInstance(); + + $now = date('Y-m-d H:i:s'); + $where = 'token = ? AND used_flg = 0 AND expire_date > ?'; + $arrToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', $where, [$token, $now]); + + if (empty($arrToken)) { + return false; + } + + return $arrToken; + } + + /** + * トークンを使用済みにマーク + * + * @param string $token トークン文字列 + * + * @return bool 成功した場合 true + */ + public static function markTokenAsUsed($token) + { + $objQuery = SC_Query_Ex::getSingletonInstance(); + + $sqlval = []; + $sqlval['used_flg'] = 1; + $sqlval['used_date'] = date('Y-m-d H:i:s'); + + $ret = $objQuery->update( + 'dtb_mailmaga_unsubscribe_token', + $sqlval, + 'token = ?', + [$token] + ); + + return $ret > 0; + } + + /** + * メルマガ配信を解除(mailmaga_flg を 3 に設定) + * + * @param int $customer_id 会員ID + * + * @return bool 成功した場合 true + */ + public static function unsubscribeMailmaga($customer_id) + { + $objQuery = SC_Query_Ex::getSingletonInstance(); + + $sqlval = []; + $sqlval['mailmaga_flg'] = 3; // 配信拒否 + $sqlval['update_date'] = date('Y-m-d H:i:s'); + + $ret = $objQuery->update( + 'dtb_customer', + $sqlval, + 'customer_id = ?', + [$customer_id] + ); + + return $ret > 0; + } + + /** + * 期限切れトークンのクリーンアップ + * (バッチ処理として定期実行を想定) + * + * @return int 削除件数 + */ + public static function cleanupExpiredTokens() + { + $objQuery = SC_Query_Ex::getSingletonInstance(); + + $now = date('Y-m-d H:i:s'); + $where = 'expire_date < ? OR used_flg = 1'; + $ret = $objQuery->delete('dtb_mailmaga_unsubscribe_token', $where, [$now]); + + return $ret; + } +} diff --git a/data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php b/data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php new file mode 100644 index 0000000000..abb5a9cbf9 --- /dev/null +++ b/data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php @@ -0,0 +1,164 @@ +tpl_title = 'メルマガ登録解除'; + $this->tpl_success = false; + $this->tpl_mainpage = 'mailmaga/unsubscribe.tpl'; + } + + /** + * Page のプロセス. + * + * @return void + */ + public function process() + { + parent::process(); + $this->action(); + $this->sendResponse(); + } + + /** + * Page のアクション. + * + * @return void + */ + public function action() + { + // トークンの取得 (URL クエリパラメータから取得) + $token = $_GET['token'] ?? ''; + + // トークンの検証 + if (empty($token) || !preg_match('/^[0-9a-fA-F]{64}$/', $token)) { + $this->tpl_message = '無効なURLです。'; + + return; + } + + $arrToken = SC_Helper_Mailmaga_Ex::validateToken($token); + + if ($arrToken === false) { + $this->tpl_message = 'このURLは無効か、既に使用済みか、有効期限が切れています。'; + + return; + } + + // RFC 8058 準拠: POST リクエストで List-Unsubscribe=One-Click の場合 + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $postBody = file_get_contents('php://input'); + + // List-Unsubscribe=One-Click の確認 + if ($postBody === 'List-Unsubscribe=One-Click') { + // ワンクリック登録解除処理 + $this->processUnsubscribe($arrToken, $token); + + // RFC 8058: 成功時は 200 OK を返す(コンテンツなし) + http_response_code(200); + exit; + } else { + // 通常のフォーム送信(確認ページからの送信) + $mode = $_POST['mode'] ?? ''; + + if ($mode === 'confirm') { + $this->processUnsubscribe($arrToken, $token); + + return; + } + } + } + + // GET リクエストの場合: 確認ページを表示 + $this->tpl_email = $arrToken['email']; + } + + /** + * 登録解除処理 + * + * @param array $arrToken トークン情報 + * @param string $token トークン文字列 + * + * @return void + */ + protected function processUnsubscribe($arrToken, $token) + { + $objQuery = SC_Query_Ex::getSingletonInstance(); + + $objQuery->begin(); + + try { + // メルマガ配信フラグを「配信拒否」に更新 + $success = SC_Helper_Mailmaga_Ex::unsubscribeMailmaga($arrToken['customer_id']); + + if (!$success) { + throw new Exception('メルマガ登録解除に失敗しました。'); + } + + // トークンを使用済みにマーク + if (!SC_Helper_Mailmaga_Ex::markTokenAsUsed($token)) { + throw new Exception('トークンの無効化に失敗しました。'); + } + + $objQuery->commit(); + + $this->tpl_success = true; + $this->tpl_message = 'メルマガの登録を解除しました。'; + } catch (Exception $e) { + $objQuery->rollback(); + + $this->tpl_success = false; + $this->tpl_message = 'エラーが発生しました: '.$e->getMessage(); + + GC_Utils_Ex::gfPrintLog($e->getMessage()); + } + } +} diff --git a/data/migrations/Version20260401000001_CreateMailmagaUnsubscribeTokenTable.php b/data/migrations/Version20260401000001_CreateMailmagaUnsubscribeTokenTable.php new file mode 100644 index 0000000000..8524580b9b --- /dev/null +++ b/data/migrations/Version20260401000001_CreateMailmagaUnsubscribeTokenTable.php @@ -0,0 +1,37 @@ +create('dtb_mailmaga_unsubscribe_token', function (Table $table) { + $table->serial(); + $table->integer('customer_id')->notNull(); + $table->integer('send_id')->notNull(); + $table->string('token', 64)->notNull(); + $table->string('email', 255)->notNull(); + $table->smallint('used_flg')->notNull()->default(0); + $table->timestamp('used_date')->nullable(); + $table->timestamp('expire_date')->notNull(); + $table->timestamp('create_date')->notNull()->default('CURRENT_TIMESTAMP'); + + $table->unique(['token']); + $table->index(['customer_id']); + $table->index(['send_id']); + $table->index(['expire_date']); + }); + } + + public function down(): void + { + $this->drop('dtb_mailmaga_unsubscribe_token'); + } +} diff --git a/html/install/sql/create_table_sqlite3.sql b/html/install/sql/create_table_sqlite3.sql index 1dccbcf8a7..20ad90bf35 100644 --- a/html/install/sql/create_table_sqlite3.sql +++ b/html/install/sql/create_table_sqlite3.sql @@ -106,4 +106,4 @@ CREATE INDEX dtb_mobile_ext_session_id_create_date_key ON dtb_mobile_ext_session CREATE INDEX dtb_session_update_date_key ON dtb_session (update_date); CREATE TABLE dtb_login_attempt ( login_attempt_id INTEGER NOT NULL, login_id text NOT NULL, ip_address text, user_agent text, result INTEGER NOT NULL, create_date TEXT NOT NULL DEFAULT (datetime('now','localtime')), PRIMARY KEY (login_attempt_id)); CREATE INDEX idx_login_id_create_date ON dtb_login_attempt (login_id, create_date); -CREATE INDEX idx_ip_create_date ON dtb_login_attempt (ip_address, create_date); \ No newline at end of file +CREATE INDEX idx_ip_create_date ON dtb_login_attempt (ip_address, create_date); diff --git a/html/mailmaga/unsubscribe/index.php b/html/mailmaga/unsubscribe/index.php new file mode 100644 index 0000000000..e2a5bb0fdf --- /dev/null +++ b/html/mailmaga/unsubscribe/index.php @@ -0,0 +1,28 @@ +init(); +$objPage->process(); diff --git a/tests/class/SC_SendMailTest.php b/tests/class/SC_SendMailTest.php index 5b15864466..cb851dd890 100644 --- a/tests/class/SC_SendMailTest.php +++ b/tests/class/SC_SendMailTest.php @@ -174,4 +174,72 @@ public function testGetBackendParams() $this->actual = $this->objSendMail->getBackendParams('smtp'); $this->verify(); } + + public function testAddCustomHeader() + { + $this->resetEmails(); + + $this->objSendMail->setItem( + 'to@example.com', + '件名', + '本文', + 'from@example.com', + '差出人名' + ); + + // カスタムヘッダーを追加 + $this->objSendMail->addCustomHeader('List-Unsubscribe', '