1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2024-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: */
21:
22: namespace SimpleID\Util;
23:
24: use \Base;
25: use \SMTP as F3SMTP;
26:
27: /**
28: * This extends the F3 SMTP class with the following additional features:
29: *
30: * - Correct encoding of non-ASCII values in MIME headers
31: * - Ability to specify Content-Type for attachments
32: * - Send messages in HTML
33: *
34: * In the F3 implementation, the following special MIME headers are
35: * specified. These headers are not included in the mail message, but
36: * instead are used to provide additional commands to the SMTP server
37: *
38: * - Sender - specifies the address for the `MAIL FROM` command
39: * - Bcc - specifies additional recipients for the `RCPT TO` command
40: */
41: class SMTP extends F3SMTP {
42: /**
43: * OAuth access token
44: * @var string
45: */
46: protected $oauthToken;
47:
48: /**
49: * Instantiate class
50: *
51: * @param string $host server name
52: * @param int $port port
53: * @param string|null $scheme security, one of ssl (SSL) or tls (STARTTLS)
54: * @param string|null $user user name
55: * @param string|null $pw password
56: * @param string|null $oauthToken OAuth token
57: * @param array<mixed>|null $ctx resource options
58: */
59: function __construct($host = 'localhost', $port = 25, $scheme = NULL, $user = NULL, $pw = NULL, $oauthToken = NULL, $ctx = NULL) {
60: parent::__construct($host, $port, $scheme, $user, $pw, $ctx);
61: $this->oauthToken = $oauthToken;
62: }
63:
64: /**
65: * Encodes a MIME header value.
66: *
67: * If the value has non-ASCII characters, this function
68: * assumes that the value is already UTF-8 encoded. It then further
69: * encodes it in base64.
70: *
71: * @param string $val the value to encode
72: * @return string the encoded value
73: */
74: protected function encodeHeaderValue(string $val): string {
75: if (preg_match('/[^\x00-\x7F]/', $val) === 1) return sprintf("=?utf-8?B?%s?= ", base64_encode($val));;
76: return $val;
77: }
78:
79: /**
80: * Encodes a body.
81: *
82: * The body can either be a string, or an array with 'html' and (optionally) 'text' keys.
83: * If the 'text' key is not specified, the plain text version is automatically generated
84: * from the HTML.
85: *
86: * @param string|array<string, string> $body the body to encode
87: * @param array<string, mixed> &$headers MIME headers.
88: * @return string the encoded body
89: */
90: protected function encodeBody($body, &$headers): string {
91: if (is_string($body)) {
92: if ($headers['Content-Transfer-Encoding'] == 'quoted-printable')
93: $body = preg_replace('/^\.(.+)/m', '..$1', quoted_printable_encode($body));
94: return $body;
95: } elseif (isset($body['html'])) {
96: if (!isset($body['text'])) {
97: $body['text'] = strip_tags(preg_replace('{<(head|style|script)\b.*?</\1>}is', '', $body['html']));
98: }
99:
100: $fw = Base::instance();
101: $eol = "\r\n";
102: $hash = bin2hex(random_bytes(16));
103: $headers['Content-Type'] = 'multipart/alternative; boundary="'. $hash .'"';
104:
105: $out = '--' . $hash . $eol;
106: $out .= "Content-Type: text/plain; charset=\"" . $fw->get('ENCODING') . "\"" . $eol;
107: $out .= "Content-Transfer-Encoding: " . $headers['Content-Transfer-Encoding'] . $eol;
108: $out .= $eol;
109:
110: if ($headers['Content-Transfer-Encoding'] == 'quoted-printable') {
111: $out .= preg_replace('/^\.(.+)/m', '..$1', quoted_printable_encode($body['text'])) . $eol;
112: } else {
113: $out .= $body['text'] . $eol;
114: }
115:
116: $out .= $eol;
117: $out .= "--" . $hash . $eol;
118: $out .= "Content-Type: text/html; charset=\"" . $fw->get('ENCODING') ."\"" . $eol;
119: $out .= "Content-Transfer-Encoding: " . $headers['Content-Transfer-Encoding'] . $eol;
120: $out .= $eol;
121:
122: if ($headers['Content-Transfer-Encoding'] == 'quoted-printable') {
123: $out .= preg_replace('/^\.(.+)/m', '..$1', quoted_printable_encode($body['html'])) . $eol;
124: } else {
125: $out .= $body['html'] . $eol;
126: }
127:
128: $out .= $eol;
129: $out .= "--" . $hash . "--" . $eol;
130:
131: unset($headers['Content-Transfer-Encoding']);
132: return $out;
133: }
134: user_error(self::E_Blank, E_USER_ERROR);
135: return '';
136: }
137:
138: /**
139: * Adds an attachment
140: *
141: * @param string $file the name of the file on the local filesystem to add
142: * @param string $type the MIME content type
143: * @param string $alias the name of the file as presented in the email
144: * @param string $cid the Content-Id
145: * @return null
146: */
147: function attach($file, $type = 'application/octet-stream', $alias = NULL, $cid = NULL) {
148: if (!is_file($file))
149: user_error(sprintf(self::E_Attach,$file),E_USER_ERROR);
150: if ($alias)
151: $file = [$alias, $file];
152: $this->attachments[] = ['filename' => $file, 'cid' => $cid, 'type' => $type];
153: return null;
154: }
155:
156: /**
157: * Transmit message
158: *
159: * @param string|array<string, string> $message the body of the message
160: * @param bool|string $log whether the response should be saved in `$this->log`
161: * @param bool $mock dry run if true
162: * @return bool true if the message was successfully sent
163: */
164: function send($message, $log = TRUE, $mock = FALSE) {
165: if (($this->scheme == 'ssl') && !extension_loaded('openssl')) return FALSE;
166:
167: // Message should not be blank
168: if (!$message) user_error(self::E_Blank, E_USER_ERROR);
169:
170: $fw = Base::instance();
171:
172: // Retrieve headers
173: $headers=$this->headers;
174:
175: // Connect to the server
176: if (!$mock) {
177: $socket = &$this->socket;
178: $socket = @stream_socket_client($this->host.':'.$this->port, $errno, $errstr, intval(ini_get('default_socket_timeout')), STREAM_CLIENT_CONNECT, $this->context);
179: if (!$socket) {
180: $fw->error(500,$errstr);
181: return FALSE;
182: }
183: stream_set_blocking($socket,TRUE);
184: }
185:
186: // Get server's initial response
187: $this->dialog(NULL, $log, $mock);
188:
189: // Announce presence
190: $reply = $this->dialog('EHLO ' . $fw->get('HOST'), $log, $mock);
191: if (strtolower($this->scheme) == 'tls') {
192: $this->dialog('STARTTLS', $log, $mock);
193: if (!$mock) {
194: $method = STREAM_CRYPTO_METHOD_TLS_CLIENT;
195: if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
196: $method |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
197: $method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
198: }
199: stream_socket_enable_crypto($socket, TRUE, $method);
200: }
201: $reply = $this->dialog('EHLO ' . $fw->get('HOST'), $log, $mock);
202: }
203:
204: if (preg_match('/8BITMIME/', $reply)) {
205: $headers['Content-Transfer-Encoding'] = '8bit';
206: } else {
207: $headers['Content-Transfer-Encoding'] = 'quoted-printable';
208: }
209:
210: $message = $this->encodeBody($message, $headers);
211:
212: if (preg_match('/AUTH/', $reply)) {
213: // Authenticate
214: if ($this->user && $this->pw) {
215: $this->dialog('AUTH LOGIN', $log, $mock);
216: $this->dialog(base64_encode($this->user), $log, $mock);
217: $reply = $this->dialog(base64_encode($this->pw), $log, $mock);
218: } elseif ($this->oauthToken) {
219: $auth = base64_encode(sprintf("n,a=%s,%shost=%s%sport=%s%sauth=Bearer %s%s%s",
220: $this->user, chr(1), $this->host, chr(1), $this->port, chr(1), $this->oauthToken, chr(1), chr(1)));
221: $reply = $this->dialog('AUTH OAUTHBEARER ' . $auth, $log, $mock);
222: }
223:
224: if (!preg_match('/^235\s.*/', $reply)) {
225: $this->dialog('QUIT', $log, $mock);
226: if (!$mock && ($socket !== false)) fclose($socket); // @phpstan-ignore notIdentical.alwaysTrue
227: return FALSE;
228: }
229: }
230:
231: if (empty($headers['Message-Id']))
232: $host_name = parse_url($this->host, PHP_URL_HOST);
233: $headers['Message-Id'] = '<' . bin2hex(random_bytes(16)) . '@' . (isset($host_name) ? $host_name : $this->host) . '>';
234: if (empty($headers['Date']))
235: $headers['Date'] = date('r');
236:
237: // Required headers
238: $reqd = ['From', 'To', 'Subject'];
239: foreach ($reqd as $id) {
240: if (empty($headers[$id]))
241: user_error(sprintf(self::E_Header,$id),E_USER_ERROR);
242: }
243: $eol = "\r\n";
244:
245: // Stringify headers
246: foreach ($headers as $key=>&$val) {
247: if (in_array($key,['From','To','Cc','Bcc'])) {
248: $email = '';
249: preg_match_all('/(?:".+?" |=\?.+?\?= )?(?:<.+?>|[^ ,]+)/', $val,$matches,PREG_SET_ORDER);
250: foreach ($matches as $raw) {
251: $email .= ($email ? ', ' : '') . (preg_match('/<.+?>/', $raw[0]) ? $raw[0] : ('<' . $raw[0] . '>'));
252: }
253: $val = $email;
254: }
255: unset($val);
256: }
257:
258: $from = isset($headers['Sender']) ? $headers['Sender'] : strstr($headers['From'],'<');
259: unset($headers['Sender']);
260:
261: // Start message dialog
262: $this->dialog('MAIL FROM: '.$from, $log, $mock);
263: foreach ($fw->split($headers['To'] . (isset($headers['Cc']) ? (';'. $headers['Cc']) : '') . (isset($headers['Bcc']) ? (';' . $headers['Bcc']) : '')) as $dst) {
264: $this->dialog('RCPT TO: ' . strstr($dst, '<'), $log, $mock);
265: }
266: unset($headers['Bcc']);
267:
268: $this->dialog('DATA', $log, $mock);
269: if ($this->attachments) {
270: // Replace Content-Type
271: $type = $headers['Content-Type'];
272: unset($headers['Content-Type']);
273: $enc = $headers['Content-Transfer-Encoding'];
274: unset($headers['Content-Transfer-Encoding']);
275:
276: $hash = bin2hex(random_bytes(16));
277: // Send mail headers
278: $out='Content-Type: multipart/mixed; boundary="'.$hash.'"'.$eol;
279: foreach ($headers as $key=>$val)
280: $out.=$key.': '. $this->encodeHeaderValue($val) .$eol;
281: $out.=$eol;
282: $out.='This is a multi-part message in MIME format'.$eol;
283: $out.=$eol;
284: $out.='--'.$hash.$eol;
285: $out.='Content-Type: '.$type.$eol;
286:
287: // Only add Content-Transfer-Encoding if the first part is NOT a multipart
288: if (strpos($type, 'multipart/') !== 0)
289: $out.='Content-Transfer-Encoding: '.$enc.$eol;
290:
291: $out.=$eol;
292: $out.=$message.$eol;
293: foreach ($this->attachments as $attachment) {
294: if (is_array($attachment['filename']))
295: list($alias,$file)=$attachment['filename'];
296: else
297: $alias=basename($file=$attachment['filename']);
298: $out.='--'.$hash.$eol;
299: $out.='Content-Type: ' . $attachment['type'] . $eol;
300: $out.='Content-Transfer-Encoding: base64'.$eol;
301: if ($attachment['cid'])
302: $out.='Content-Id: '.$attachment['cid'].$eol;
303: $out.='Content-Disposition: attachment; '.
304: 'filename="'.$alias.'"'.$eol;
305: $out.=$eol;
306: $contents = file_get_contents($file);
307: if ($contents !== false) $out.=chunk_split(base64_encode($contents)).$eol;
308: }
309: $out.=$eol;
310: $out.='--'.$hash.'--'.$eol;
311: $out.='.';
312: $this->dialog($out,preg_match('/verbose/i',strval($log)),$mock);
313: } else {
314: // Send mail headers
315: $out='';
316: foreach ($headers as $key=>$val)
317: $out.=$key . ': ' . $this->encodeHeaderValue($val) . $eol;
318: $out.=$eol;
319: $out.=$message.$eol;
320: $out.='.';
321: // Send message
322: $this->dialog($out,preg_match('/verbose/i',strval($log)),$mock);
323: }
324:
325: $this->dialog('QUIT',$log,$mock);
326: if (!$mock && ($socket !== false)) fclose($socket); // @phpstan-ignore notIdentical.alwaysTrue
327: return TRUE;
328: }
329: }
330:
331: ?>