Email.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <?php
  2. namespace Tools\Mailer;
  3. use Cake\Core\Configure;
  4. use Cake\Log\LogTrait;
  5. use Cake\Mailer\Email as CakeEmail;
  6. use InvalidArgumentException;
  7. use Psr\Log\LogLevel;
  8. use Tools\Utility\Mime;
  9. use Tools\Utility\Text;
  10. use Tools\Utility\Utility;
  11. class Email extends CakeEmail {
  12. use LogTrait;
  13. /**
  14. * @var int|null
  15. */
  16. protected $_wrapLength = null;
  17. /**
  18. * @var string|null
  19. */
  20. protected $_error = null;
  21. /**
  22. * @var array|null
  23. */
  24. protected $_debug = null;
  25. /**
  26. * @var array
  27. */
  28. protected $_log = [];
  29. /**
  30. * @var \Tools\Utility\Mime|null
  31. */
  32. protected $_Mime;
  33. /**
  34. * @param string|null $config
  35. */
  36. public function __construct($config = null) {
  37. if ($config === null) {
  38. $config = 'default';
  39. }
  40. parent::__construct($config);
  41. }
  42. /**
  43. * Sets wrap length.
  44. *
  45. * @param int $length Must not be more than CakeEmail::LINE_LENGTH_MUST
  46. * @return $this
  47. */
  48. public function setWrapLength($length) {
  49. $this->_wrapLength = $length;
  50. return $this;
  51. }
  52. /**
  53. * Gets wrap length.
  54. *
  55. * @return int
  56. */
  57. public function getWrapLength() {
  58. return $this->_wrapLength;
  59. }
  60. /**
  61. * Fix line length
  62. *
  63. * @override To wrap by must length by default.
  64. *
  65. * @param string $message Message to wrap
  66. * @param int $wrapLength
  67. * @return array Wrapped message
  68. */
  69. protected function _wrap($message, $wrapLength = CakeEmail::LINE_LENGTH_MUST) {
  70. if ($this->_wrapLength !== null) {
  71. $wrapLength = $this->_wrapLength;
  72. }
  73. return parent::_wrap($message, $wrapLength);
  74. }
  75. /**
  76. * @return $this
  77. */
  78. public function reset() {
  79. $this->_wrapLength = null;
  80. $this->_error = null;
  81. $this->_debug = null;
  82. return parent::reset();
  83. }
  84. /**
  85. * Ovewrite to allow custom enhancements
  86. *
  87. * @param array|string $config
  88. * @return $this
  89. */
  90. public function setProfile($config) {
  91. if (!is_array($config)) {
  92. $config = (string)$config;
  93. }
  94. //$this->_applyConfig($config);
  95. $fromEmail = Configure::read('Config.systemEmail');
  96. if ($fromEmail) {
  97. $fromName = Configure::read('Config.systemName');
  98. } else {
  99. $fromEmail = Configure::read('Config.adminEmail');
  100. $fromName = Configure::read('Config.adminName');
  101. }
  102. if ($fromEmail) {
  103. $this->setFrom($fromEmail, $fromName);
  104. }
  105. $xMailer = Configure::read('Config.xMailer');
  106. if ($xMailer) {
  107. $this->addHeaders(['X-Mailer' => $xMailer]);
  108. }
  109. return $this;
  110. }
  111. /**
  112. * Overwrite to allow mimetype detection
  113. *
  114. * @param string|array $attachments String with the filename or array with filenames
  115. * @return $this
  116. * @throws \InvalidArgumentException
  117. */
  118. public function setAttachments($attachments) {
  119. $attach = [];
  120. foreach ((array)$attachments as $name => $fileInfo) {
  121. if (!is_array($fileInfo)) {
  122. $fileInfo = ['file' => $fileInfo];
  123. }
  124. if (!isset($fileInfo['file'])) {
  125. if (!isset($fileInfo['data'])) {
  126. throw new InvalidArgumentException('No file or data specified.');
  127. }
  128. if (is_int($name)) {
  129. throw new InvalidArgumentException('No filename specified.');
  130. }
  131. } else {
  132. $fileName = $fileInfo['file'];
  133. if (!preg_match('~^https?://~i', $fileInfo['file'])) {
  134. $fileInfo['file'] = realpath($fileInfo['file']);
  135. }
  136. if ($fileInfo['file'] === false || !Utility::fileExists($fileInfo['file'])) {
  137. throw new InvalidArgumentException(sprintf('File not found: "%s"', $fileName));
  138. }
  139. if (is_int($name)) {
  140. $name = basename($fileInfo['file']);
  141. }
  142. }
  143. if (!isset($fileInfo['mimetype'])) {
  144. $ext = pathinfo($name, PATHINFO_EXTENSION);
  145. $fileInfo['mimetype'] = $this->_getMimeByExtension($ext);
  146. }
  147. $attach[$name] = $fileInfo;
  148. }
  149. $this->_attachments = $attach;
  150. return $this;
  151. }
  152. /**
  153. * Add an attachment from file
  154. *
  155. * @param string $file Absolute path
  156. * @param string|null $name
  157. * @param array $fileInfo
  158. * @return $this
  159. */
  160. public function addAttachment($file, $name = null, $fileInfo = []) {
  161. $fileInfo['file'] = $file;
  162. if (!empty($name)) {
  163. $fileInfo = [$name => $fileInfo];
  164. } else {
  165. $fileInfo = [$fileInfo];
  166. }
  167. $this->message->addAttachments($fileInfo);
  168. return $this;
  169. }
  170. /**
  171. * Add an attachment as blob
  172. *
  173. * @param string $content Blob data
  174. * @param string $filename to attach it
  175. * @param string|null $mimeType (leave it empty to get mimetype from $filename)
  176. * @param array $fileInfo
  177. * @return $this
  178. */
  179. public function addBlobAttachment($content, $filename, $mimeType = null, $fileInfo = []) {
  180. if ($mimeType === null) {
  181. $ext = pathinfo($filename, PATHINFO_EXTENSION);
  182. $mimeType = $this->_getMimeByExtension($ext);
  183. }
  184. $fileInfo['data'] = $content;
  185. $fileInfo['mimetype'] = $mimeType;
  186. $file = [$filename => $fileInfo];
  187. $this->message->addAttachments($file);
  188. return $this;
  189. }
  190. /**
  191. * Adds an inline attachment from file.
  192. *
  193. * Options:
  194. * - mimetype
  195. * - contentDisposition
  196. *
  197. * @param string $contentId
  198. * @param string $file
  199. * @param string|null $name
  200. * @param array $options
  201. * @return $this
  202. */
  203. public function addEmbeddedAttachmentByContentId($contentId, $file, $name = null, array $options = []) {
  204. if (empty($name)) {
  205. $name = basename($file);
  206. }
  207. $name = pathinfo($name, PATHINFO_FILENAME) . '_' . md5($file) . '.' . pathinfo($name, PATHINFO_EXTENSION);
  208. $options['file'] = $file;
  209. if (empty($options['mimetype'])) {
  210. $options['mimetype'] = $this->_getMime($file);
  211. }
  212. $options['contentId'] = $contentId;
  213. $file = [$name => $options];
  214. $this->message->addAttachments($file);
  215. return $this;
  216. }
  217. /**
  218. * Adds an inline attachment from file.
  219. *
  220. * Options:
  221. * - mimetype
  222. * - contentDisposition
  223. *
  224. * @param string $file Absolute path
  225. * @param string|null $name (optional)
  226. * @param array $options Options
  227. * @return string
  228. */
  229. public function addEmbeddedAttachment($file, $name = null, array $options = []) {
  230. if (empty($name)) {
  231. $name = basename($file);
  232. }
  233. $name = pathinfo($name, PATHINFO_FILENAME) . '_' . md5($file) . '.' . pathinfo($name, PATHINFO_EXTENSION);
  234. $cid = $this->_isEmbeddedAttachment($file, $name);
  235. if ($cid) {
  236. return $cid;
  237. }
  238. $options['file'] = $file;
  239. if (empty($options['mimetype'])) {
  240. $options['mimetype'] = $this->_getMime($file);
  241. }
  242. $options['contentId'] = str_replace('-', '', Text::uuid()) . '@' . $this->_domain;
  243. $file = [$name => $options];
  244. $this->message->addAttachments($file);
  245. return $options['contentId'];
  246. }
  247. /**
  248. * Adds an inline attachment from file.
  249. *
  250. * Options:
  251. * - mimetype
  252. * - contentDisposition
  253. *
  254. * @param string $contentId
  255. * @param string $content Blob data
  256. * @param string $file File File path to file
  257. * @param string|null $mimeType (leave it empty to get mimetype from $filename)
  258. * @param array $options
  259. * @return $this
  260. */
  261. public function addEmbeddedBlobAttachmentByContentId($contentId, $content, $file, $mimeType = null, array $options = []) {
  262. if ($mimeType === null) {
  263. $ext = pathinfo($file, PATHINFO_EXTENSION);
  264. $mimeType = $this->_getMimeByExtension($ext);
  265. }
  266. $filename = pathinfo($file, PATHINFO_FILENAME) . '_' . md5($content) . '.' . pathinfo($file, PATHINFO_EXTENSION);
  267. $options['data'] = $content;
  268. $options['mimetype'] = $mimeType;
  269. $options['contentId'] = $contentId;
  270. $file = [$filename => $options];
  271. $this->message->addAttachments($file);
  272. return $this;
  273. }
  274. /**
  275. * Add an inline attachment as blob
  276. *
  277. * Options:
  278. * - contentDisposition
  279. *
  280. * @param string $content Blob data
  281. * @param string $filename to attach it
  282. * @param string|null $mimeType (leave it empty to get mimetype from $filename)
  283. * @param array|string|null $options Options - string CID is deprecated
  284. * @param array $notUsed
  285. * @return string|null CID CcontentId (null is deprecated)
  286. */
  287. public function addEmbeddedBlobAttachment($content, $filename, $mimeType = null, $options = null, array $notUsed = []) {
  288. if ($mimeType === null) {
  289. $ext = pathinfo($filename, PATHINFO_EXTENSION);
  290. $mimeType = $this->_getMimeByExtension($ext);
  291. }
  292. $contentId = null;
  293. // Deprecated $contentId here
  294. if (!is_array($options)) {
  295. $contentId = $options;
  296. $options = $notUsed;
  297. }
  298. $filename = pathinfo($filename, PATHINFO_FILENAME) . '_' . md5($content) . '.' . pathinfo($filename, PATHINFO_EXTENSION);
  299. if ($contentId === null && ($cid = $this->_isEmbeddedBlobAttachment($content, $filename))) {
  300. return $cid;
  301. }
  302. $options['data'] = $content;
  303. $options['mimetype'] = $mimeType;
  304. $options['contentId'] = $contentId ? $contentId : str_replace('-', '', Text::uuid()) . '@' . $this->_domain;
  305. $file = [$filename => $options];
  306. $this->message->addAttachments($file);
  307. if ($contentId === null) {
  308. return $options['contentId'];
  309. }
  310. // Deprecated
  311. return $contentId;
  312. }
  313. /**
  314. * Returns if this particular file has already been attached as embedded file with this exact name
  315. * to prevent the same image to overwrite each other and also to only send this image once.
  316. * Allows multiple usage of the same embedded image (using the same cid)
  317. *
  318. * @param string $file
  319. * @param string $name
  320. * @return bool|string CID of the found file or false if no such attachment can be found
  321. */
  322. protected function _isEmbeddedAttachment($file, $name) {
  323. foreach ($this->_attachments as $filename => $fileInfo) {
  324. if ($filename !== $name) {
  325. continue;
  326. }
  327. return $fileInfo['contentId'];
  328. }
  329. return false;
  330. }
  331. /**
  332. * Returns if this particular file has already been attached as embedded file with this exact name
  333. * to prevent the same image to overwrite each other and also to only send this image once.
  334. * Allows multiple usage of the same embedded image (using the same cid)
  335. *
  336. * @param string $content
  337. * @param string $name
  338. * @return bool|string CID of the found file or false if no such attachment can be found
  339. */
  340. protected function _isEmbeddedBlobAttachment($content, $name) {
  341. foreach ($this->_attachments as $filename => $fileInfo) {
  342. if ($filename !== $name) {
  343. continue;
  344. }
  345. return $fileInfo['contentId'];
  346. }
  347. return false;
  348. }
  349. /**
  350. * @param string $ext
  351. * @param string $default
  352. * @return mixed
  353. */
  354. protected function _getMimeByExtension($ext, $default = 'application/octet-stream') {
  355. if (!isset($this->_Mime)) {
  356. $this->_Mime = new Mime();
  357. }
  358. $mime = $this->_Mime->getMimeTypeByAlias($ext);
  359. if (!$mime) {
  360. $mime = $default;
  361. }
  362. return $mime;
  363. }
  364. /**
  365. * Try to find mimetype by file extension
  366. *
  367. * @param string $filename File name
  368. * @param string $default default MimeType
  369. * @return string Mimetype (falls back to `application/octet-stream`)
  370. */
  371. protected function _getMime($filename, $default = 'application/octet-stream') {
  372. if (!isset($this->_Mime)) {
  373. $this->_Mime = new Mime();
  374. }
  375. $mime = $this->_Mime->detectMimeType($filename);
  376. // Some environments falsely return the default too fast, better fallback to extension here
  377. if (!$mime || $mime === $default) {
  378. $ext = pathinfo($filename, PATHINFO_EXTENSION);
  379. $mime = $this->_Mime->getMimeTypeByAlias($ext);
  380. }
  381. return $mime;
  382. }
  383. /**
  384. * Read the file contents and return a base64 version of the file contents.
  385. * Overwrite parent to avoid File class and file_exists to false negative existent
  386. * remove images.
  387. * Also fixes file_get_contents (used via File class) to close the connection again
  388. * after getting remote files. So far it would have kept the connection open in HTTP/1.1.
  389. *
  390. * @param string $path The absolute path to the file to read.
  391. * @return string File contents in base64 encoding
  392. */
  393. protected function _readFile($path) {
  394. $context = stream_context_create(
  395. ['http' => ['header' => 'Connection: close']]);
  396. $content = file_get_contents($path, 0, $context);
  397. if (!$content) {
  398. trigger_error('No content found for ' . $path);
  399. }
  400. return chunk_split(base64_encode($content));
  401. }
  402. /**
  403. * Validate if the email has the required fields necessary to make send() work.
  404. * Assumes layouting (does not check on content to be present or if view/layout files are missing).
  405. *
  406. * @return bool Success
  407. */
  408. public function validates() {
  409. if (!empty($this->_subject) && !empty($this->_to)) {
  410. return true;
  411. }
  412. return false;
  413. }
  414. /**
  415. * Set the body of the mail as we send it.
  416. * Note: the text can be an array, each element will appear as a seperate line in the message body.
  417. *
  418. * Do NOT pass a message if you use $this->set() in combination with templates
  419. *
  420. * @param string|array|null $message Message
  421. * @return bool Success
  422. */
  423. public function sendEmail($message = null): bool {
  424. $this->_log = [
  425. 'to' => $this->_to,
  426. 'from' => $this->_from,
  427. 'sender' => $this->_sender,
  428. 'replyTo' => $this->_replyTo,
  429. 'cc' => $this->_cc,
  430. 'subject' => $this->_subject,
  431. 'bcc' => $this->_bcc,
  432. 'transport' => $this->_transport ? get_class($this->_transport) : null,
  433. ];
  434. // if not live, just log but do not send any mails //TODO: remove and use Debug Transport!
  435. if (!Configure::read('Config.live')) {
  436. $this->_logEmail();
  437. return true;
  438. }
  439. // Security measure to not sent to the actual addressee in debug mode while email sending is live
  440. if (Configure::read('debug') && Configure::read('Config.live')) {
  441. $adminEmail = Configure::read('Config.adminEmail');
  442. if (!$adminEmail) {
  443. $adminEmail = Configure::read('Config.systemEmail');
  444. }
  445. foreach ($this->_to as $k => $v) {
  446. if ($k === $adminEmail) {
  447. continue;
  448. }
  449. unset($this->_to[$k]);
  450. $this->_to[$adminEmail] = $v;
  451. }
  452. foreach ($this->_cc as $k => $v) {
  453. if ($k === $adminEmail) {
  454. continue;
  455. }
  456. unset($this->_cc[$k]);
  457. $this->_cc[$adminEmail] = $v;
  458. }
  459. foreach ($this->_bcc as $k => $v) {
  460. if ($k === $adminEmail) {
  461. continue;
  462. }
  463. unset($this->_bcc[$k]);
  464. $this->_bcc[] = $v;
  465. }
  466. }
  467. try {
  468. $this->_debug = parent::send($message);
  469. } catch (\Exception $e) {
  470. $this->_error = $e->getMessage();
  471. $this->_error .= ' (line ' . $e->getLine() . ' in ' . $e->getFile() . ')' . PHP_EOL .
  472. $e->getTraceAsString();
  473. // always log report
  474. $this->_logEmail(LogLevel::ERROR);
  475. // log error
  476. $this->log($this->_error, LogLevel::ERROR);
  477. return false;
  478. }
  479. if (!empty($this->_profile['logReport'])) {
  480. $this->_logEmail();
  481. }
  482. return true;
  483. }
  484. /**
  485. * @param string $level
  486. * @return void
  487. */
  488. protected function _logEmail($level = LogLevel::INFO) {
  489. $content =
  490. $this->_log['transport'] . (!Configure::read('Config.live') ? ' (simulated)' : '')
  491. . ' - ' . 'TO:' . implode(',', array_keys($this->_log['to']))
  492. . '||FROM:' . implode(',', array_keys($this->_log['from']))
  493. . '||REPLY:' . implode(',', array_keys($this->_log['replyTo']))
  494. . '||S:' . $this->_log['subject'];
  495. $this->log($content, $level);
  496. }
  497. /**
  498. * Attach inline/embedded files to the message.
  499. *
  500. * CUSTOM FIX: blob data support
  501. *
  502. * @override
  503. * @param string|null $boundary Boundary to use. If null, will default to $this->_boundary
  504. * @return array An array of lines to add to the message
  505. */
  506. protected function _attachInlineFiles($boundary = null) {
  507. if ($boundary === null) {
  508. $boundary = $this->_boundary;
  509. }
  510. $msg = [];
  511. foreach ($this->_attachments as $filename => $fileInfo) {
  512. if (empty($fileInfo['contentId'])) {
  513. continue;
  514. }
  515. if (!empty($fileInfo['data'])) {
  516. $data = $fileInfo['data'];
  517. $data = chunk_split(base64_encode($data));
  518. } elseif (!empty($fileInfo['file'])) {
  519. $data = $this->_readFile($fileInfo['file']);
  520. } else {
  521. continue;
  522. }
  523. $msg[] = '--' . $boundary;
  524. $msg[] = 'Content-Type: ' . $fileInfo['mimetype'];
  525. $msg[] = 'Content-Transfer-Encoding: base64';
  526. $msg[] = 'Content-ID: <' . $fileInfo['contentId'] . '>';
  527. $msg[] = 'Content-Disposition: inline; filename="' . $filename . '"';
  528. $msg[] = '';
  529. $msg[] = $data;
  530. $msg[] = '';
  531. }
  532. return $msg;
  533. }
  534. /**
  535. * Attach non-embedded files by adding file contents inside boundaries.
  536. *
  537. * CUSTOM FIX: blob data support
  538. *
  539. * @override
  540. * @param string|null $boundary Boundary to use. If null, will default to $this->_boundary
  541. * @return array An array of lines to add to the message
  542. */
  543. protected function _attachFiles($boundary = null) {
  544. if ($boundary === null) {
  545. $boundary = $this->_boundary;
  546. }
  547. $msg = [];
  548. foreach ($this->_attachments as $filename => $fileInfo) {
  549. if (!empty($fileInfo['contentId'])) {
  550. continue;
  551. }
  552. if (!empty($fileInfo['data'])) {
  553. $data = $fileInfo['data'];
  554. $data = chunk_split(base64_encode($data));
  555. } elseif (!empty($fileInfo['file'])) {
  556. $data = $this->_readFile($fileInfo['file']);
  557. } else {
  558. continue;
  559. }
  560. $msg[] = '--' . $boundary;
  561. $msg[] = 'Content-Type: ' . $fileInfo['mimetype'];
  562. $msg[] = 'Content-Transfer-Encoding: base64';
  563. if (
  564. !isset($fileInfo['contentDisposition']) ||
  565. $fileInfo['contentDisposition']
  566. ) {
  567. $msg[] = 'Content-Disposition: attachment; filename="' . $filename . '"';
  568. }
  569. $msg[] = '';
  570. $msg[] = $data;
  571. $msg[] = '';
  572. }
  573. return $msg;
  574. }
  575. /**
  576. * Returns the error if existent
  577. *
  578. * @return string
  579. */
  580. public function getError() {
  581. return $this->_error;
  582. }
  583. }