Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions data/Smarty/templates/default/mailmaga/unsubscribe.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!--{*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*}-->

<div id="undercolumn">
<h2 class="title"><!--{$tpl_title|h}--></h2>
<div id="undercolumn_mailmaga_unsubscribe">
<div id="complete_area">
<!--{if $tpl_success}-->
<p class="message success"><!--{$tpl_message|h}--></p>
<p>今後、メールマガジンは配信されません。</p>
<div class="btn_area">
<ul>
<li>
<a href="<!--{$smarty.const.TOP_URL}-->"><img class="hover_change_image" src="<!--{$TPL_URLPATH}-->img/button/btn_toppage.jpg" alt="トップページへ" /></a>
</li>
</ul>
</div>
<!--{elseif $tpl_message}-->
<p class="message error"><!--{$tpl_message|h}--></p>
<div class="btn_area">
<ul>
<li>
<a href="<!--{$smarty.const.TOP_URL}-->"><img class="hover_change_image" src="<!--{$TPL_URLPATH}-->img/button/btn_toppage.jpg" alt="トップページへ" /></a>
</li>
</ul>
</div>
<!--{else}-->
<p class="message">メールアドレス: <strong><!--{$tpl_email|h}--></strong></p>
<p>メールマガジンの登録を解除しますか?</p>
<form method="post">
<input type="hidden" name="mode" value="confirm" />
<div class="btn_area">
<ul>
<li>
<button type="submit" class="btn">登録を解除する</button>
</li>
</ul>
</div>
</form>
<!--{/if}-->
</div>
</div>
</div>
44 changes: 43 additions & 1 deletion data/class/SC_SendMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class SC_SendMail
public $from;
/** @var string */
public $reply_to;
/** @var array */
protected $customHeaders;

/**
* コンストラクタ
Expand All @@ -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;
Expand Down Expand Up @@ -144,6 +147,40 @@ public function setReturnPath($return_path)
$this->return_path = $return_path;
}

/**
* カスタムヘッダーを追加
*
* @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($name, $protectedHeaders)) {
trigger_error('保護されたヘッダーは上書きできません: '.$name, E_USER_WARNING);

return;
}

$this->customHeaders[$name] = $value;
}

/**
* カスタムヘッダーをクリア
*/
public function clearCustomHeaders()
{
$this->customHeaders = [];
}

// 件名の設定
public function setSubject($subject)
{
Expand Down Expand Up @@ -284,6 +321,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;
}

Expand Down
27 changes: 22 additions & 5 deletions data/class/helper/SC_Helper_Mail.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']
);
Comment on lines +534 to +539
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.

Token generation happens inside the mail sending loop without transaction protection. If the mail sending process fails midway and is retried, duplicate tokens could be generated for the same customer_id and send_id combination. Consider either wrapping token generation and mail sending in a transaction, or checking for existing valid tokens before generating new ones to avoid token proliferation.

Copilot uses AI. Check for mistakes.

// ワンクリック登録解除URLの生成
$unsubscribeUrl = SC_Helper_Mailmaga_Ex::getUnsubscribeUrl($token);

$objMail->setItem(
$arrDestination['email'],
$subjectBody,
Expand All @@ -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 増やす
Expand Down Expand Up @@ -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;
Expand Down
173 changes: 173 additions & 0 deletions data/class/helper/SC_Helper_Mailmaga.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php
/*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*/

/**
* メールマガジン関連のヘルパークラス.
*
* RFC 8058 対応のワンクリック登録解除機能を含む
*
* @author EC-CUBE CO.,LTD.
*
* @version $Id$
*/
class SC_Helper_Mailmaga
{
/** トークンの有効期限(日数) */
public const TOKEN_EXPIRE_DAYS = 90;

/** トークンの長さ */
public const TOKEN_LENGTH = 64;

/**
* ワンクリック登録解除用トークンを生成
*
* @param int $customer_id 会員ID
* @param int $send_id 配信ID
* @param string $email メールアドレス
*
* @return string トークン文字列
*/
public static function generateUnsubscribeToken($customer_id, $send_id, $email)
{
// セキュアなトークン生成
$token = SC_Utils_Ex::sfGetRandomString(self::TOKEN_LENGTH);
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 sfGetRandomString function generates only half the expected entropy. When requesting 64 characters, sfGetRandomString generates 64 bytes but then converts them to 128 hex characters and truncates to 64 characters. This means the actual randomness is only 32 bytes (256 bits), not 64 bytes (512 bits) as might be expected. While 256 bits is still cryptographically secure, the TOKEN_LENGTH constant name is misleading. Consider either renaming the constant to TOKEN_BYTES or adjusting the implementation to achieve the intended token length.

Copilot uses AI. Check for mistakes.

// データベースに保存
$objQuery = SC_Query_Ex::getSingletonInstance();

$sqlval = [];
$sqlval['customer_id'] = $customer_id;
$sqlval['send_id'] = $send_id;
$sqlval['token'] = $token;
$sqlval['email'] = $email;
$sqlval['used_flg'] = 0;
$sqlval['expire_date'] = date('Y-m-d H:i:s', strtotime('+'.self::TOKEN_EXPIRE_DAYS.' days'));
$sqlval['create_date'] = date('Y-m-d H:i:s');
$sqlval['mailmaga_unsubscribe_token_id'] = $objQuery->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;
}
}
Loading
Loading