Email.php 16 KB

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