码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Support/Mailer.php
Mailer.php7.1 KB
<?php
/**
 * SMTP 邮件发送(零依赖 socket 实现)。
 *
 * 读取 sys_settings 的 smtp_* 配置;未配置 smtp_host 时回退 PHP mail()。
 * 支持 ssl(465) / STARTTLS(587) / 明文(25),认证 AUTH LOGIN(可选)。
 * 返回 [bool ok, string message],供 Notifier 与「发送测试邮件」共用。
 */
declare(strict_types=1);

namespace Wstat\Support;

class Mailer
{
    /**
     * @param array<int,string> $to 已校验格式的收件人列表
     */
    public static function send(array $to, string $subject, string $text): array
    {
        if (!$to) {
            return [false, '缺少收件人'];
        }
        $host = trim(Settings::get('smtp_host'));
        if ($host === '') {
            return self::sendByMail($to, $subject, $text);
        }
        try {
            return self::sendBySmtp($to, $subject, $text);
        } catch (\Throwable $e) {
            return [false, 'SMTP: ' . $e->getMessage()];
        }
    }

    /** 回退路径:PHP mail()(需主机配置 MTA) */
    private static function sendByMail(array $to, string $subject, string $text): array
    {
        $fromEmail = self::fromEmail();
        $subjectEnc = '=?UTF-8?B?' . base64_encode($subject) . '?=';
        $headers = implode("\r\n", [
            'From: ' . self::fromName() . " <{$fromEmail}>",
            'Content-Type: text/plain; charset=UTF-8',
            'Content-Transfer-Encoding: base64',
            'MIME-Version: 1.0',
        ]);
        $ok = @mail(implode(',', $to), $subjectEnc, chunk_split(base64_encode($text)), $headers);
        return [(bool) $ok, $ok ? '已通过 mail() 提交发送' : 'mail() 投递失败(未配置 SMTP 且主机无 MTA)'];
    }

    private static function sendBySmtp(array $to, string $subject, string $text): array
    {
        $secure = strtolower(Settings::get('smtp_secure'));
        $port = (int) Settings::get('smtp_port');
        if (!in_array($port, [25, 465, 587, 2525], true)) {
            $port = $secure === 'ssl' ? 465 : ($secure === 'tls' ? 587 : 25);
        }
        $transport = $secure === 'ssl' ? 'ssl://' : '';
        $host = trim(Settings::get('smtp_host'));

        $sock = @fsockopen($transport . $host, $port, $errno, $errstr, 10);
        if ($sock === false) {
            return [false, "连接失败: {$errstr}({$errno})"];
        }
        stream_set_timeout($sock, 10);

        $resp = self::read($sock);
        if ($resp[0] !== 2) {
            fclose($sock);
            return [false, '握手失败: ' . $resp[1]];
        }

        self::cmd($sock, 'EHLO ' . self::heloName());

        if ($secure === 'tls') {
            $r = self::cmd($sock, 'STARTTLS');
            if ($r[0] !== 2) {
                fclose($sock);
                return [false, 'STARTTLS 被拒绝: ' . $r[1]];
            }
            if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                fclose($sock);
                return [false, 'TLS 协商失败'];
            }
            self::cmd($sock, 'EHLO ' . self::heloName());
        }

        $user = Settings::get('smtp_username');
        $pass = Settings::get('smtp_password');
        if ($user !== '') {
            $r = self::cmd($sock, 'AUTH LOGIN');
            if ($r[0] !== 3) {
                fclose($sock);
                return [false, '服务器不支持 AUTH LOGIN: ' . $r[1]];
            }
            $r = self::cmd($sock, base64_encode($user));
            if ($r[0] !== 3) {
                fclose($sock);
                return [false, 'SMTP 用户名被拒: ' . $r[1]];
            }
            $r = self::cmd($sock, base64_encode($pass));
            if ($r[0] !== 2) {
                fclose($sock);
                return [false, 'SMTP 认证失败(检查用户名/密码/授权码): ' . $r[1]];
            }
        }

        $from = self::fromEmail();
        $r = self::cmd($sock, 'MAIL FROM:<' . $from . '>');
        if ($r[0] !== 2) {
            fclose($sock);
            return [false, 'MAIL FROM 被拒: ' . $r[1]];
        }
        foreach ($to as $rcpt) {
            $r = self::cmd($sock, 'RCPT TO:<' . $rcpt . '>');
            if ($r[0] !== 2) {
                fclose($sock);
                return [false, 'RCPT 被拒(' . $rcpt . '): ' . $r[1]];
            }
        }

        $body = self::buildBody($from, $to, $subject, $text);
        $r = self::cmd($sock, 'DATA');
        if ($r[0] !== 3) {
            fclose($sock);
            return [false, 'DATA 被拒: ' . $r[1]];
        }
        // 按 SMTP 规范把行首的单独「.」转义
        $body = preg_replace('/^\./m', '..', $body);
        fwrite($sock, $body . "\r\n.\r\n");
        $resp = self::read($sock);
        fclose($sock);
        if ($resp[0] !== 2) {
            return [false, '正文被拒: ' . $resp[1]];
        }
        return [true, 'SMTP 已接受(' . count($to) . ' 个收件人)'];
    }

    private static function buildBody(string $from, array $to, string $subject, string $text): string
    {
        $headers = [
            'From: =?UTF-8?B?' . base64_encode(self::fromName()) . '?= <' . $from . '>',
            'To: ' . implode(', ', $to),
            'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=',
            'Date: ' . date('r'),
            'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . self::heloName() . '>',
            'MIME-Version: 1.0',
            'Content-Type: text/plain; charset=UTF-8',
            'Content-Transfer-Encoding: base64',
        ];
        return implode("\r\n", $headers) . "\r\n\r\n" . chunk_split(base64_encode($text));
    }

    /** 读一条 SMTP 响应(可能多行 250-xxx),返回 [code, text];code 归一为 1/2/3 档 */
    private static function read($sock): array
    {
        $data = '';
        while (($line = fgets($sock, 1024)) !== false) {
            $data .= $line;
            if (strlen($line) < 4 || $line[3] !== '-') {
                break;
            }
        }
        $code = (int) substr($data, 0, 3);
        return [$code === 0 ? 0 : ($code < 300 ? 2 : ($code < 400 ? 3 : 1)), trim($data)];
    }

    /** 发命令并返回响应([档位, 原文]),由调用方校验档位 */
    private static function cmd($sock, string $cmd): array
    {
        fwrite($sock, $cmd . "\r\n");
        return self::read($sock);
    }

    private static function fromEmail(): string
    {
        $e = trim(Settings::get('smtp_from_email'));
        if ($e !== '' && Util::validEmail($e)) {
            return $e;
        }
        $u = Settings::get('smtp_username');
        if ($u !== '' && Util::validEmail($u)) {
            return $u;
        }
        return 'no-reply@' . self::heloName();
    }

    private static function fromName(): string
    {
        $n = trim(Settings::get('smtp_from_name'));
        return $n !== '' ? $n : 'WebStats';
    }

    private static function heloName(): string
    {
        $host = strtolower($_SERVER['SERVER_NAME'] ?? $_SERVER['HTTP_HOST'] ?? '');
        if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) {
            $host = 'localhost';
        }
        return preg_replace('/[^a-z0-9.\-]/', '', $host) ?: 'localhost';
    }
}