<?php
/**
 * SmtpMailer.
 * @package magic.core
 * @subpackage tool.mail.impl
 */
/**
 * インターフェイスをロードします。
 */
require_once dirname(dirname(__FILE__)) . DIRECTORY_SEPARATOR . 'Mailer.php';
/**
 * SMTPを利用したメール送信クラスです.
 * <p>
 * 外部のSMTPサーバーを指定して、メールを送信する機能を提供します。<br/>
 * 単体で使用することもできますが、日本語のメールは処理が複雑です。<br/>
 * 通常は{@link Encoder}のコンストラクタに渡してそちらから送信します。
 * </p>
 * @package magic.core
 * @subpackage tool.mail
 * @author T.Okumura
 * @version 1.0.0
 * @final
 * @see Encoder
 */
final class SmtpMailer implements Mailer {
    /**
     * 認証モード(DIGEST_MD5).
     * @staticvar string
     */
    const DIGEST_MD5 = 'DIGEST_MD5';
    /**
     * 認証モード(CRAM_MD5).
     * @staticvar string
     */
    const CRAM_MD5 = 'CRAM_MD5';
    /**
     * 認証モード(LOGIN).
     * @staticvar string
     */
    const LOGIN = 'LOGIN';
    /**
     * 認証モード(PLAIN).
     * @staticvar string
     */
    const PLAIN = 'PLAIN';
    /**
     * EHLOに使用するホスト名を保持します.
     * @var string
     */
    private $_heloHost = NULL;
    /**
     * 接続先のSMTPホスト名を保持します.
     *
     * @var string
     */
    private $_smtpHost = NULL;
    /**
     * 接続先のSMTPホストのポート番号を保持します.
     * @var int
     */
    private $_smtpPort = 25;
    /**
     * SMTPの接続ユーザー名を保持します.
     * @var string
     */
    private $_smtpUser = NULL;
    /**
     * SMTPの接続パスワードを保持します.
     * @var string
     */
    private $_smtpPass = NULL;
    /**
     * SMTP認証の種類を保持します.
     * @var string
     */
    private $_smtpAuth = NULL;
    /**
     * STARTTLSを使用するかどうかを保持します.
     * @var bool
     */
    private $_useStartTls = FALSE;
    /**
     * POP Before SMTPで利用するPOPのホスト名を保持します.
     * @var string
     */
    private $_popHost = NULL;
    /**
     * POP Before SMTPで利用するPOPのポート番号を保持します.
     * @var int
     */
    private $_popPort = 110;
    /**
     * POPの接続ユーザー名を保持します.
     * @var string
     */
    private $_popUser = NULL;
    /**
     * POPの接続パスワードを保持します.
     * @var string
     */
    private $_popPass = NULL;
    /**
     * POP Before SMTPを使用するかどうかを保持します.
     * @var bool
     */
    private $_usePopBefore = FALSE;
    /**
     * POPのタイムアウト値を保持します.
     * @var int
     */
    private $_popTimeout = 1800;
    /**
     * POPを永続セッションで接続するかどうかを保持します.
     * @var bool
     */
    private $_isPersist = FALSE;
    /**
     * FROMを保持します.
     * @var string
     */
    private $_from = NULL;
    /**
     * TOを保持します.
     * @var string
     */
    private $_to = NULL;
    /**
     * 最後にPOP接続した時間を保持します.
     * @var int
     */
    private $_lastPopRecognitionTime = 0;
    /**
     * 接続ソケットを保持します.
     * @var resource
     */
    private $_socket = NULL;
    /**
     * ネゴシエーション済みかどうかを保持します.
     * @var bool
     */
    private $_isNegotiate = FALSE;
    /**
     * 認証済みかどうかを保持します.
     * @var bool
     */
    private $_isRecognition = FALSE;
    /**
     * コマンドオプションを保持します.
     * @var array
     */
    private $_args = array();
    /**
     * 改行コードを保持します.
     * @var string
     */
    private $_eol = "\r\n";
    /**
     * 発生した直近のエラーを保持します.
     * @var string
     */
    private $_error = NULL;
    /**
     * コンストラクタ.
     */
    public function __construct() {
    }
    /**
     * 直近のエラーを取得します
     * @see Mailer::getError()
     */
    public function getError() {
        return $this->_error;
    }
    /**
     * EHLOに使用するホスト名を設定します.
     * @param string $heloHost EHLOに使用するホスト名
     */
    public function setHeloHost($heloHost) {
        $this->_heloHost = $heloHost;
    }
    /**
     * 永続的な接続を保持するかどうかを設定します.
     * <p>
     * 複数のメールを送信する場合は、TRUEを推奨します。
     * </p>
     * @param bool $isPersist TRUEの場合は永続接続
     */
    public function setPersist($isPersist) {
        $this->_isPersist = $isPersist;
    }
    /**
     * SMTP接続に必要な情報を設定します.
     * <p>
     * <var>$smtpAuth</var>が省略された場合は、暗号化しません。<br/>
     * <var>$useStartTls</var>が省略された場合は、使用しないになります。
     * </p>
     * @param string $smtpHost SMTPのホスト名
     * @param int $smtpPort SMTPのポート番号
     * @param string $smtpUser 接続ユーザー名
     * @param string $smtpPass 接続パスワード
     * @param string $smtpAuth [optional] SMTP認証方式(オプション)
     * @param bool $useStartTls [optional] STARTTLSを使用するかどうかのフラグ(オプション)
     */
    public function setSmtpParameters($smtpHost, $smtpPort, $smtpUser, $smtpPass, $smtpAuth = NULL,
            $useStartTls = FALSE) {
        $this->_smtpHost = $smtpHost;
        $this->_smtpPort = $smtpPort;
        $this->_smtpUser = $smtpUser;
        $this->_smtpPass = $smtpPass;
        $this->_smtpAuth = $smtpAuth;
        $this->_useStartTls = $useStartTls;
    }
    /**
     * POP接続に必要な情報を設定します.
     * @param string $popHost POPのホスト名
     * @param int $popPort POPのポート番号
     * @param string $popUser 接続ユーザー名
     * @param string $popPass 接続パスワード
     * @param int $popTimeout [optional] POPのタイムアウト値を秒で指定(オプション)
     */
    public function setPopParameters($popHost, $popPort, $popUser, $popPass, $popTimeout = 1800) {
        $this->_popHost = $popHost;
        $this->_popPort = $popPort;
        $this->_popUser = $popUser;
        $this->_popPass = $popPass;
        $this->_popTimeout = $popTimeout;
        $this->_usePopBefore = TRUE;
    }
    /**
     * FROMを設定します.
     * @see Mailer::setFrom()
     */
    public function setFrom($from) {
        $this->_from = $from;
    }
    /**
     * TOを設定します.
     * @see Mailer::setTo()
     */
    public function setTo($to) {
        $this->_to = $to;
    }
    /**
     * デストラクタ.
     */
    public function __destruct() {
        $this->_close();
    }
    /**
     * 送信処理.
     * @see Mailer::send()
     */
    public function send($header, $body) {
        if ($this->_usePopBefore) {
            if (!$this->_popConnect()) {
                return FALSE;
            }
        }
        if (!$this->_connect($this->_smtpHost, $this->_smtpPort, $this->_isPersist)) {
            return FALSE;
        }
        if (!$this->_isNegotiate) {
            if (!$this->_negotiate()) {
                return FALSE;
            }
        }
        if (!$this->_isRecognition) {
            if (!$this->_switchAuth()) {
                return FALSE;
            }
        }
        if (!$this->_mailFrom()) {
            return FALSE;
        }
        if (!$this->_rcptTo()) {
            return FALSE;
        }
        if (!$this->_data($header . $body)) {
            return FALSE;
        }
        if (!$this->_rset()) {
            return FALSE;
        }
        return TRUE;
    }
    /**
     * POP接続を実行します.
     * @return bool 接続できた場合はTRUE
     */
    private function _popConnect() {
        if ($this->_lastPopRecognitionTime + $this->_popTimeout > ($currTime = time())) {
            return TRUE;
        }
        if (!$this->_connect($this->_popHost, $this->_popPort, FALSE)) {
            return FALSE;
        }
        if (!$this->_sendCommand('USER', $this->_popUser)) {
            return FALSE;
        }
        if (!$this->_getResponse(array('+OK'))) {
            return FALSE;
        }
        if (!$this->_sendCommand('PASS', $this->_popPass)) {
            return FALSE;
        }
        if (!$this->_getResponse(array('+OK'))) {
            return FALSE;
        }
        $this->_close();
        $this->_lastPopRecognitionTime = $currTime;
        return TRUE;
    }
    /**
     * 相手ホストに接続します.
     * @param string $host POPまたはSMTPのホスト名
     * @param int $port POPまたはSMTPのポート番号
     * @param bool $isPersist 永続接続するかどうかのフラグ
     * @return bool 接続できた場合はTRUE
     */
    private function _connect($host, $port, $isPersist) {
        if (is_resource($this->_socket)) {
            $this->_close();
        }
        $sockOpen = $isPersist ? 'pfsockopen' : 'fsockopen';
        if ($this->_socket = @$sockOpen($host, $port, $errno, $errstr, 10)) {
            return $this->_getResponse(array(220, '+OK'));
        }
        $this->_error = 'ソケットがオープンできませんでした。 理由：' . $errstr;
        return FALSE;
    }
    /**
     * コネクションをクローズします.
     */
    private function _close() {
        $this->_isNegotiate = FALSE;
        $this->_isRecognition = FALSE;
        if (!is_resource($this->_socket)) {
            return;
        }
        $this->_sendCommand('QUIT');
        fclose($this->_socket);
        $this->_socket = NULL;
    }
    /**
     * コマンドを送信します.
     * @param string $command コマンド
     * @param string  $args [optional] コマンドのオプション(オプション)
     * @return bool 正常に送信できればTRUE
     */
    private function _sendCommand($command, $args = NULL) {
        if (fwrite($this->_socket, $command . (is_null($args) ? '' : ' ' . $args) . $this->_eol)) {
            return TRUE;
        }
        $this->_error = 'ソケットに書き込みできませんでした。';
        return FALSE;
    }
    /**
     * レスポンスを取得します.
     * @param array $expect 期待するレスポンスコード
     * @return bool レスポンスコードが正常な場合はTRUE
     */
    private function _getResponse(array $expect) {
        $this->_args = array();
        while (!feof($this->_socket)) {
            $this->_args[] = $line = rtrim(substr(@fgets($this->_socket, 1024), 0, 4));
            if ($line[3] !== '-') {
                break;
            }
        }
        if (in_array($this->_args[0], $expect)) {
            return TRUE;
        }
        $this->_error = 'レスポンスコードが不正です。理由：' . $this->_args[0];
        return FALSE;
    }
    /**
     * ネゴシエーションを実行します.
     * @return bool ネゴシエーションできた場合はTRUE
     */
    private function _negotiate() {
        if (!$this->_sendCommand('EHLO', $this->_heloHost)) {
            return FALSE;
        }
        if (!$this->_getResponse(array(250, 503))) {
            if (!$this->_sendCommand('HELO', $this->_heloHost)) {
                return FALSE;
            }
            if (!$this->_getResponse(array(250))) {
                $this->_error = 'ネゴシエーションができませんでした。';
                return FALSE;
            }
        }
        $this->_isNegotiate = TRUE;
        return TRUE;
    }
    /**
     * SMTPの認証方式を判定して実行します.
     * @return bool 認証できた場合はTRUE
     */
    private function _switchAuth() {
        if ($this->_useStartTls) {
            if (!$this->_sendCommand('STARTTLS')) {
                return FALSE;
            }
            if (!$this->_getResponse(array(220))) {
                $this->_error = 'STARTTLSに失敗しました。';
                return FALSE;
            }
        }
        switch ($this->_smtpAuth) {
            case NULL:
                $this->_isRecognition = TRUE;
                break;
            case self::DIGEST_MD5:
                $this->_isRecognition = $this->_digestMd5();
                break;
            case self::CRAM_MD5:
                $this->_isRecognition = $this->_cramMd5();
                break;
            case self::LOGIN:
                $this->_isRecognition = $this->_login();
                break;
            case self::PLAIN:
                $this->_isRecognition = $this->_plain();
                break;
            default:
                $this->_isRecognition = FALSE;
                $this->_error = 'その認証方式には対応していません。';
        }
        return $this->_isRecognition;
    }
    /**
     * DIGEST-MD5の認証を実行します.
     * @return bool 認証できた場合はTRUE
     */
    private function _digestMd5() {
        require_once dirname(dirname(__FILE__)) . DIRECTORY_SEPARATOR . 'auth' . DIRECTORY_SEPARATOR . 'DigestMd5.php';
        if (!$this->_sendCommand('AUTH', 'DIGEST-MD5')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'DIGEST-MD5の認証に失敗しました。';
            return FALSE;
        }
        if (!$auth = base64_encode(
                DigestMd5::getResponse($this->_smtpUser, $this->_smtpPass, base64_decode($this->_args[0]),
                        $this->_smtpHost, "smtp"))) {
            $this->_error = 'digest-challengeが不正です。';
            return FALSE;
        }
        if (!$this->_sendCommand($auth)) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'DIGEST-MD5の認証に失敗しました。';
            return FALSE;
        }
        if (!$this->_sendCommand('')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(235))) {
            $this->_error = 'DIGEST-MD5の認証に失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
    /**
     * CRAM-MD5の認証を実行します.
     * @return bool 認証できた場合はTRUE
     */
    private function _cramMd5() {
        require_once dirname(dirname(__FILE__)) . DIRECTORY_SEPARATOR . 'auth' . DIRECTORY_SEPARATOR . 'CramMd5.php';
        if (!$this->_sendCommand('AUTH', 'CRAM-MD5')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'CRAM-MD5の認証に失敗しました。';
            return FALSE;
        }
        if (!$this
                ->_sendCommand(
                        base64_encode(
                                CramMd5::getResponse($this->_smtpUser, $this->_smtpPass, base64_decode($this->_args[0]))))) {
            return FALSE;
        }
        if (!$this->_getResponse(array(235))) {
            $this->_error = 'CRAM-MD5の認証に失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
    /**
     * LOGINの認証を実行します.
     * @return bool 認証できた場合はTRUE
     */
    private function _login() {
        if (!$this->_sendCommand('AUTH', 'LOGIN')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'LOGINの認証に失敗しました。';
            return FALSE;
        }
        if (!$this->_sendCommand(base64_encode($this->_smtpUser))) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'LOGINの認証に失敗しました。';
            return FALSE;
        }
        if (!$this->_sendCommand(base64_encode($this->_smtpPass))) {
            return FALSE;
        }
        if (!$this->_getResponse(array(235))) {
            $this->_error = 'LOGINの認証に失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
    /**
     * PLAINの認証を実行します.
     * @return bool 認証できた場合はTRUE
     */
    private function _plain() {
        if (!$this->_sendCommand('AUTH', 'PLAIN')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(334))) {
            $this->_error = 'PLAINの認証に失敗しました。';
            return FALSE;
        }
        if (!$this->_sendCommand(base64_encode(chr(0) . $this->_smtpUser . chr(0) . $this->_smtpPass))) {
            return FALSE;
        }
        if (!$this->_getResponse(array(235))) {
            $this->_error = 'PLAINの認証に失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
    /**
     * FROMコマンドを実行します.
     * @return bool 正常に実行できた場合はTRUE
     */
    private function _mailFrom() {
        if (!$this->_sendCommand('MAIL FROM:', $this->_from)) {
            return FALSE;
        }
        if (!$this->_getResponse(array(250))) {
            $this->_error = 'MAIL FROM:コマンドに失敗しました。アドレス：' . $this->_from;
            return FALSE;
        }
        return TRUE;
    }
    /**
     * TOコマンドを実行します.
     * @return bool 正常に実行できた場合はTRUE
     */
    private function _rcptTo() {
        if (!$this->_sendCommand('RCPT TO:', $this->_to)) {
            return FALSE;
        }
        if (!$this->_getResponse(array(250, 251))) {
            $this->_error = 'RCPT TO:コマンドに失敗しました。アドレス：' . $this->_to;
            return FALSE;
        }
        return TRUE;
    }
    /**
     * DATAコマンドを実行します.
     * @param string $data 送信データ
     * @return bool 正常に実行できた場合はTRUE
     */
    private function _data($data) {
        if (!$this->_sendCommand('DATA')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(354))) {
            $this->_error = 'DATAコマンドに失敗しました。';
            return FALSE;
        }
        if (!$this->_sendCommand($data . $this->_eol . '.')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(250))) {
            $this->_error = 'DATAコマンドに失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
    /**
     * RSETコマンドを実行します.
     * @return bool 正常に実行できた場合はTRUE
     */
    private function _rset() {
        if (!$this->_sendCommand('RSET')) {
            return FALSE;
        }
        if (!$this->_getResponse(array(250))) {
            $this->_error = 'RSETコマンドに失敗しました。';
            return FALSE;
        }
        return TRUE;
    }
}
// EOF.