Skip to content

Issue #819: RFC 8058対応のワンクリックメルマガ登録解除機能#1316

Open
nobuhiko wants to merge 8 commits intomasterfrom
feature/issue-819-mailmaga-one-click-unsubscribe
Open

Issue #819: RFC 8058対応のワンクリックメルマガ登録解除機能#1316
nobuhiko wants to merge 8 commits intomasterfrom
feature/issue-819-mailmaga-one-click-unsubscribe

Conversation

@nobuhiko
Copy link
Copy Markdown
Contributor

@nobuhiko nobuhiko commented Jan 23, 2026

Summary

Google/Yahoo のメール送信ガイドライン変更に対応するため、RFC 8058 準拠のワンクリックメルマガ登録解除機能を実装しました。

主な変更点

  • データベース: dtb_mailmaga_unsubscribe_token テーブルを追加(トークン管理)
  • 新規クラス: SC_Helper_Mailmaga を作成(トークン生成・検証・登録解除処理)
  • SC_SendMail拡張: カスタムヘッダー追加機能を実装(addCustomHeader(), clearCustomHeaders()
  • メルマガ配信: 配信時に List-UnsubscribeList-Unsubscribe-Post ヘッダーを自動追加
  • 登録解除ページ: /mailmaga/unsubscribe/ を新規作成(GET/POST対応)
  • テスト: PHPUnit テストを追加(21 tests, 69 assertions - 全て成功)

RFC 8058 要件

  • List-Unsubscribe ヘッダー: HTTPS URL を含む
  • List-Unsubscribe-Post ヘッダー: List-Unsubscribe=One-Click 固定値
  • ✅ 不透明識別子(トークン)でセキュリティを確保
  • ✅ POST リクエストで登録解除を処理

セキュリティ対策

  • 64文字のランダム文字列トークン(256ビット相当)
  • トークン有効期限90日
  • ヘッダーインジェクション対策(改行文字のバリデーション)
  • 保護されたヘッダーの上書き防止
  • 使用済みトークンの再利用防止

後方互換性

  • 既存のメルマガ配信機能への影響なし
  • 新テーブル追加のみで既存テーブルの変更なし
  • SC_SendMail クラスの変更は後方互換性を維持

Test plan

  • PHPUnit全テスト通過(MySQL)
    • SC_Helper_Mailmaga: 9 tests, 25 assertions
    • SC_SendMail: 12 tests, 44 assertions
  • PHPUnit全テスト通過(PostgreSQL)
  • E2Eテスト全テスト通過(MySQL & PostgreSQL)
  • 手動テスト完了
    • メルマガ配信時のヘッダー確認
    • GET リクエストで確認ページ表示
    • POST リクエストでワンクリック登録解除
    • トークンの再利用防止確認

関連Issue

Closes #819

🤖 Generated with Claude Code

Summary by CodeRabbit

リリースノート

  • 新機能

    • メールマガジンのワンクリック配信停止機能を追加。ワンタイムトークンで簡単に配信停止できます。
    • 受信メールにRFC準拠の「List-Unsubscribe」ヘッダーを付与し、ワンクリック退会に対応しました。
    • 配信停止用のユーザー向け画面(確認/成功/エラー表示)を追加しました。
  • 不具合修正

    • 送信メールのreply-to初期化の不具合を修正しました。
  • テスト

    • 追加機能の自動テストを導入しました。

Google/Yahoo のメール送信ガイドライン変更に対応するため、
RFC 8058 準拠のワンクリックメルマガ登録解除機能を実装しました。

主な変更点:
- dtb_mailmaga_unsubscribe_token テーブルを追加(トークン管理)
- SC_Helper_Mailmaga クラスを新規作成(トークン生成・検証)
- SC_SendMail にカスタムヘッダー追加機能を実装
- メルマガ配信時に List-Unsubscribe と List-Unsubscribe-Post ヘッダーを追加
- ワンクリック登録解除ページを作成(/mailmaga/unsubscribe/)
- PHPUnit テストを追加(21 tests, 69 assertions)

セキュリティ:
- 64文字のランダムトークン(256ビット相当)
- トークン有効期限90日
- ヘッダーインジェクション対策
- 使用済みトークンの再利用防止

関連Issue: #819

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Jan 23, 2026

Codecov Report

❌ Patch coverage is 82.89474% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.61%. Comparing base (a695fd4) to head (40cba44).
⚠️ Report is 115 commits behind head on master.

Files with missing lines Patch % Lines
data/class/helper/SC_Helper_Mail.php 0.00% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1316      +/-   ##
==========================================
- Coverage   54.72%   54.61%   -0.11%     
==========================================
  Files          84       85       +1     
  Lines       10815    10892      +77     
==========================================
+ Hits         5918     5949      +31     
- Misses       4897     4943      +46     
Flag Coverage Δ
tests 54.61% <82.89%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- null合体演算子(??)を使用(isset() ? : から変更)
- class定数をpublic constに変更(PSR準拠)
- テストメソッド名をキャメルケースに変更(PSR-1準拠)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements RFC 8058-compliant one-click unsubscribe functionality for newsletter emails to meet Google and Yahoo's updated email sender guidelines. The implementation adds database infrastructure for token management, a new helper class for unsubscribe operations, custom email header support, an unsubscribe landing page, and comprehensive test coverage.

Changes:

  • Introduces dtb_mailmaga_unsubscribe_token table with PostgreSQL and MySQL schemas for secure token storage with 90-day expiration
  • Adds SC_Helper_Mailmaga helper class implementing token generation, validation, and unsubscribe operations with cleanup functionality
  • Extends SC_SendMail class with custom header methods (addCustomHeader, clearCustomHeaders) including security protections against header injection and protected header overwrites
  • Integrates RFC 8058 headers (List-Unsubscribe, List-Unsubscribe-Post) into newsletter sending workflow via SC_Helper_Mail
  • Creates new unsubscribe page at /mailmaga/unsubscribe/ supporting both GET confirmation displays and POST one-click unsubscribe requests
  • Provides comprehensive PHPUnit test coverage (21 tests, 69 assertions) for both helper and mail sending functionality

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
html/install/sql/create_table_pgsql.sql Adds PostgreSQL schema for unsubscribe token table with indexes
html/install/sql/create_table_mysqli.sql Adds MySQL schema for unsubscribe token table with indexes
eccube_install.sh Registers sequence for token_id generation in PostgreSQL
data/class/helper/SC_Helper_Mailmaga.php New helper class for token management and unsubscribe operations
data/class/SC_SendMail.php Extends email class with custom header support and security validations
data/class/helper/SC_Helper_Mail.php Integrates RFC 8058 headers into newsletter sending workflow
data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php New page class handling unsubscribe requests per RFC 8058
html/mailmaga/unsubscribe/index.php Entry point for unsubscribe page
data/Smarty/templates/default/mailmaga/unsubscribe.tpl User interface template for unsubscribe confirmation and completion
tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php Comprehensive unit tests for helper class (9 tests, 25 assertions)
tests/class/SC_SendMailTest.php Extended tests for custom header functionality (12 tests, 44 assertions)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

// トークンを使用済みにマーク
SC_Helper_Mailmaga_Ex::markTokenAsUsed($token);
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unsubscribe process uses a transaction but doesn't handle the case where markTokenAsUsed returns false. If marking the token fails but the unsubscribe succeeds, the token could be reused. Consider checking the return value of markTokenAsUsed and throwing an exception if it fails to ensure atomicity.

Suggested change
SC_Helper_Mailmaga_Ex::markTokenAsUsed($token);
$tokenMarked = SC_Helper_Mailmaga_Ex::markTokenAsUsed($token);
if (!$tokenMarked) {
throw new Exception('トークンの無効化に失敗しました。');
}

Copilot uses AI. Check for mistakes.

$sqlval = [];
$sqlval['used_flag'] = 1;
$sqlval['used_date'] = 'CURRENT_TIMESTAMP';
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'used_date' field is set to the string 'CURRENT_TIMESTAMP' which may not work correctly in all database scenarios. This could result in the literal string being inserted instead of the actual timestamp value. Consider using a consistent approach with other timestamp fields in the codebase.

Suggested change
$sqlval['used_date'] = 'CURRENT_TIMESTAMP';
$sqlval['used_date'] = date('Y-m-d H:i:s');

Copilot uses AI. Check for mistakes.

$sqlval = [];
$sqlval['mailmaga_flg'] = 3; // 配信拒否
$sqlval['update_date'] = 'CURRENT_TIMESTAMP';
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'update_date' field is set to the string 'CURRENT_TIMESTAMP' which may not be properly handled by the database layer. This could lead to inconsistent behavior across different database engines or configurations. Consider using a more reliable approach for setting timestamp values.

Suggested change
$sqlval['update_date'] = 'CURRENT_TIMESTAMP';
$sqlval['update_date'] = date('Y-m-d H:i:s');

Copilot uses AI. Check for mistakes.
<!--{else}-->
<p class="message">メールアドレス: <strong><!--{$tpl_email|h}--></strong></p>
<p>メールマガジンの登録を解除しますか?</p>
<form method="post" action="<!--{$smarty.server.REQUEST_URI|h}-->">
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using REQUEST_URI in the form action poses a CSRF vulnerability risk and potential open redirect vulnerabilities. The form should POST to a known, safe endpoint rather than echoing back the current URI. Consider using a fixed action URL or implementing CSRF protection tokens if REQUEST_URI must be used.

Suggested change
<form method="post" action="<!--{$smarty.server.REQUEST_URI|h}-->">
<form method="post">

Copilot uses AI. Check for mistakes.
token varchar(64) NOT NULL,
email varchar(255) NOT NULL,
used_flag smallint NOT NULL DEFAULT 0,
used_date timestamp DEFAULT NULL,
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostgreSQL schema uses 'timestamp DEFAULT NULL' for the used_date column, but PostgreSQL does not allow DEFAULT NULL on a timestamp column in this syntax. This should be 'timestamp' without the DEFAULT clause, or use 'timestamp DEFAULT NULL' only if the column is explicitly nullable. This could cause the table creation to fail on PostgreSQL.

Suggested change
used_date timestamp DEFAULT NULL,
used_date timestamp,

Copilot uses AI. Check for mistakes.
$sqlval['email'] = $email;
$sqlval['used_flag'] = 0;
$sqlval['expire_date'] = date('Y-m-d H:i:s', strtotime('+'.self::TOKEN_EXPIRE_DAYS.' days'));
$sqlval['create_date'] = 'CURRENT_TIMESTAMP';
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'create_date' field is set to the string 'CURRENT_TIMESTAMP' rather than being handled by the database. In some database scenarios, this literal string might be inserted instead of the current timestamp. Consider using the database's built-in handling or PHP's date function to ensure consistency across different database configurations.

Suggested change
$sqlval['create_date'] = 'CURRENT_TIMESTAMP';
$sqlval['create_date'] = date('Y-m-d H:i:s');

Copilot uses AI. Check for mistakes.
@@ -60,6 +62,7 @@ public function __construct()
$this->bcc = '';
$this->replay_to = '';
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in the property name. Line 63 sets 'replay_to' but the property declaration on line 46 is 'reply_to'. This inconsistency will cause 'replay_to' to be set as a new dynamic property instead of the intended 'reply_to' property, potentially breaking reply-to functionality.

Copilot uses AI. Check for mistakes.

return;
}

Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token parameter is not validated for format or length before being passed to validateToken. While validateToken will fail gracefully for invalid tokens, adding basic input validation (e.g., checking that the token is exactly 64 hexadecimal characters) would prevent unnecessary database queries for obviously malformed tokens and provide better error messages.

