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', ''); + $this->objSendMail->addCustomHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + + $result = $this->objSendMail->sendMail(); + $this->assertTrue($result); + + $message = $this->getLastMailCatcherMessage(); + + // ヘッダーに List-Unsubscribe が含まれているか確認 + $this->assertStringContainsString('List-Unsubscribe: ', $message['source']); + $this->assertStringContainsString('List-Unsubscribe-Post: List-Unsubscribe=One-Click', $message['source']); + } + + public function testClearCustomHeaders() + { + $this->objSendMail->addCustomHeader('X-Test', 'test'); + $this->objSendMail->clearCustomHeaders(); + + $header = $this->objSendMail->getBaseHeader(); + + $this->assertArrayNotHasKey('X-Test', $header); + } + + public function testAddCustomHeaderPreventHeaderInjection() + { + // 改行文字を含むヘッダーは追加されない + $this->objSendMail->addCustomHeader("X-Test\r\n", 'test'); + $header = $this->objSendMail->getBaseHeader(); + $this->assertArrayNotHasKey("X-Test\r\n", $header); + + // 値に改行文字を含む場合も追加されない + $this->objSendMail->addCustomHeader('X-Test', "test\r\nvalue"); + $header = $this->objSendMail->getBaseHeader(); + $this->assertArrayNotHasKey('X-Test', $header); + } + + public function testAddCustomHeaderPreventProtectedHeaderOverride() + { + $this->objSendMail->setItem( + 'to@example.com', + '件名', + '本文', + 'from@example.com', + '差出人名' + ); + + // 保護されたヘッダーは上書きできない + $this->objSendMail->addCustomHeader('From', 'attacker@example.com'); + $header = $this->objSendMail->getBaseHeader(); + + // From ヘッダーは元の値のまま + $this->assertStringContainsString('from@example.com', $header['From']); + $this->assertStringNotContainsString('attacker@example.com', $header['From']); + } } diff --git a/tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php b/tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php new file mode 100644 index 0000000000..8843d76391 --- /dev/null +++ b/tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php @@ -0,0 +1,180 @@ +delete('dtb_mailmaga_unsubscribe_token'); + } + + public function testGenerateUnsubscribeToken() + { + $token = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'test@example.com'); + + $this->assertIsString($token); + $this->assertEquals(64, strlen($token)); + + // トークンがDBに保存されているか確認 + $objQuery = SC_Query_Ex::getSingletonInstance(); + $arrToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', 'token = ?', [$token]); + + $this->assertNotEmpty($arrToken); + $this->assertEquals(1, $arrToken['customer_id']); + $this->assertEquals(1, $arrToken['send_id']); + $this->assertEquals('test@example.com', $arrToken['email']); + $this->assertEquals(0, $arrToken['used_flg']); + } + + public function testGetUnsubscribeUrl() + { + $token = 'test-token-123'; + $url = SC_Helper_Mailmaga::getUnsubscribeUrl($token); + + $this->assertStringContainsString('mailmaga/unsubscribe/index.php', $url); + $this->assertStringContainsString('token=test-token-123', $url); + $this->assertStringStartsWith(HTTPS_URL, $url); + } + + public function testValidateTokenValidToken() + { + // トークンを生成 + $token = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'test@example.com'); + + // トークンを検証 + $arrToken = SC_Helper_Mailmaga::validateToken($token); + + $this->assertIsArray($arrToken); + $this->assertEquals(1, $arrToken['customer_id']); + $this->assertEquals('test@example.com', $arrToken['email']); + } + + public function testValidateTokenInvalidToken() + { + $result = SC_Helper_Mailmaga::validateToken('invalid-token'); + + $this->assertFalse($result); + } + + public function testValidateTokenUsedToken() + { + // トークンを生成 + $token = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'test@example.com'); + + // トークンを使用済みにする + SC_Helper_Mailmaga::markTokenAsUsed($token); + + // 使用済みトークンは無効 + $result = SC_Helper_Mailmaga::validateToken($token); + + $this->assertFalse($result); + } + + public function testValidateTokenExpiredToken() + { + // トークンを生成 + $token = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'test@example.com'); + + // トークンを期限切れにする + $objQuery = SC_Query_Ex::getSingletonInstance(); + $objQuery->update( + 'dtb_mailmaga_unsubscribe_token', + ['expire_date' => date('Y-m-d H:i:s', strtotime('-1 day'))], + 'token = ?', + [$token] + ); + + // 期限切れトークンは無効 + $result = SC_Helper_Mailmaga::validateToken($token); + + $this->assertFalse($result); + } + + public function testMarkTokenAsUsed() + { + // トークンを生成 + $token = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'test@example.com'); + + // トークンを使用済みにする + $result = SC_Helper_Mailmaga::markTokenAsUsed($token); + + $this->assertTrue($result); + + // DBで used_flg が 1 になっているか確認 + $objQuery = SC_Query_Ex::getSingletonInstance(); + $arrToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', 'token = ?', [$token]); + + $this->assertEquals(1, $arrToken['used_flg']); + $this->assertNotNull($arrToken['used_date']); + } + + public function testUnsubscribeMailmaga() + { + // テスト用顧客を作成 + $objQuery = SC_Query_Ex::getSingletonInstance(); + $customer_id = $objQuery->nextVal('dtb_customer_customer_id'); + + $sqlval = [ + 'customer_id' => $customer_id, + 'name01' => 'テスト', + 'name02' => '太郎', + 'email' => 'test@example.com', + 'secret_key' => SC_Helper_Customer_Ex::sfGetUniqSecretKey(), + 'status' => 2, + 'mailmaga_flg' => 1, // HTML + 'create_date' => 'CURRENT_TIMESTAMP', + 'update_date' => 'CURRENT_TIMESTAMP', + ]; + $objQuery->insert('dtb_customer', $sqlval); + + // メルマガ登録解除 + $result = SC_Helper_Mailmaga::unsubscribeMailmaga($customer_id); + + $this->assertTrue($result); + + // mailmaga_flg が 3 になっているか確認 + $arrCustomer = $objQuery->getRow('*', 'dtb_customer', 'customer_id = ?', [$customer_id]); + $this->assertEquals(3, $arrCustomer['mailmaga_flg']); + } + + public function testCleanupExpiredTokens() + { + // 有効なトークンを生成 + $validToken = SC_Helper_Mailmaga::generateUnsubscribeToken(1, 1, 'valid@example.com'); + + // 期限切れトークンを生成 + $expiredToken = SC_Helper_Mailmaga::generateUnsubscribeToken(2, 2, 'expired@example.com'); + $objQuery = SC_Query_Ex::getSingletonInstance(); + $objQuery->update( + 'dtb_mailmaga_unsubscribe_token', + ['expire_date' => date('Y-m-d H:i:s', strtotime('-1 day'))], + 'token = ?', + [$expiredToken] + ); + + // 使用済みトークンを生成 + $usedToken = SC_Helper_Mailmaga::generateUnsubscribeToken(3, 3, 'used@example.com'); + SC_Helper_Mailmaga::markTokenAsUsed($usedToken); + + // クリーンアップを実行 + $deletedCount = SC_Helper_Mailmaga::cleanupExpiredTokens(); + + // 期限切れトークンと使用済みトークンが削除される + $this->assertEquals(2, $deletedCount); + + // 有効なトークンは残っているか確認 + $arrValidToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', 'token = ?', [$validToken]); + $this->assertNotEmpty($arrValidToken); + + // 期限切れトークンは削除されているか確認 + $arrExpiredToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', 'token = ?', [$expiredToken]); + $this->assertEmpty($arrExpiredToken); + + // 使用済みトークンは削除されているか確認 + $arrUsedToken = $objQuery->getRow('*', 'dtb_mailmaga_unsubscribe_token', 'token = ?', [$usedToken]); + $this->assertEmpty($arrUsedToken); + } +}