公開日:2023/3/9 最終更新日:2023/03/09

【PHP】PHPMailer でメールが二重送信される

問い合わせがあった際、管理者と利用者に別々でメールを送信する機能を実装した。
本来であれば管理者と利用者にメールが一件ずつ送信されるが、管理者にのみ送信されるはずのメールが利用者にも送信されてしまった。今回はその解決方法についてのメモ書き。

結論としては、

  • $this->toに前者のアドレスが残っていたこと
  • 単に自分の勉強・理解度不足

が原因だった。

以下のコードは問題があった部分(一部省略)。他の実装方法もあるが、今回はこれでいく。

<?php

# ...省略

class CustomMailer extends PHPMailer {
    # ...省略
    
    public function sendEmail($address, $subject, $body){
        # ...省略        
        parent::addAddress($address);

        $this->Subject = $subject;
        $this->Body = $body;

        parent::send();
    }
}
# ...省略
$mailer = new CustomMailer;
$mailer->sendEmail('admin@example.com', '件名1', '本文1');
$mailer->sendEmail('user@example.com', '件名2', '本文2');

最初に目をつけたのは、mailer.phpの$mailer->sendEmail()
まずはparent::addAddress()の中身を確認することにした。

    /**
     * Add a "To" address.
     *
     * @param string $address The email address to send to
     * @param string $name
     *
     * @throws Exception
     *
     * @return bool true on success, false if address already used or invalid in some way
     */
    public function addAddress($address, $name = '')
    {
        return $this->addOrEnqueueAnAddress('to', $address, $name);
    }

$this->addOrEnqueueAnAddress()を呼び出しているようだ。中身を確認する。
記事を書いている最中に知ったが、関数に含まれているEnqueueはキューの末尾に要素を追加するという意味を持つらしい。「末尾に追加する」とある時点で気づくべきだった。

    /**
     * Add an address to one of the recipient arrays or to the ReplyTo array. Because PHPMailer
     * can't validate addresses with an IDN without knowing the PHPMailer::$CharSet (that can still
     * be modified after calling this function), addition of such addresses is delayed until send().
     * Addresses that have been added already return false, but do not throw exceptions.
     *
     * @param string $kind    One of 'to', 'cc', 'bcc', or 'ReplyTo'
     * @param string $address The email address
     * @param string $name    An optional username associated with the address
     *
     * @throws Exception
     *
     * @return bool true on success, false if address already used or invalid in some way
     */
    protected function addOrEnqueueAnAddress($kind, $address, $name)
    {
        $pos = false;
        if ($address !== null) {
            $address = trim($address);
            $pos = strrpos($address, '@');
        }
        if (false === $pos) {
            //At-sign is missing.
            $error_message = sprintf(
                '%s (%s): %s',
                $this->lang('invalid_address'),
                $kind,
                $address
            );
            $this->setError($error_message);
            $this->edebug($error_message);
            if ($this->exceptions) {
                throw new Exception($error_message);
            }

            return false;
        }
        if ($name !== null && is_string($name)) {
            $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim
        } else {
            $name = '';
        }
        $params = [$kind, $address, $name];
        //Enqueue addresses with IDN until we know the PHPMailer::$CharSet.
        //Domain is assumed to be whatever is after the last @ symbol in the address
        if (static::idnSupported() && $this->has8bitChars(substr($address, ++$pos))) {
            if ('Reply-To' !== $kind) {
                if (!array_key_exists($address, $this->RecipientsQueue)) {
                    $this->RecipientsQueue[$address] = $params;

                    return true;
                }
            } elseif (!array_key_exists($address, $this->ReplyToQueue)) {
                $this->ReplyToQueue[$address] = $params;

                return true;
            }

            return false;
        }

        //Immediately add standard addresses without IDN.
        return call_user_func_array([$this, 'addAnAddress'], $params);
    }

例外が発生しておらず、対象のアドレスがIDNの場合は$this->RecipientsQueue$this->ReplyToQueueに情報(種類、アドレス、名前)が追加され、IDNでない場合はaddAnAddress()関数を呼び出すようになっている。

    /**
     * Add an address to one of the recipient arrays or to the ReplyTo array.
     * Addresses that have been added already return false, but do not throw exceptions.
     *
     * @param string $kind    One of 'to', 'cc', 'bcc', or 'ReplyTo'
     * @param string $address The email address to send, resp. to reply to
     * @param string $name
     *
     * @throws Exception
     *
     * @return bool true on success, false if address already used or invalid in some way
     */
    protected function addAnAddress($kind, $address, $name = '')
    {
        if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) {
            $error_message = sprintf(
                '%s: %s',
                $this->lang('Invalid recipient kind'),
                $kind
            );
            $this->setError($error_message);
            $this->edebug($error_message);
            if ($this->exceptions) {
                throw new Exception($error_message);
            }

            return false;
        }
        if (!static::validateAddress($address)) {
            $error_message = sprintf(
                '%s (%s): %s',
                $this->lang('invalid_address'),
                $kind,
                $address
            );
            $this->setError($error_message);
            $this->edebug($error_message);
            if ($this->exceptions) {
                throw new Exception($error_message);
            }

            return false;
        }
        if ('Reply-To' !== $kind) {
            if (!array_key_exists(strtolower($address), $this->all_recipients)) {
                $this->{$kind}[] = [$address, $name];
                $this->all_recipients[strtolower($address)] = true;

                return true;
            }
        } elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) {
            $this->ReplyTo[strtolower($address)] = [$address, $name];

            return true;
        }

        return false;
    }

addAnAddress()内で例外がない場合、対象の情報は$this->{$kind}[] = [$address, $name];の部分で相応しいプロパティに追加される。送信先の場合はこの部分で$this->to[]に情報が入れられるようだ。

やっと両者にメールが二重で送信される理由が分かった。

$this->toはてっきり一つの情報しか保持しないと思っていたが、配列で複数のアドレス情報を管理する。そのためparent::addAddress()を実行するたびに$this->toに情報が溜まってしまい結果、parent::send()を実行する度$this->toが保持しているアドレス全てにメールを送信してしまっているのだ(多分)。

○解決方法

PHPMailer内には送信メールの受信情報?をすべて削除する関数が存在するのでそちらを使ってやれば良い。

    /**
     * Clear all recipient types.
     */
    public function clearAllRecipients()
    {
        $this->to = [];
        $this->cc = [];
        $this->bcc = [];
        $this->all_recipients = [];
        $this->RecipientsQueue = [];
    }

ccやbccを削除したくない場合は下の関数を利用する。

    /**
     * Clear all To recipients.
     */
    public function clearAddresses()
    {
        foreach ($this->to as $to) {
            unset($this->all_recipients[strtolower($to[0])]);
        }
        $this->to = [];
        $this->clearQueuedAddresses('to');
    }

この関数を組み込んだことで今回の問題が解決した。

<?php

# ...省略

class CustomMailer extends PHPMailer {
    # ...省略
    
    public function sendEmail($address, $subject, $body){
        # ...省略        
        parent::addAddress($address);

        $this->Subject = $subject;
        $this->Body = $body;

        parent::send();

        parent::clearAllRecipients();
    }
}

間違えている部分がある場合はご指摘いただけると幸いです。

振り返り

  • ライブラリの拡張(継承)を行う場合は、まず親のコードをすべて読んでから作業を行え。
  • 多分クラスの使い方を間違えている、単に自分の勉強不足。