1: <?php
2: /**
3: * Basic SMTP mail class
4: */
5: namespace Opencart\System\Library\Mail;
6: /**
7: * Class Smtp
8: */
9: class Smtp {
10: /**
11: * @var array<string, mixed>
12: */
13: protected array $option = [];
14: /**
15: * @var array<string, false|int>
16: */
17: protected array $default = [
18: 'smtp_port' => 25,
19: 'smtp_timeout' => 5,
20: 'max_attempts' => 3,
21: 'verp' => false
22: ];
23:
24: /**
25: * Constructor
26: *
27: * @param array<string, mixed> $option
28: */
29: public function __construct(array &$option = []) {
30: foreach ($this->default as $key => $value) {
31: if (!isset($option[$key])) {
32: $option[$key] = $value;
33: }
34: }
35:
36: $this->option = &$option;
37: }
38:
39: /**
40: * Send
41: *
42: * @return bool
43: */
44: public function send(): bool {
45: if (empty($this->option['smtp_hostname'])) {
46: throw new \Exception('Error: SMTP hostname required!');
47: }
48:
49: if (empty($this->option['smtp_username'])) {
50: throw new \Exception('Error: SMTP username required!');
51: }
52:
53: if (empty($this->option['smtp_password'])) {
54: throw new \Exception('Error: SMTP password required!');
55: }
56:
57: if (empty($this->option['smtp_port'])) {
58: throw new \Exception('Error: SMTP port required!');
59: }
60:
61: if (empty($this->option['smtp_timeout'])) {
62: throw new \Exception('Error: SMTP timeout required!');
63: }
64:
65: if (is_array($this->option['to'])) {
66: $to = implode(',', $this->option['to']);
67: } else {
68: $to = $this->option['to'];
69: }
70:
71: $boundary = '----=_NextPart_' . md5((string)time());
72:
73: $header = 'MIME-Version: 1.0' . PHP_EOL;
74: $header .= 'To: <' . $to . '>' . PHP_EOL;
75: $header .= 'Subject: =?UTF-8?B?' . base64_encode($this->option['subject']) . '?=' . PHP_EOL;
76: $header .= 'Date: ' . date('D, d M Y H:i:s O') . PHP_EOL;
77: $header .= 'From: =?UTF-8?B?' . base64_encode($this->option['sender']) . '?= <' . $this->option['from'] . '>' . PHP_EOL;
78:
79: if (empty($this->option['reply_to'])) {
80: $header .= 'Reply-To: =?UTF-8?B?' . base64_encode($this->option['sender']) . '?= <' . $this->option['from'] . '>' . PHP_EOL;
81: } else {
82: $header .= 'Reply-To: =?UTF-8?B?' . base64_encode($this->option['reply_to']) . '?= <' . $this->option['reply_to'] . '>' . PHP_EOL;
83: }
84:
85: $header .= 'Return-Path: ' . $this->option['from'] . PHP_EOL;
86: $header .= 'X-Mailer: PHP/' . PHP_VERSION . PHP_EOL;
87: $header .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"' . PHP_EOL . PHP_EOL;
88:
89: $message = '--' . $boundary . PHP_EOL;
90:
91: if (empty($this->option['html'])) {
92: $message .= 'Content-Type: text/plain; charset="utf-8"' . PHP_EOL;
93: $message .= 'Content-Transfer-Encoding: base64' . PHP_EOL . PHP_EOL;
94: $message .= chunk_split(base64_encode($this->option['text']), 950) . PHP_EOL;
95: } else {
96: $message .= 'Content-Type: multipart/alternative; boundary="' . $boundary . '_alt"' . PHP_EOL . PHP_EOL;
97: $message .= '--' . $boundary . '_alt' . PHP_EOL;
98: $message .= 'Content-Type: text/plain; charset="utf-8"' . PHP_EOL;
99: $message .= 'Content-Transfer-Encoding: base64' . PHP_EOL . PHP_EOL;
100:
101: if (!empty($this->option['text'])) {
102: $message .= chunk_split(base64_encode($this->option['text']), 950) . PHP_EOL;
103: } else {
104: $message .= chunk_split(base64_encode('This is a HTML email and your email client software does not support HTML email!'), 950) . PHP_EOL;
105: }
106:
107: $message .= '--' . $boundary . '_alt' . PHP_EOL;
108: $message .= 'Content-Type: text/html; charset="utf-8"' . PHP_EOL;
109: $message .= 'Content-Transfer-Encoding: base64' . PHP_EOL . PHP_EOL;
110: $message .= chunk_split(base64_encode($this->option['html']), 950) . PHP_EOL;
111: $message .= '--' . $boundary . '_alt--' . PHP_EOL;
112: }
113:
114: if (!empty($this->option['attachments'])) {
115: foreach ($this->option['attachments'] as $attachment) {
116: if (is_file($attachment)) {
117: $handle = fopen($attachment, 'r');
118:
119: $content = fread($handle, filesize($attachment));
120:
121: fclose($handle);
122:
123: $message .= '--' . $boundary . PHP_EOL;
124: $message .= 'Content-Type: application/octet-stream; name="' . basename($attachment) . '"' . PHP_EOL;
125: $message .= 'Content-Transfer-Encoding: base64' . PHP_EOL;
126: $message .= 'Content-Disposition: attachment; filename="' . basename($attachment) . '"' . PHP_EOL;
127: $message .= 'Content-ID: <' . urlencode(basename($attachment)) . '>' . PHP_EOL;
128: $message .= 'X-Attachment-Id: ' . urlencode(basename($attachment)) . PHP_EOL . PHP_EOL;
129: $message .= chunk_split(base64_encode($content), 950);
130: }
131: }
132: }
133:
134: $message .= '--' . $boundary . '--' . PHP_EOL;
135:
136: if (substr($this->option['smtp_hostname'], 0, 3) == 'tls') {
137: $hostname = substr($this->option['smtp_hostname'], 6);
138: } else {
139: $hostname = $this->option['smtp_hostname'];
140: }
141:
142: $handle = fsockopen($hostname, $this->option['smtp_port'], $errno, $errstr, $this->option['smtp_timeout']);
143:
144: if ($handle) {
145: if (substr(PHP_OS, 0, 3) != 'WIN') {
146: stream_set_timeout($handle, $this->option['smtp_timeout'], 0);
147: }
148:
149: while ($line = fgets($handle, 515)) {
150: if (substr($line, 3, 1) == ' ') {
151: break;
152: }
153: }
154:
155: fwrite($handle, 'EHLO ' . getenv('SERVER_NAME') . "\r\n");
156:
157: $reply = '';
158:
159: while ($line = fgets($handle, 515)) {
160: $reply .= $line;
161:
162: //some SMTP servers respond with 220 code before responding with 250. hence, we need to ignore 220 response string
163: if (substr($reply, 0, 3) == 220 && substr($line, 3, 1) == ' ') {
164: $reply = '';
165:
166: continue;
167: } elseif (substr($line, 3, 1) == ' ') {
168: break;
169: }
170: }
171:
172: if (substr($reply, 0, 3) != 250) {
173: throw new \Exception('Error: EHLO not accepted from server!');
174: }
175:
176: if (substr($this->option['smtp_hostname'], 0, 3) == 'tls') {
177: fwrite($handle, 'STARTTLS' . "\r\n");
178:
179: $this->handleReply($handle, 220, 'Error: STARTTLS not accepted from server!');
180:
181: if (stream_socket_enable_crypto($handle, true, STREAM_CRYPTO_METHOD_TLS_CLIENT) !== true) {
182: throw new \Exception('Error: TLS could not be established!');
183: }
184:
185: fwrite($handle, 'EHLO ' . getenv('SERVER_NAME') . "\r\n");
186:
187: $this->handleReply($handle, 250, 'Error: EHLO not accepted from server!');
188: }
189:
190: fwrite($handle, 'AUTH LOGIN' . "\r\n");
191:
192: $this->handleReply($handle, 334, 'Error: AUTH LOGIN not accepted from server!');
193:
194: fwrite($handle, base64_encode($this->option['smtp_username']) . "\r\n");
195:
196: $this->handleReply($handle, 334, 'Error: Username not accepted from server!');
197:
198: fwrite($handle, base64_encode($this->option['smtp_password']) . "\r\n");
199:
200: $this->handleReply($handle, 235, 'Error: Password not accepted from server!');
201:
202: if ($this->option['verp']) {
203: fwrite($handle, 'MAIL FROM: <' . $this->option['from'] . '>XVERP' . "\r\n");
204: } else {
205: fwrite($handle, 'MAIL FROM: <' . $this->option['from'] . '>' . "\r\n");
206: }
207:
208: $this->handleReply($handle, 250, 'Error: MAIL FROM not accepted from server!');
209:
210: if (!is_array($this->option['to'])) {
211: fwrite($handle, 'RCPT TO: <' . $this->option['to'] . '>' . "\r\n");
212:
213: $reply = $this->handleReply($handle, false, 'RCPT TO [!array]');
214:
215: if ((substr($reply, 0, 3) != 250) && (substr($reply, 0, 3) != 251)) {
216: throw new \Exception('Error: RCPT TO not accepted from server!');
217: }
218: } else {
219: foreach ($this->option['to'] as $recipient) {
220: fwrite($handle, 'RCPT TO: <' . $recipient . '>' . "\r\n");
221:
222: $reply = $this->handleReply($handle, false, 'RCPT TO [array]');
223:
224: if ((substr($reply, 0, 3) != 250) && (substr($reply, 0, 3) != 251)) {
225: throw new \Exception('Error: RCPT TO not accepted from server!');
226: }
227: }
228: }
229:
230: fwrite($handle, 'DATA' . "\r\n");
231:
232: $this->handleReply($handle, 354, 'Error: DATA not accepted from server!');
233:
234: // According to rfc 821 we should not send more than 1000 including the CRLF
235: $message = str_replace("\r\n", "\n", $header . $message);
236: $message = str_replace("\r", "\n", $message);
237:
238: $lines = explode("\n", $message);
239:
240: foreach ($lines as $line) {
241: // see https://php.watch/versions/8.2/str_split-empty-string-empty-array
242: $results = ($line === '') ? [''] : str_split($line, 998);
243:
244: foreach ($results as $result) {
245: fwrite($handle, $result . "\r\n");
246: }
247: }
248:
249: fwrite($handle, '.' . "\r\n");
250:
251: $this->handleReply($handle, 250, 'Error: DATA not accepted from server!');
252:
253: fwrite($handle, 'QUIT' . "\r\n");
254:
255: $this->handleReply($handle, 221, 'Error: QUIT not accepted from server!');
256:
257: fclose($handle);
258:
259: return true;
260: } else {
261: throw new \Exception('Error: ' . $errstr . ' (' . $errno . ')');
262: }
263: }
264:
265: /**
266: * @param resource $handle
267: * @param false|int $status_code
268: * @param false|string $error_text
269: * @param int $counter
270: *
271: * @return string
272: */
273: private function handleReply($handle, $status_code = false, $error_text = false, int $counter = 0): string {
274: $reply = '';
275:
276: while (($line = fgets($handle, 515)) !== false) {
277: $reply .= $line;
278:
279: if (substr($line, 3, 1) == ' ') {
280: break;
281: }
282: }
283:
284: // Handle slowish server responses (generally due to policy servers)
285: if (!$line && empty($reply) && $counter < $this->option['max_attempts']) {
286: sleep(1);
287:
288: $counter++;
289:
290: return $this->handleReply($handle, $status_code, $error_text, $counter);
291: }
292:
293: if ($status_code) {
294: if (substr($reply, 0, 3) != $status_code) {
295: throw new \Exception($error_text);
296: }
297: }
298:
299: return $reply;
300: }
301: }
302: