<?php
/**
 * Encoder.
 * @package magic.core
 * @subpackage tool.mail
 */
/**
 * 日本語メールのエンコードを提供します.
 * <p>
 * 通常、日本語メールを送信する場合は、このクラスのコンストラクタに、
 * {@link Mailer}を実装したクラスを渡して、このクラスから送信します。
 * </p>
 * @package magic.core
 * @subpackage tool.mail
 * @author T.Okumura
 * @version 1.0.0
 * @final
 * @see Mailer
 */
final class Encoder {
    /**
     * メールの文字コードを保持します.
     * @var string
     */
    private $_charset = 'iso-2022-jp';
    /**
     * このクラスに渡される文字列の内部エンコードを保持します.
     * @var string
     */
    private $_internalEncoding = 'utf-8';
    /**
     * ヘッダのエンコードを保持します.
     * @var string
     */
    private $_headerEncoding = 'base64';
    /**
     * ボディのエンコードを保持します.
     * @var string
     */
    private $_bodyEncoding = '7bit';
    /**
     * 改行コードを保持します.
     * @var string
     */
    private $_eol = "\r\n";
    /**
     * Mailerの実装クラスを保持します.
     * @var Mailer
     */
    private $_mailer = NULL;
    /**
     * FROMアドレスを保持します.
     * @var array
     */
    private $_from = array();
    /**
     * TOアドレスを保持します.
     * @var array
     */
    private $_to = array();
    /**
     * メールのSubjectを保持します.
     * @var string
     */
    private $_subject = NULL;
    /**
     * メールの本文を保持します.
     * @var string
     */
    private $_body = NULL;
    /**
     * HTMLメール送信時のテキスト本文を保持します.
     * @var string
     */
    private $_altBody = NULL;
    /**
     * メールのコンテントタイプを保持します.
     * @var string
     */
    private $_contentType = NULL;
    /**
     * メールのタイプを保持します.
     * @var string
     */
    private $_messageType = NULL;
    /**
     * 添付ファイルを保持します.
     * @var array
     */
    private $_attachment = array();
    /**
     * インライン画像があるかどうかを保持します.
     * @var bool
     */
    private $_isInlineImage = FALSE;
    /**
     * バウンダリを保持します.
     * @var array
     */
    private $_boundary = array();
    /**
     * 直近のエラーを保持します.
     * @var string
     */
    private $_error = NULL;
    /**
     * コンストラクタ.
     * @param Mailer $mailer Mailerの実装クラス
     */
    public function __construct(Mailer $mailer) {
        $this->_mailer = $mailer;
    }
    /**
     * 直近のエラーを取得します.
     * @return string 直近のエラー
     */
    public function getError() {
        return $this->_error;
    }
    /**
     * 文字コードを設定します.
     * @param string $charset 文字コード
     */
    public function setCharset($charset) {
        $this->_charset = strtolower($charset);
    }
    /**
     * このクラスに渡される文字列の内部エンコードを設定します.
     * @param string $internalEncoding 内部エンコード
     */
    public function setInternalEncoding($internalEncoding) {
        $this->_internalEncoding = strtolower($internalEncoding);
    }
    /**
     * ヘッダのエンコードを設定します.
     * @param string $headerEncoding ヘッダのエンコード
     */
    public function setHeaderEncoding($headerEncoding) {
        $this->_headerEncoding = strtolower($headerEncoding);
    }
    /**
     * ボディのエンコードを設定します.
     * @param string $bodyEncoding ボディのエンコード
     */
    public function setBodyEncoding($bodyEncoding) {
        $this->_bodyEncoding = strtolower($bodyEncoding);
    }
    /**
     * FROMアドレスを設定します.
     * @param string $address メールアドレス
     * @param string $name [optional] 氏名(オプション)
     * @param string $nameEncoding [optional] 氏名の文字コード(オプション)
     */
    public function setFrom($address, $name = NULL, $nameEncoding = NULL) {
        $this->_from = $this->_encodeAddress($address, $name, $nameEncoding);
    }
    /**
     * TOアドレスを設定します.
     * @param string $address メールアドレス
     * @param string $name [optional] 氏名(オプション)
     * @param string $nameEncoding [optional] 氏名の文字コード(オプション)
     */
    public function setTo($address, $name = NULL, $nameEncoding = NULL) {
        $this->_to = $this->_encodeAddress($address, $name, $nameEncoding);
    }
    /**
     * メールのSubjectを設定します.
     * @param string $subject メールのSubject
     * @param string $subjectEncoding [optional] Subjectの文字コード(オプション)
     */
    public function setSubject($subject, $subjectEncoding = NULL) {
        if (!is_null($subjectEncoding) && strtolower($subjectEncoding) !== $this->_internalEncoding) {
            $subject = mb_convert_encoding($subject, $this->_internalEncoding, $subjectEncoding);
        }
        $this->_subject = mb_encode_mimeheader($subject, $this->_charset,
                ($this->_headerEncoding === 'base64' ? 'B' : 'Q'));
    }
    /**
     * テキストメールの本文を設定します.
     * @param string $body 本文
     * @param string $bodyEncoding [optional] 本文の文字コード(オプション)
     */
    public function setTextBody($body, $bodyEncoding = NULL) {
        $bodyEncoding = is_null($bodyEncoding) ? $this->_internalEncoding : $bodyEncoding;
        $this->_body = mb_convert_encoding($body, $this->_charset, $bodyEncoding);
        $this->_contentType = 'text/plain';
    }
    /**
     * HTMLメールの本文を設定します.
     * <p>
     * HTMLメールを送信する際、相手がHTMLメールを受け取れない場合もあるので、
     * 通常はテキストメールも併せて送信します。<br/>
     * ここで<var>$autoSet</var>をTRUEにした場合は、{@link Encoder::setAltBody()}で
     * 設定する必要はありませんが、単純にHTMLからタグを除去した程度の精度です。<br/>
     * もう少しマシなメールを送りたい場合は、{@link Encoder::setAltBody()}を使用してください。
     * </p>
     * @param string $body 本文
     * @param bool $autoSet [optional] テキストメールも自動で設定するかどうかのフラグ
     * @param string $bodyEncoding [optional] 本文の文字コード(オプション)
     */
    public function setHtmlBody($body, $autoSet = TRUE, $bodyEncoding = NULL) {
        $bodyEncoding = is_null($bodyEncoding) ? $this->_internalEncoding : $bodyEncoding;
        $this->_body = mb_convert_encoding($body, $this->_charset, $bodyEncoding);
        $this->_contentType = 'text/html';
        if ($autoSet) {
            $body = strip_tags(preg_replace('/<br[^>]*>/i', "\n", $body));
            $body = preg_replace('/^\s+$|\r|\n/m', "\n", $body);
            $body = preg_replace('/\n+/', "\r\n", $body);
            $this->setAltBody(trim(html_entity_decode($body, ENT_QUOTES, $bodyEncoding)), $bodyEncoding);
        }
    }
    /**
     * HTMLメールの場合に併せて送るテキストメール本文を設定します.
     * <p>
     * このファンクションで設定した後に、{@link Encoder::setHtmlBody()}の
     * <var>$autoSet</var>をTRUEにしてコールした場合、上書きされてしまいます。<br/>
     * (FALSEでコールした場合は、メールのコンテントタイプが間違えて指定されてしまいます)<br/>
     * 必ずこのファンクションを後からコールするようにしてください。
     * </p>
     * @param string $body 本文
     * @param string $bodyEncoding [optional] 本文の文字コード(オプション)
     */
    public function setAltBody($body, $bodyEncoding = NULL) {
        $bodyEncoding = is_null($bodyEncoding) ? $this->_internalEncoding : $bodyEncoding;
        $this->_altBody = mb_convert_encoding($body, $this->_charset, $bodyEncoding);
        $this->_contentType = 'multipart/alternative';
    }
    /**
     * 添付ファイルを設定します.
     * @param string $path 添付ファイルのパス
     * @param string $name 添付する際に使用するファイル名
     * @param string $nameEncoding [optional] ファイル名の文字コード(オプション)
     * @param string $fileEncoding [optional] ファイルのエンコード(オプション)
     * @param string $type [optional] ファイルタイプ(オプション)
     */
    public function addAttachment($path, $name, $nameEncoding = NULL, $fileEncoding = NULL, $type = NULL) {
        if (!is_null($nameEncoding) && $nameEncoding !== $this->_internalEncoding) {
            $name = mb_convert_encoding($name, $this->_internalEncoding, $nameEncoding);
        }
        $file = array();
        $file['path'] = $path;
        $file['name'] = mb_encode_mimeheader($name, $this->_charset, ($this->_headerEncoding === 'base64' ? 'B' : 'Q'));
        $file['encoding'] = is_null($fileEncoding) ? 'base64' : $fileEncoding;
        $file['type'] = is_null($type) ? 'application/octet-stream' : $type;
        $file['disposition'] = 'attach';
        $file['cid'] = 0;
        $this->_attachment[] = $file;
    }
    /**
     * HTMLファイルのインラインイメージを設定します.
     * @param string $path ファイルのパス
     * @param string $cid HTMLのimgタグに指定したcid
     * @param string $name インラインイメージの名前
     * @param string $nameEncoding [optional] インラインイメージ名の文字コード(オプション)
     * @param string $fileEncoding [optional] イメージのエンコード(オプション)
     * @param string $type [optional] イメージのファイルタイプ(オプション)
     */
    public function addInlineImage($path, $cid, $name, $nameEncoding = NULL, $fileEncoding = NULL, $type = NULL) {
        if (!is_null($nameEncoding) && $nameEncoding != $this->_internalEncoding) {
            $name = mb_convert_encoding($name, $this->_internalEncoding, $nameEncoding);
        }
        $file = array();
        $file['path'] = $path;
        $file['name'] = mb_encode_mimeheader($name, $this->_charset, ($this->_headerEncoding === 'base64' ? 'B' : 'Q'));
        $file['encoding'] = is_null($fileEncoding) ? 'base64' : $fileEncoding;
        $file['type'] = is_null($type) ? 'application/octet-stream' : $type;
        $file['disposition'] = 'inline';
        $file['cid'] = $cid;
        $this->_attachment[] = $file;
        $this->_isInlineImage = TRUE;
    }
    /**
     * 送信処理.
     * <p>
     * {@link Mailer}の実装クラスで送信します。
     * このファンクションが返す結果は、送信までの結果です。<br/>
     * これは先方に届いたかどうかではない事に注意してください。
     * </p>
     * @return bool 送信できた場合はTRUE
     */
    public function send() {
        if (empty($this->_from)) {
            $this->_error = 'FROMのメールアドレスは必須です。';
            return FALSE;
        }
        if (empty($this->_to)) {
            $this->_error = 'TOのメールアドレスは必須です。';
            return FALSE;
        }
        $this->_setMessageType();
        $header = $this->_createHeader();
        $body = $this->_createBody();
        $this->_mailer->setFrom($this->_from['address']);
        $this->_mailer->setTo($this->_to['address']);
        $res = $this->_mailer->send($header, $body);
        $this->_error = $this->_mailer->getError();
        return $res;
    }
    /**
     * アドレスをエンコードします.
     * @param string $address アドレス
     * @param string $name 氏名
     * @param string $nameEncoding 氏名の文字コード
     * @return array アドレスと氏名の配列
     */
    private function _encodeAddress($address, $name, $nameEncoding) {
        if (!is_null($name)) {
            if (!is_null($nameEncoding) && strtolower($nameEncoding) != $this->_internalEncoding) {
                $name = mb_convert_encoding(trim($name), $this->_internalEncoding, $nameEncoding);
            }
            $name = mb_encode_mimeheader($name, $this->_charset, ($this->_headerEncoding === 'base64' ? 'B' : 'Q'));
        }
        return array('address' => strtolower(trim($address)), 'name' => $name);
    }
    /**
     * アドレスをフォーマットします.
     * @param array $address アドレス
     * @return string フォーマットされたアドレス
     */
    private function _formatAddress(array $address) {
        if (is_null($address['name'])) {
            return $address['address'];
        }
        return $address['name'] . $this->_eol . ' <' . $address['address'] . '>';
    }
    /**
     * メールのメッセージタイプを設定します.
     */
    private function _setMessageType() {
        if (empty($this->_attachment)) {
            if (is_null($this->_altBody)) {
                $this->_messageType = 'plain';
            } else {
                $this->_messageType = 'alt';
            }
        } else {
            if (is_null($this->_altBody)) {
                $this->_messageType = 'attachments';
            } else {
                $this->_messageType = 'alt_attachments';
            }
        }
    }
    /**
     * ヘッダを作成します.
     * @return string ヘッダ
     */
    private function _createHeader() {
        $uniqueId = md5(uniqid('', TRUE));
        $this->_boundary[1] = 'b1_' . $uniqueId;
        $this->_boundary[2] = 'b2_' . $uniqueId;
        $result = 'Date: ' . $this->_rfcDate() . $this->_eol;
        $result .= 'Message-ID: <' . $uniqueId . '@' . ServerUtil::getHttpHost() . '>' . $this->_eol;
        $result .= 'X-Priority: 3' . $this->_eol;
        $result .= 'X-Mailer: MagicMailer 1.1.0' . $this->_eol;
        $result .= 'MIME-Version: 1.0' . $this->_eol;
        $result .= 'From: ' . $this->_formatAddress($this->_from) . $this->_eol;
        $result .= 'To: ' . $this->_formatAddress($this->_to) . $this->_eol;
        if (!is_null($this->_subject)) {
            $result .= 'Subject: ' . $this->_subject . $this->_eol;
        }
        $result .= $this->_getMailMime();
        return $result;
    }
    /**
     * ボディを作成します.
     * @return string ボディ
     */
    public function _createBody() {
        switch ($this->_messageType) {
            case 'alt':
                $body = $this->_getBoundary($this->_boundary[1], 'text/plain');
                $body .= $this->_encodeString($this->_altBody, $this->_bodyEncoding) . $this->_eol . $this->_eol;
                $body .= $this->_getBoundary($this->_boundary[1], 'text/html');
                $body .= $this->_encodeString($this->_body, $this->_bodyEncoding) . $this->_eol . $this->_eol;
                $body .= '--' . $this->_boundary[1] . '--' . $this->_eol;
                break;
            case 'plain':
                $body = $this->_encodeString($this->_body, $this->_bodyEncoding) . $this->_eol;
                break;
            case 'attachments':
                $body = $this->_getBoundary($this->_boundary[1], $this->_contentType);
                $body .= $this->_encodeString($this->_body, $this->_bodyEncoding);
                $body .= $this->_eol;
                $body .= $this->_attachAll();
                break;
            case 'alt_attachments':
                $body = '--' . $this->_boundary[1] . $this->_eol;
                $body .= 'Content-Type: multipart/alternative;' . $this->_eol;
                $body .= ' boundary="' . $this->_boundary[2] . '"' . $this->_eol . $this->_eol;
                $body .= $this->_getBoundary($this->_boundary[2], 'text/plain');
                $body .= $this->_encodeString($this->_altBody, $this->_bodyEncoding);
                $body .= $this->_eol . $this->_eol;
                $body .= $this->_getBoundary($this->_boundary[2], 'text/html');
                $body .= $this->_encodeString($this->_body, $this->_bodyEncoding);
                $body .= $this->_eol . $this->_eol;
                $body .= $this->_eol . '--' . $this->_boundary[2] . '--' . $this->_eol;
                $body .= $this->_attachAll();
                break;
        }
        return $body;
    }
    /**
     * RFC型の日付を取得します.
     * @return string RFC型の日付
     */
    private function _rfcDate() {
        $sign = (($tz = date('Z')) < 0) ? '-' : '+';
        $tz = abs($tz);
        $tz = (int) ($tz / 3600) * 100 + ($tz % 3600) / 60;
        return sprintf("%s %s%04d", date('D, j M Y H:i:s'), $sign, $tz);
    }
    /**
     * メールのマイムタイプを取得します.
     * @return string マイムタイプ
     */
    private function _getMailMime() {
        switch ($this->_messageType) {
            case 'plain':
                $result = 'Content-Transfer-Encoding: ' . $this->_bodyEncoding . $this->_eol;
                $result .= 'Content-Type: ' . $this->_contentType . '; charset="' . $this->_charset . '"' . $this->_eol;
                break;
            case 'attachments':
            case 'alt_attachments':
                if ($this->_isInlineImage) {
                    $result = 'Content-Type: multipart/related;' . $this->_eol;
                    $result .= ' type="text/html";' . $this->_eol;
                    $result .= ' boundary="' . $this->_boundary[1] . '"' . $this->_eol;
                } else {
                    $result = 'Content-Type: multipart/mixed;' . $this->_eol;
                    $result .= ' boundary="' . $this->_boundary[1] . '"' . $this->_eol;
                }
                break;
            case 'alt':
                $result = 'Content-Type: multipart/alternative;' . $this->_eol;
                $result .= ' boundary="' . $this->_boundary[1] . '"' . $this->_eol;
                break;
        }
        return $result . $this->_eol;
    }
    /**
     * バウンダリを取得します.
     * @param string $boundary カレントのバウンダリ
     * @param string $contentType コンテントタイプ
     * @return string バウンダリ
     */
    private function _getBoundary($boundary, $contentType) {
        $result = '--' . $boundary . $this->_eol;
        $result .= 'Content-Type: ' . $contentType . '; charset = "' . $this->_charset . '"' . $this->_eol;
        $result .= 'Content-Transfer-Encoding: ' . $this->_bodyEncoding . $this->_eol . $this->_eol;
        return $result;
    }
    /**
     * 文字列をエンコードします.
     * @param string $string 文字列
     * @param string $encoding エンコードタイプ
     * @return string エンコードされた文字列
     */
    private function _encodeString($string, $encoding) {
        switch (strtolower($encoding)) {
            case 'base64':
                $encoded = chunk_split(base64_encode($string), 76, $this->_eol);
                break;
            case 'quoted-printable':
                $encoded = $this->_encodeQp($string);
                break;
            case '7bit':
            case '8bit':
            default:
                $encoded = $string;
        }
        return $encoded;
    }
    /**
     * quoted-printableでエンコードします.
     * @param string $string エンコードする文字列
     * @return string エンコードされた文字列
     */
    private function _encodeQp($string) {
        $hex = array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F');
        $lines = preg_split('/(?:\r\n|\r|\n)/', $string);
        $result = '';
        while (list(, $line) = each($lines)) {
            $length = strlen($line);
            $newLine = '';
            for ($i = 0; $i < $length; $i++) {
                $c = substr($line, $i, 1);
                $dec = ord($c);
                if (($i === 0) && ($dec === 46)) {
                    $c = '=2E';
                }
                if ($dec === 32) {
                    if ($i === ($length - 1)) {
                        $c = '=20';
                    }
                } elseif (($dec === 61) || ($dec < 32) || ($dec > 126)) {
                    $h2 = floor($dec / 16);
                    $h1 = floor($dec % 16);
                    $c = '=' . $hex[$h2] . $hex[$h1];
                }
                if ((strlen($newLine) + strlen($c)) >= 76) {
                    $result .= $newLine . '=' . $this->_eol;
                    $newLine = '';
                    if ($dec === 46) {
                        $c = '=2E';
                    }
                }
                $newLine .= $c;
            }
            $result .= $newLine . $this->_eol;
        }
        return $result;
    }
    /**
     * 添付またはインラインで設定されたデータをアタッチします.
     * @return string アタッチ後の文字列
     */
    private function _attachAll() {
        $result = '';
        $cidUniq = array();
        $incl = array();
        foreach ($this->_attachment as $att) {
            if (in_array($att['path'], $incl)) {
                continue;
            }
            $incl[] = $att['path'];
            if ($att['disposition'] === 'inline' && isset($cidUniq[$att['cid']])) {
                continue;
            }
            $cidUniq[$att['cid']] = TRUE;
            $result .= '--' . $this->_boundary[1] . $this->_eol;
            $result .= 'Content-Type: ' . $att['type'] . ';' . $this->_eol;
            $result .= ' name="' . $att['name'] . '"' . $this->_eol;
            $result .= 'Content-Transfer-Encoding: ' . $att['encoding'] . $this->_eol;
            if ($att['disposition'] === 'inline') {
                $result .= 'Content-ID: <' . $att['cid'] . '>' . $this->_eol;
            }
            $result .= 'Content-Disposition: ' . $att['disposition'] . ';' . $this->_eol;
            $result .= ' filename="' . $att['name'] . '"' . $this->_eol . $this->_eol;
            $result .= $this->_encodeString(file_get_contents($att['path']), $att['encoding']) . $this->_eol;
        }
        return $result .= '--' . $this->_boundary[1] . '--' . $this->_eol;
    }
}
// EOF.