Suggested change
// トークン形式の簡易チェック(64 桁の 16 進数)
if (!preg_match('/^[0-9a-fA-F]{64}$/', $token)) {
$this->tpl_message = '無効なURLです。';
return;
}

Copilot uses AI. Check for mistakes.

// テキストメール配信の場合
if ($arrMail['mail_method'] == 2) {
$sendResut = $objMail->sendMail();
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name '$sendResut' is misspelled - it should be '$sendResult'. This typo appears multiple times in the mail sending loop and could cause confusion during maintenance. While this doesn't break functionality (as the variable is used consistently), it reduces code quality.

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +79
// トークンの取得
$token = $_REQUEST['token'] ?? '';
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using $_REQUEST to get the token combines GET, POST, and COOKIE data, which could potentially be exploited through cookie poisoning attacks. According to RFC 8058, the token should come from the URL query parameter for GET requests and POST body for POST requests. Use $_GET['token'] specifically to prevent potential security issues.

Suggested change
// トークンの取得
$token = $_REQUEST['token'] ?? '';
// トークンの取得 (URL クエリパラメータから取得)
$token = $_GET['token'] ?? '';

Copilot uses AI. Check for mistakes.
@nanasess
Copy link
Copy Markdown
Contributor

下位互換性を維持してそうですが、慎重に確認を進めます

@nobuhiko
Copy link
Copy Markdown
Contributor Author

@nanasess https://www.coderabbit.ai/ja とか導入するとどうなんでしょうね。もっとちゃんとやらないと役に立たないかな

@nanasess
Copy link
Copy Markdown
Contributor

@nobuhiko https://www.coderabbit.ai/ja は開発者数に対する課金なので難しそうですね。。。
gemini-code-assist とか入れてしまいたいんですが、交渉があまり進んでいません😢

@nobuhiko
Copy link
Copy Markdown
Contributor Author

@nanasess
GitHubまたはGitLabでCodeRabbitに登録し、公開リポジトリにCodeRabbitをインストールするだけで、公開リポジトリのレビューを永久に無料で受けられます。追加の設定は不要です。

だけじゃ足りないですかね〜?

@nanasess
Copy link
Copy Markdown
Contributor

@nobuhiko OSS は無料なんですね。ちょっと交渉してみます!

@nanasess
Copy link
Copy Markdown
Contributor

@coderabbitai レビューお願いします

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 28, 2026

@nanasess: かしこまりました。RFC 8058対応のワンクリックメルマガ登録解除機能の実装についてレビューを開始いたします。

変更内容を確認させていただきます:

  • データベーステーブルの追加
  • ヘルパークラスとSC_SendMailの拡張
  • 登録解除ページの実装
  • セキュリティ対策と後方互換性

詳細なレビューを実施いたします。

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

RFC 8058準拠のワンクリックメルマガ登録解除を追加。送信時に受信者別トークンを生成して List-Unsubscribe ヘッダーを注入し、トークン管理テーブル、ページ、テンプレート、ヘルパー、テストを導入しています。

Changes

Cohort / File(s) Summary
DB スキーマ
html/install/sql/create_table_mysqli.sql, html/install/sql/create_table_pgsql.sql, html/install/sql/create_table_sqlite3.sql, eccube_install.sh
新テーブル dtb_mailmaga_unsubscribe_token とインデックスを追加。インストール用シーケンス定義を追加。
メール送信クラス
data/class/SC_SendMail.php
カスタムヘッダー管理を追加($customHeadersaddCustomHeader()clearCustomHeaders())。ヘッダー注入防止と保護ヘッダー上書き防止を実装。返信先プロパティ初期化のバグ修正。
メルマガ送信統合
data/class/helper/SC_Helper_Mail.php
送信ごとに受信者別トークンを生成し、List-UnsubscribeList-Unsubscribe-Post ヘッダーをカスタムヘッダーとして注入。送信後にカスタムヘッダーをクリア。変数名のタイポ修正。
トークン管理ヘルパー
data/class/helper/SC_Helper_Mailmaga.php
トークン生成・保存・URL生成・検証・使用マーク・顧客の登録解除・期限切れトークン削除を提供する新クラスを追加。定数で有効期限・長さを管理。
登録解除ページ
data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php, html/mailmaga/unsubscribe/index.php, data/Smarty/templates/default/mailmaga/unsubscribe.tpl
トークン検証、ワンクリック(List-Unsubscribe=One-Click POST)と確認フロー(GET→POST)を実装するページとテンプレート、エントリーポイントを追加。
テスト
tests/class/SC_SendMailTest.php, tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php
カスタムヘッダーの挙動(追加・クリア・注入防止・保護ヘッダー防止)とトークン管理API(生成・検証・使用・クリーンアップ・顧客解除)を網羅するテストを追加。

Sequence Diagram(s)

sequenceDiagram
    participant User as "ユーザー"
    participant MailSystem as "メルマガ送信"
    participant Token as "トークン管理\n(SC_Helper_Mailmaga)"
    participant DB as "データベース"
    participant UnsubPage as "登録解除ページ"

    User->>MailSystem: 送信要求 / 配信開始
    MailSystem->>Token: generateUnsubscribeToken(customer_id, send_id, email)
    Token->>DB: INSERT dtb_mailmaga_unsubscribe_token
    DB-->>Token: 保存完了
    Token-->>MailSystem: トークン文字列
    MailSystem->>MailSystem: List-Unsubscribeヘッダー生成
    MailSystem->>User: メール送信 (ワンクリックURL)

    User->>UnsubPage: ワンクリックURLにアクセス (token)
    UnsubPage->>Token: validateToken(token)
    Token->>DB: SELECT トークン・有効性確認
    DB-->>Token: トークン情報/無効
    Token-->>UnsubPage: 検証結果
    alt 有効
        UnsubPage->>User: 確認表示 (or そのままPOSTでワンクリック)
        User->>UnsubPage: POST (List-Unsubscribe=One-Click) / 通常確認POST
        UnsubPage->>DB: BEGIN
        UnsubPage->>Token: markTokenAsUsed(token)
        Token->>DB: UPDATE used_flg, used_date
        UnsubPage->>DB: UPDATE dtb_customer.mailmaga_flg=3
        DB-->>UnsubPage: 更新完了
        UnsubPage->>DB: COMMIT
        UnsubPage-->>User: 成功表示 / 200 OK (ワンクリック)
    else 無効
        UnsubPage-->>User: エラーメッセージ表示
    end
Loading

Estimated code review effort

🎯 4 (複雑) | ⏱️ ~60分

Possibly related PRs

Suggested reviewers

  • nanasess

🐰 トークンは土から出て 列に並び
メールは飛び立ち ワンクリックで解放
データは更新され 期限は刻々
兎が運ぶ RFC の小さな鍵 🎀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed タイトルは RFC 8058 対応のワンクリックメルマガ登録解除機能という実装の主要な目的を明確に示しており、変更セットの主要な内容を適切に要約しています。
Linked Issues check ✅ Passed PR は Issue #819 の全要件を実装しています:RFC 8058 準拠(List-Unsubscribe ヘッダーと List-Unsubscribe-Post)、ワンクリック登録解除、トークン管理、登録解除ページ、メッセージ本文へのリンク表示、セキュリティ対策等が完備されています。
Out of Scope Changes check ✅ Passed 全ての変更は Issue #819 のワンクリック登録解除機能実装に直接関連しており、スコープ外の変更は確認されません。DB テーブル追加、ヘルパークラス、カスタムヘッダー機能、登録解除ページ、テストはすべて要件を満たすための必須変更です。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-819-mailmaga-one-click-unsubscribe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@data/class/helper/SC_Helper_Mailmaga.php`:
- Around line 162-170: Implement a scheduled batch that calls
SC_Helper_Mailmaga::cleanupExpiredTokens() regularly: add a small CLI/cron entry
(e.g., a new script or console command) that boots the app context, invokes
SC_Helper_Mailmaga::cleanupExpiredTokens(), logs results and exits with proper
status, and register it in the system crontab or the app's scheduler so it runs
periodically; ensure the batch is idempotent and uses a simple lock (file/DB) to
avoid concurrent executions and include contextual logging referencing the
dtb_mailmaga_unsubscribe_token cleanup for observability.

In `@tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php`:
- Around line 32-40: The test testGetUnsubscribeUrl is asserting the URL always
starts with 'https://' which fails when the test env's HTTPS_URL uses 'http://';
update the assertion to be environment-agnostic by checking the URL starts with
the configured base (use the same scheme as the HTTPS_URL constant) or assert it
starts with either 'http://' or 'https://', and keep the existing assertions for
'mailmaga/unsubscribe/index.php' and 'token=test-token-123'; modify the
testGetUnsubscribeUrl in SC_Helper_MailmagaTest to fetch the scheme/base from
HTTPS_URL and assert the generated URL begins with that value or one of the two
allowed schemes while still verifying path and token.
🧹 Nitpick comments (4)
data/class/SC_SendMail.php (1)

165-171: 保護ヘッダーのチェックは大文字小文字を区別している

in_array() による保護ヘッダーのチェックは大文字小文字を区別しますが、HTTPヘッダーは本来大文字小文字を区別しません。例えば、fromFROM で保護を回避できる可能性があります。

♻️ 大文字小文字を区別しない比較への修正案
     // 重要なヘッダーの上書きを防止
     $protectedHeaders = ['From', 'To', 'Subject', 'Cc', 'Bcc', 'Reply-To', 'Return-Path', 'Date', 'MIME-Version', 'Content-Type', 'Content-Transfer-Encoding'];
-    if (in_array($name, $protectedHeaders)) {
+    $protectedHeadersLower = array_map('strtolower', $protectedHeaders);
+    if (in_array(strtolower($name), $protectedHeadersLower, true)) {
         trigger_error('保護されたヘッダーは上書きできません: '.$name, E_USER_WARNING);

         return;
     }
html/install/sql/create_table_pgsql.sql (1)

274-276: インデックス名の衝突リスク

PostgreSQL ではインデックス名がスキーマスコープです。idx_customer_ididx_send_ididx_expire_date という汎用的な名前は、他のテーブルに同名のインデックスが追加された場合に衝突する可能性があります。

♻️ テーブル名をプレフィックスとして追加する案
-CREATE INDEX idx_customer_id ON dtb_mailmaga_unsubscribe_token(customer_id);
-CREATE INDEX idx_send_id ON dtb_mailmaga_unsubscribe_token(send_id);
-CREATE INDEX idx_expire_date ON dtb_mailmaga_unsubscribe_token(expire_date);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_customer_id_key ON dtb_mailmaga_unsubscribe_token(customer_id);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_send_id_key ON dtb_mailmaga_unsubscribe_token(send_id);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_expire_date_key ON dtb_mailmaga_unsubscribe_token(expire_date);

MySQL 版(Lines 273-275)も同様のパターンで命名することを推奨します。

data/class/helper/SC_Helper_Mail.php (1)

534-542: 送信失敗時のトークン生成について

トークンはメール送信前に生成されるため、送信に失敗した場合でもトークンがデータベースに残ります。これらの孤立トークンは有効期限(90日)後に自動クリーンアップされますが、送信失敗時のトークン生成を避けることで、データベースのノイズを減らせます。

ただし、現在の実装でも機能的には問題なく、パフォーマンスへの影響も軽微です。

♻️ 送信成功後にトークン生成する案(オプション)

トークン生成をメール送信成功後に移動する場合、ヘッダーを先に追加できないため、RFC 8058 準拠のためには現在の実装が適切です。この指摘は情報提供のみです。

data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php (1)

151-158: 例外メッセージのユーザー表示について確認してください。

例外メッセージをそのままユーザーに表示していますが、内部エラーの詳細がユーザーに漏洩する可能性があります。

♻️ 修正案: ユーザー向けメッセージを一般化
         } catch (Exception $e) {
             $objQuery->rollback();

             $this->tpl_success = false;
-            $this->tpl_message = 'エラーが発生しました: '.$e->getMessage();
+            $this->tpl_message = 'エラーが発生しました。しばらく時間をおいて再度お試しください。';

             GC_Utils_Ex::gfPrintLog($e->getMessage());
         }

nobuhiko and others added 3 commits January 30, 2026 08:30
EC-CUBE naming convention: PRIMARY KEY should be {table_name}_id
- token_id → mailmaga_unsubscribe_token_id
- Add dtb_mailmaga_unsubscribe_token to SQLite3 schema
- Update sequence name in eccube_install.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Document EC-CUBE 2 conventions for:
- PRIMARY KEY naming (table_name_id)
- Sequence naming and registration
- Multi-DBMS support (MySQL, PostgreSQL, SQLite3)
- _Ex class auto-aliasing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @.claude/skills/ec-cube-primary-key.md:
- Around line 13-23: Add a blank line before the Markdown table and normalize
the pipe spacing and separator row so markdownlint is happy: ensure the header
row uses single spaces around pipes ("| テーブル名 | PRIMARY KEY |"), the separator
row uses only dashes and pipes ("|---|---|") with no extra spaces, and every
data row matches the same pipe/space pattern (e.g., "| dtb_customer |
customer_id |"). Update the rows shown in the diff (the header line, the
separator line, and all "| dtb_* | ... |" lines) to follow this consistent
formatting.

In `@data/class/helper/SC_Helper_Mailmaga.php`:
- Around line 92-97: The SQL uses CURRENT_TIMESTAMP causing timezone
inconsistencies; update validateToken and cleanupExpiredTokens to compute the
current timestamp in PHP (e.g., $now = date('Y-m-d H:i:s')) and replace any SQL
comparisons that use CURRENT_TIMESTAMP with a parameterized comparison (e.g.,
"expire_date > ?" or "expire_date <= ?") passing $now as the bound parameter
when querying the dtb_mailmaga_unsubscribe_token table; ensure used_flag checks
remain (used_flag = 0) and the same $now logic is applied in both validateToken
and cleanupExpiredTokens to keep comparisons consistent across DB engines.

Copy link
Copy Markdown
Contributor

@nanasess nanasess left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nobuhiko コメント確認お願いいたします🙇‍♂️

public function action()
{
// トークンの取得
$token = $_REQUEST['token'] ?? '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$_GET でお願いします

send_id int NOT NULL,
token varchar(64) NOT NULL,
email varchar(255) NOT NULL,
used_flag smallint NOT NULL DEFAULT 0,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

フラグは _flg で統一いるため、修正をお願いします

- $_REQUEST → $_GET に変更 (nanasess)
- used_flag → used_flg に命名規則統一 (nanasess)
- $sendResut typo → $sendResult に修正
- CURRENT_TIMESTAMP → date('Y-m-d H:i:s') でタイムゾーン統一
- markTokenAsUsed の戻り値チェックを追加
- replay_to typo → reply_to に修正
- form action の REQUEST_URI を削除
- テストの assertStringStartsWith を HTTPS_URL 定数に変更
- POST confirm 後に return 追加
- トークン形式の簡易バリデーション追加 (64桁HEX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
html/install/sql/create_table_pgsql.sql (1)

274-276: インデックス名を既存の命名規則に合わせてください。

現在のインデックス名(idx_customer_ididx_send_ididx_expire_date)は、ファイル内の他のすべてのインデックスで使用されている dtb_<テーブル名>_<カラム名>_key パターン(例: dtb_order_customer_id_keydtb_send_customer_customer_id_key)と異なります。

♻️ 推奨される修正
-CREATE INDEX idx_customer_id ON dtb_mailmaga_unsubscribe_token(customer_id);
-CREATE INDEX idx_send_id ON dtb_mailmaga_unsubscribe_token(send_id);
-CREATE INDEX idx_expire_date ON dtb_mailmaga_unsubscribe_token(expire_date);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_customer_id_key ON dtb_mailmaga_unsubscribe_token(customer_id);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_send_id_key ON dtb_mailmaga_unsubscribe_token(send_id);
+CREATE INDEX dtb_mailmaga_unsubscribe_token_expire_date_key ON dtb_mailmaga_unsubscribe_token(expire_date);

MySQL側の create_table_mysqli.sql でも同様に KEY の名前を dtb_mailmaga_unsubscribe_token_customer_id_key などの形式に統一することを検討してください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@html/install/sql/create_table_pgsql.sql` around lines 274 - 276, The index
names for dtb_mailmaga_unsubscribe_token (currently idx_customer_id,
idx_send_id, idx_expire_date) do not follow the repository naming convention;
rename them to the pattern dtb_<table>_<column>_key (e.g.,
dtb_mailmaga_unsubscribe_token_customer_id_key,
dtb_mailmaga_unsubscribe_token_send_id_key,
dtb_mailmaga_unsubscribe_token_expire_date_key) in the CREATE INDEX statements
and apply the same naming change for the corresponding KEY entries in
create_table_mysqli.sql to keep MySQL and PostgreSQL consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data/class/SC_SendMail.php`:
- Around line 166-174: The header protection check in SC_SendMail (the block
comparing $name to $protectedHeaders) is case-sensitive and can be bypassed by
"from"/"subject" lowercase variants; normalize header names before comparison by
lowercasing (or uppercasing) both $name and the entries in $protectedHeaders (or
store $protectedHeaders as lowercased values) and then perform the in_array
check so the protected-header guard is case-insensitive; update the assignment
to $this->customHeaders[$name] accordingly (you may want to preserve the
original header casing when storing but use the normalized form only for the
membership test).

---

Nitpick comments:
In `@html/install/sql/create_table_pgsql.sql`:
- Around line 274-276: The index names for dtb_mailmaga_unsubscribe_token
(currently idx_customer_id, idx_send_id, idx_expire_date) do not follow the
repository naming convention; rename them to the pattern
dtb_<table>_<column>_key (e.g., dtb_mailmaga_unsubscribe_token_customer_id_key,
dtb_mailmaga_unsubscribe_token_send_id_key,
dtb_mailmaga_unsubscribe_token_expire_date_key) in the CREATE INDEX statements
and apply the same naming change for the corresponding KEY entries in
create_table_mysqli.sql to keep MySQL and PostgreSQL consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21d7f958-85bc-46fe-8676-c5d6f73dbbf6

📥 Commits

Reviewing files that changed from the base of the PR and between b16549a and ca3f2fa.

📒 Files selected for processing (9)
  • data/Smarty/templates/default/mailmaga/unsubscribe.tpl
  • data/class/SC_SendMail.php
  • data/class/helper/SC_Helper_Mail.php
  • data/class/helper/SC_Helper_Mailmaga.php
  • data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php
  • html/install/sql/create_table_mysqli.sql
  • html/install/sql/create_table_pgsql.sql
  • html/install/sql/create_table_sqlite3.sql
  • tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php
✅ Files skipped from review due to trivial changes (1)
  • data/Smarty/templates/default/mailmaga/unsubscribe.tpl
🚧 Files skipped from review as they are similar to previous changes (5)
  • html/install/sql/create_table_sqlite3.sql
  • data/class/helper/SC_Helper_Mailmaga.php
  • data/class/pages/mailmaga/LC_Page_Mailmaga_Unsubscribe.php
  • tests/class/helper/SC_Helper_Mailmaga/SC_Helper_MailmagaTest.php
  • html/install/sql/create_table_mysqli.sql

- 保護ヘッダー判定をケース非依存に修正 (SC_SendMail.php)
- インデックス名を dtb_<table>_<column>_key 形式に統一 (MySQL/PostgreSQL/SQLite3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
data/class/SC_SendMail.php (1)

324-327: カスタムヘッダーのクリアを sendMail() 側で自動化するとより安全です。

Line 324〜327 は保持状態をそのままマージするため、呼び出し側の clearCustomHeaders() 呼び忘れ時に次メールへ混入し得ます。送信直後の自動クリアを検討してください。

改善案(送信後に自動クリア)
 public function sendMail($isHtml = false)
 {
     $header = $isHtml ? $this->getHTMLHeader() : $this->getTEXTHeader();
     $recip = $this->getRecip();
     // メール送信
     $result = $this->objMail->send($recip, $header, $this->body);
+    $this->clearCustomHeaders();
     if (PEAR::isError($result)) {
         // XXX Windows 環境では SJIS でメッセージを受け取るようなので変換する。
         $msg = mb_convert_encoding($result->getMessage(), CHAR_CODE, 'auto');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/class/SC_SendMail.php` around lines 324 - 327, The loop merging
$this->customHeaders into $arrHeader can leak headers if callers forget
clearCustomHeaders(); update the sendMail() flow in class SC_SendMail to
automatically call $this->clearCustomHeaders() (or reset $this->customHeaders =
[]) immediately after the merge/send completes (successfully or in
finally/fallback) so each send starts with an empty customHeaders state;
reference the merge loop where $arrHeader is populated from $this->customHeaders
and the existing clearCustomHeaders()/sendMail() methods to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data/class/SC_SendMail.php`:
- Around line 150-174: The addCustomHeader method currently only checks for
CR/LF injection but allows empty or RFC-noncompliant header names; update
addCustomHeader to trim $name, ensure it's not empty, and validate it against
the RFC token characters (e.g. use a regex like
/^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/) before the protected header check, calling
trigger_error and returning on failure; keep the existing CR/LF and protected
header checks and then set $this->customHeaders[$name] = $value when validation
passes.

---

Nitpick comments:
In `@data/class/SC_SendMail.php`:
- Around line 324-327: The loop merging $this->customHeaders into $arrHeader can
leak headers if callers forget clearCustomHeaders(); update the sendMail() flow
in class SC_SendMail to automatically call $this->clearCustomHeaders() (or reset
$this->customHeaders = []) immediately after the merge/send completes
(successfully or in finally/fallback) so each send starts with an empty
customHeaders state; reference the merge loop where $arrHeader is populated from
$this->customHeaders and the existing clearCustomHeaders()/sendMail() methods to
locate the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cc9f7add-70d6-4fb0-8ba5-c5ea136daf18

📥 Commits

Reviewing files that changed from the base of the PR and between ca3f2fa and 40cba44.

📒 Files selected for processing (4)
  • data/class/SC_SendMail.php
  • html/install/sql/create_table_mysqli.sql
  • html/install/sql/create_table_pgsql.sql
  • html/install/sql/create_table_sqlite3.sql
✅ Files skipped from review due to trivial changes (1)
  • html/install/sql/create_table_mysqli.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • html/install/sql/create_table_pgsql.sql
  • html/install/sql/create_table_sqlite3.sql

Comment on lines +150 to +174
/**
* カスタムヘッダーを追加
*
* @param string $name ヘッダー名
* @param string $value ヘッダー値
*/
public function addCustomHeader($name, $value)
{
// ヘッダーインジェクション対策
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ヘッダー名の形式検証が不足しています。

Line 159 の改行チェックだけだと、空文字や RFC 非準拠のヘッダー名を受け入れてしまい、不正ヘッダー生成や送信失敗につながる可能性があります。$name の形式を追加検証してください。

修正案(ヘッダー名/値の妥当性チェック追加)
 public function addCustomHeader($name, $value)
 {
+    if (!is_string($name) || $name === '' || !preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) {
+        trigger_error('ヘッダー名の形式が不正です。', E_USER_WARNING);
+
+        return;
+    }
+    if (!is_scalar($value)) {
+        trigger_error('ヘッダー値の形式が不正です。', E_USER_WARNING);
+
+        return;
+    }
+    $value = (string) $value;
+
     // ヘッダーインジェクション対策
     if (preg_match('/[\r\n]/', $name) || preg_match('/[\r\n]/', $value)) {
         trigger_error('ヘッダーに改行文字は使用できません。', E_USER_WARNING);

         return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/class/SC_SendMail.php` around lines 150 - 174, The addCustomHeader
method currently only checks for CR/LF injection but allows empty or
RFC-noncompliant header names; update addCustomHeader to trim $name, ensure it's
not empty, and validate it against the RFC token characters (e.g. use a regex
like /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/) before the protected header check,
calling trigger_error and returning on failure; keep the existing CR/LF and
protected header checks and then set $this->customHeaders[$name] = $value when
validation passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ワンクリックでのメルマガ登録解除機能

3 participants