master
ブランチ master
LittleBeast / src / Std / Lib / Mailer.php
12.6 KB、364行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40use LogType;
41use Result;
42use Std\Lib\Tls\TlsException;
43
44class Mailer {
45 private $socket;
46 private string $host;
47 private int $port;
48 private ?string $user;
49 private ?string $pass;
50
51 private string $from;
52 private ?string $pgpKey = null;
53 private ?string $pgpPass = null;
54 private int $timeout = 30;
55 /** STARTTLSのオプション(Std\Lib\Tlsを参照、例:['cafile' => …]) */
56 private array $tls = [];
57
58 /**
59 * コンストラクタ
60 *
61 * @param array|null $info MAILINFOの代わりの設定(テスト用。指定した場合はMAILER_ENABLEDを確認しない)
62 */
63 public function __construct(?array $info = null) {
64 if ($info === null && !MAILER_ENABLED) {
65 throw new \Exception("メーラーは無効です。");
66 }
67
68 $info ??= MAILINFO;
69 $this->host = (string)($info['host'] ?? '');
70 $this->port = (int)($info['port'] ?? 587);
71 $this->user = $info['user'] ?? null;
72 $this->pass = $info['pass'] ?? null;
73 $this->from = (string)($info['from'] ?? '');
74 $this->tls = (array)($info['tls'] ?? []);
75 }
76
77 /**
78 * 接続・認証・送信・切断を1回で行う(例外はResultにする)
79 *
80 * @param string $to 受信者のメールアドレス
81 * @param string $subject 件名
82 * @param string $body 本文
83 * @param string|null $toName 受信者の名前
84 * @return Result
85 */
86 public static function deliver(string $to, string $subject, string $body, ?string $toName = null): Result {
87 try {
88 $mail = new self();
89 $mail->connect();
90 try {
91 $mail->authenticate();
92 $mail->send($to, $subject, $body, $toName);
93 } finally {
94 $mail->disconnect();
95 }
96 return Result::Success('メールを送信しました。');
97 } catch (\Throwable $e) {
98 logger(LogType::Mailer, "「{$to}」への送信に失敗:".$e->getMessage());
99 return Result::Error('メールの送信に失敗しました。');
100 }
101 }
102
103 /**
104 * ソケット接続を開く。
105 *
106 * @return void
107 */
108 public function connect(): void {
109 $this->socket = @fsockopen($this->host, $this->port, $errno, $err, $this->timeout);
110 if (!$this->socket) {
111 $msg = "接続に失敗: {$err} ({$errno})";
112 logger(LogType::Mailer, $msg);
113 throw new \Exception($msg);
114 }
115 stream_set_timeout($this->socket, $this->timeout);
116
117 $this->expectGreeting();
118 }
119
120 /**
121 * 既に開いているストリームを使う(テストやプロキシ用)
122 *
123 * @param resource $stream SMTPサーバーに繋がったストリーム
124 * @return void
125 */
126 public function useStream($stream): void {
127 $this->socket = $stream;
128 $this->expectGreeting();
129 }
130
131 private function expectGreeting(): void {
132 $res = $this->readResponse();
133 if (substr($res, 0, 3) !== '220') {
134 $msg = "予期しない挨拶: {$res}";
135 logger(LogType::Mailer, $msg);
136 throw new \Exception($msg);
137 }
138 }
139
140 /**
141 * ソケット接続を切断する。
142 *
143 * @return void
144 */
145 public function disconnect(): void {
146 if (!is_resource($this->socket)) return;
147 try {
148 $this->sendCommand('QUIT', 221);
149 } catch (\Exception $e) {
150 // 切断の失敗は無視する
151 }
152 fclose($this->socket);
153 }
154
155 /**
156 * サーバーで認証する。
157 * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
158 *
159 * @return void
160 */
161 public function authenticate(): void {
162 // EHLOには相手ではなく自分のホスト名を名乗る
163 $me = gethostname() ?: 'localhost';
164 $ehloRes = $this->sendCommand('EHLO '.$me, 250);
165
166 // STARTTLSは対応するかどうか確認
167 if (strpos($ehloRes, 'STARTTLS') !== false) {
168 $this->sendCommand('STARTTLS', 220);
169
170 // TLS暗号化(TLS 1.3・1.2、証明書はサーバーのホスト名で確認する)
171 try {
172 $this->socket = Tls::enable($this->socket, $this->tls + ['peer_name' => $this->host]);
173 } catch (TlsException $e) {
174 $msg = "TLSハンドシェイクに失敗: ".$e->getMessage();
175 logger(LogType::Mailer, $msg);
176 throw new \Exception($msg);
177 }
178
179 $this->sendCommand('EHLO '.$me, 250);
180 }
181
182 if ($this->user && $this->pass) {
183 $this->sendCommand('AUTH LOGIN', 334);
184 $this->sendCommand(base64_encode($this->user), 334, true);
185 $this->sendCommand(base64_encode($this->pass), 235, true);
186 }
187 }
188
189 /**
190 * メールを送信する。
191 *
192 * @param string $to 受信者のメールアドレス。
193 * @param string $subject メールの件名。
194 * @param string $body メールの本文。
195 * @param string|null $toName 受信者の名前。
196 * @param string|null $replyTo 返信先メールアドレス。
197 * @param string|null $replyToName 返信先の名前。
198 * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。
199 *
200 * @return void
201 * @throws \InvalidArgumentException アドレスや名前に改行等が含まれる場合(ヘッダー注入対策)
202 */
203 public function send(
204 string $to,
205 string $subject,
206 string $body,
207 ?string $toName = null,
208 ?string $replyTo = null,
209 ?string $replyToName = null,
210 bool $pgpSign = false
211 ): void {
212 $from = $this->from;
213 foreach ([$from, $to, $replyTo] as $addr) {
214 if ($addr !== null && !self::isSafeAddress($addr)) throw new \InvalidArgumentException("不正なメールアドレス: {$addr}");
215 }
216 foreach ([$toName, $replyToName, $subject] as $text) {
217 if ($text !== null && preg_match('/[\r\n\0]/', $text)) throw new \InvalidArgumentException('件名や名前に改行は使えません。');
218 }
219
220 $this->sendCommand("MAIL FROM: <{$from}>", 250);
221 $this->sendCommand("RCPT TO: <{$to}>", 250);
222 $this->sendCommand('DATA', 354);
223
224 $headers = "Date: ".date('r')."\r\n"; // RFC 2822
225 // Message-IDが無いと迷惑メール扱いされ易い
226 $domain = substr(strrchr($from, '@') ?: '@localhost', 1);
227 $headers .= "Message-ID: <".bin2hex(random_bytes(16))."@{$domain}>\r\n";
228
229 $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
230 $headers .= "Subject: {$encSubject}\r\n";
231
232 $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
233 $headers .= "From: {$fromHeader}\r\n";
234
235 $toHeader = $toName
236 ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
237 $headers .= "To: {$toHeader}\r\n";
238
239 if ($replyTo) {
240 $replyToHeader = $replyToName
241 ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
242 $headers .= "Reply-To: {$replyToHeader}\r\n";
243 }
244
245 $headers .= "MIME-Version: 1.0\r\n";
246
247 if ($pgpSign) {
248 $boundary = uniqid('BOUNDARY_');
249 $headers .= "Content-Type: multipart/signed;\r\n";
250 $headers .= " protocol=\"application/pgp-signature\";\r\n";
251 $headers .= " micalg=php-sha512;\r\n";
252 $headers .= " boundary=\"{$boundary}\"\r\n";
253 } else {
254 $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
255 $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
256 }
257
258 $headers .= "X-Mailer: 076 Little Beast\r\n";
259
260 // 改行をCRLFに揃えてからエンコードする
261 $encBody = quoted_printable_encode(preg_replace('/\r\n|\r|\n/', "\r\n", $body));
262 $data = $headers."\r\n".$encBody;
263
264 if ($pgpSign) {
265 $signature = $this->signMessage($data);
266 $data = "--{$boundary}\r\n"
267 .$data."\r\n"
268 ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
269 ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
270 .$signature."\r\n"
271 ."--{$boundary}--\r\n";
272 }
273
274 // ドットスタッフィング:「.」で始まる行は「..」にする(「.」だけの行で本文が途中で終わらない様に)
275 $data = preg_replace('/^\./m', '..', $data);
276 $data .= "\r\n.\r\n";
277 fwrite($this->socket, $data);
278
279 $response = $this->readResponse();
280 if (substr($response, 0, 3) != '250') {
281 $msg = "メール送信に失敗: {$response}";
282 logger(LogType::Mailer, $msg);
283 throw new \Exception($msg);
284 }
285 }
286
287 /**
288 * お好みのタイムゾーンを設定する。
289 * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
290 * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
291 *
292 * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo)
293 * @return void
294 */
295 public function setTimezone(string $zone): void {
296 date_default_timezone_set($zone);
297 }
298
299 /**
300 * PGPキーとオプションでパスフレーズを設定する。
301 *
302 * @param string $keypath PGP署名に使用する秘密鍵へのパス。
303 * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
304 *
305 * @return void
306 */
307 public function enablePGP(string $keypath, ?string $passphrase = null): void {
308 $this->pgpKey = file_get_contents($keypath);
309 $this->pgpPass = $passphrase;
310 }
311
312 // 機能性メソッド
313
314 private function sendCommand(string $command, int $retcode, bool $secret = false): string {
315 fwrite($this->socket, $command."\r\n");
316 $res = $this->readResponse();
317 if (substr($res, 0, 3) != $retcode) {
318 // 認証情報をログに残さない
319 $shown = $secret ? '(認証情報)' : $command;
320 $msg = "「{$shown}」に対する予期しないレスポンス: {$res}";
321 logger(LogType::Mailer, $msg);
322 throw new \Exception($msg);
323 }
324
325 return $res;
326 }
327
328 private function readResponse(): string {
329 $res = '';
330
331 while (($line = fgets($this->socket, 515)) !== false) {
332 $res .= $line;
333 if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
334 }
335
336 return $res;
337 }
338
339 /**
340 * SMTPのコマンドに入れても安全なメールアドレスか(改行や<>を含まない)
341 */
342 private static function isSafeAddress(string $addr): bool {
343 return $addr !== '' && !preg_match('/[\r\n\0<>\s]/', $addr) && str_contains($addr, '@');
344 }
345
346 private function signMessage(string $message): string {
347 if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
348 $gpg = new \gnupg();
349 $gpg->addsignkey($this->pgpKey, $this->pgpPass);
350 $gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
351 return $gpg->sign($message);
352 } else { // なければ、CLIツールを使う(gnupgをインストールは必須)
353 $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
354 file_put_contents($tmp, $message);
355 $sig = shell_exec(
356 "gpg --batch "
357 .($this->pgpPass ? "--passphrase ".escapeshellarg($this->pgpPass)." " : '')
358 ."--detach-sign --armor ".escapeshellarg($tmp)." 2>&1"
359 );
360 unlink($tmp);
361 return $sig;
362 }
363 }
364}