1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2024-2026
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 the entire string 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: throw new \RuntimeException(self::E_Blank);
135: }
136:
137: /**
138: * Adds an attachment
139: *
140: * @param string $file the name of the file on the local filesystem to add
141: * @param string $type the MIME content type
142: * @param string $alias the name of the file as presented in the email
143: * @param string $cid the Content-Id
144: * @return null
145: */
146: function attach($file, $type = 'application/octet-stream', $alias = NULL, $cid = NULL) {
147: if (!is_file($file))
148: throw new \RuntimeException(sprintf(self::E_Attach,$file));
149: if ($alias)
150: $file = [$alias, $file];
151: $this->attachments[] = ['filename' => $file, 'cid' => $cid, 'type' => $type];
152: return null;
153: }
154:
155: /**
156: * Transmit message
157: *
158: * @param string|array<string, string> $message the body of the message
159: * @param bool|string $log whether the response should be saved in `$this->log`
160: * @param bool $mock dry run if true
161: * @return bool true if the message was successfully sent
162: */
163: function send($message, $log = TRUE, $mock = FALSE) {
164: if (($this->scheme == 'ssl') && !extension_loaded('openssl')) return FALSE;
165:
166: // Message should not be blank
167: if (!$message) throw new \RuntimeException(self::E_Blank);
168:
169: $fw = Base::instance();
170:
171: // Retrieve headers
172: $headers=$this->headers;
173:
174: // Connect to the server
175: if (!$mock) {
176: $socket = &$this->socket;
177: $socket = @stream_socket_client($this->host.':'.$this->port, $errno, $errstr, intval(ini_get('default_socket_timeout')), STREAM_CLIENT_CONNECT, $this->context);
178: if (!$socket) {
179: $fw->error(500,$errstr);
180: return FALSE;
181: }
182: stream_set_blocking($socket,TRUE);
183: }
184:
185: // Get server's initial response
186: $this->dialog(NULL, $log, $mock);
187:
188: // Announce presence
189: $reply = $this->dialog('EHLO ' . $fw->get('HOST'), $log, $mock);
190: if (strtolower($this->scheme) == 'tls') {
191: $this->dialog('STARTTLS', $log, $mock);
192: if (!$mock) {
193: $method = STREAM_CRYPTO_METHOD_TLS_CLIENT;
194: if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
195: $method |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
196: $method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
197: }
198: stream_socket_enable_crypto($socket, TRUE, $method);
199: }
200: $reply = $this->dialog('EHLO ' . $fw->get('HOST'), $log, $mock);
201: }
202:
203: if (preg_match('/8BITMIME/', $reply)) {
204: $headers['Content-Transfer-Encoding'] = '8bit';
205: } else {
206: $headers['Content-Transfer-Encoding'] = 'quoted-printable';
207: }
208:
209: $message = $this->encodeBody($message, $headers);
210:
211: if (preg_match('/AUTH/', $reply)) {
212: // Authenticate
213: if ($this->user && $this->pw) {
214: $this->dialog('AUTH LOGIN', $log, $mock);
215: $this->dialog(base64_encode($this->user), $log, $mock);
216: $reply = $this->dialog(base64_encode($this->pw), $log, $mock);
217: } elseif ($this->oauthToken) {
218: $auth = base64_encode(sprintf("n,a=%s,%shost=%s%sport=%s%sauth=Bearer %s%s%s",
219: $this->user, chr(1), $this->host, chr(1), $this->port, chr(1), $this->oauthToken, chr(1), chr(1)));
220: $reply = $this->dialog('AUTH OAUTHBEARER ' . $auth, $log, $mock);
221: }
222:
223: if (!preg_match('/^235\s.*/', $reply)) {
224: $this->dialog('QUIT', $log, $mock);
225: if (!$mock && ($socket !== false)) fclose($socket); // @phpstan-ignore notIdentical.alwaysTrue
226: return FALSE;
227: }
228: }
229:
230: if (empty($headers['Message-Id']))
231: $host_name = parse_url($this->host, PHP_URL_HOST);
232: $headers['Message-Id'] = '<' . bin2hex(random_bytes(16)) . '@' . (isset($host_name) ? $host_name : $this->host) . '>';
233: if (empty($headers['Date']))
234: $headers['Date'] = date('r');
235:
236: // Required headers
237: $reqd = ['From', 'To', 'Subject'];
238: foreach ($reqd as $id) {
239: if (empty($headers[$id]))
240: throw new \RuntimeException(sprintf(self::E_Header,$id));
241: }
242: $eol = "\r\n";
243:
244: // Stringify headers
245: foreach ($headers as $key=>&$val) {
246: if (in_array($key,['From','To','Cc','Bcc'])) {
247: $email = '';
248: preg_match_all('/(?:".+?" |=\?.+?\?= )?(?:<.+?>|[^ ,]+)/', $val,$matches,PREG_SET_ORDER);
249: foreach ($matches as $raw) {
250: $email .= ($email ? ', ' : '') . (preg_match('/<.+?>/', $raw[0]) ? $raw[0] : ('<' . $raw[0] . '>'));
251: }
252: $val = $email;
253: }
254: unset($val);
255: }
256:
257: $from = isset($headers['Sender']) ? $headers['Sender'] : strstr($headers['From'],'<');
258: unset($headers['Sender']);
259:
260: // Start message dialog
261: $this->dialog('MAIL FROM: '.$from, $log, $mock);
262: foreach ($fw->split($headers['To'] . (isset($headers['Cc']) ? (';'. $headers['Cc']) : '') . (isset($headers['Bcc']) ? (';' . $headers['Bcc']) : '')) as $dst) {
263: $this->dialog('RCPT TO: ' . strstr($dst, '<'), $log, $mock);
264: }
265: unset($headers['Bcc']);
266:
267: $this->dialog('DATA', $log, $mock);
268: if ($this->attachments) {
269: // Replace Content-Type
270: $type = $headers['Content-Type'];
271: unset($headers['Content-Type']);
272: $enc = $headers['Content-Transfer-Encoding'];
273: unset($headers['Content-Transfer-Encoding']);
274:
275: $hash = bin2hex(random_bytes(16));
276: // Send mail headers
277: $out='Content-Type: multipart/mixed; boundary="'.$hash.'"'.$eol;
278: foreach ($headers as $key=>$val)
279: $out.=$key.': '. $this->encodeHeaderValue($val) .$eol;
280: $out.=$eol;
281: $out.='This is a multi-part message in MIME format'.$eol;
282: $out.=$eol;
283: $out.='--'.$hash.$eol;
284: $out.='Content-Type: '.$type.$eol;
285:
286: // Only add Content-Transfer-Encoding if the first part is NOT a multipart
287: if (strpos($type, 'multipart/') !== 0)
288: $out.='Content-Transfer-Encoding: '.$enc.$eol;
289:
290: $out.=$eol;
291: $out.=$message.$eol;
292: foreach ($this->attachments as $attachment) {
293: if (is_array($attachment['filename']))
294: list($alias,$file)=$attachment['filename'];
295: else
296: $alias=basename($file=$attachment['filename']);
297: $out.='--'.$hash.$eol;
298: $out.='Content-Type: ' . $attachment['type'] . $eol;
299: $out.='Content-Transfer-Encoding: base64'.$eol;
300: if ($attachment['cid'])
301: $out.='Content-Id: '.$attachment['cid'].$eol;
302: $out.='Content-Disposition: attachment; '.
303: 'filename="'.$alias.'"'.$eol;
304: $out.=$eol;
305: $contents = file_get_contents($file);
306: if ($contents !== false) $out.=chunk_split(base64_encode($contents)).$eol;
307: }
308: $out.=$eol;
309: $out.='--'.$hash.'--'.$eol;
310: $out.='.';
311: $this->dialog($out,preg_match('/verbose/i',strval($log)),$mock);
312: } else {
313: // Send mail headers
314: $out='';
315: foreach ($headers as $key=>$val)
316: $out.=$key . ': ' . $this->encodeHeaderValue($val) . $eol;
317: $out.=$eol;
318: $out.=$message.$eol;
319: $out.='.';
320: // Send message
321: $this->dialog($out,preg_match('/verbose/i',strval($log)),$mock);
322: }
323:
324: $this->dialog('QUIT',$log,$mock);
325: if (!$mock && ($socket !== false)) fclose($socket); // @phpstan-ignore notIdentical.alwaysTrue
326: return TRUE;
327: }
328: }
329:
330: ?>
331: