]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Core/Mailing.php
5e8fedde8feb8f8fe5a1e7488fab04fcfdd5d4c6
[galette.git] / galette / lib / Galette / Core / Mailing.php
1 <?php
2
3 /**
4 * Copyright © 2003-2024 The Galette Team
5 *
6 * This file is part of Galette (https://galette.eu).
7 *
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
20 */
21
22 namespace Galette\Core;
23
24 use Analog\Analog;
25 use ArrayObject;
26 use Galette\Entity\Adherent;
27 use Galette\IO\File;
28 use Laminas\Db\ResultSet\ResultSet;
29
30 /**
31 * Mailing features
32 *
33 * @author Johan Cwiklinski <johan@x-tnd.be>
34 *
35 * @property string $subject
36 * @property string $message
37 * @property boolean $html
38 * @property integer $current_step
39 * @property-read integer $step
40 * @property integer|string $id
41 * @property-read string $alt_message
42 * @property-read string $wrapped_message
43 * @property-read PHPMailer\PHPMailer\PHPMailer $mail
44 * @property-read PHPMailer\PHPMailer\PHPMailer $_mail
45 * @property-read array $errors
46 * @property-read array $recipients
47 * @property-read array $unreachables
48 * @property-read string|false $tmp_path
49 * @property array $attachments
50 * @property-read string $sender_name
51 * @property-read string $sender_address
52 * @property integer $history_id
53 */
54 class Mailing extends GaletteMail
55 {
56 public const STEP_START = 0;
57 public const STEP_PREVIEW = 1;
58 public const STEP_SEND = 2;
59 public const STEP_SENT = 3;
60
61 public const MIME_HTML = 'text/html';
62 public const MIME_TEXT = 'text/plain';
63 public const MIME_DEFAULT = self::MIME_TEXT;
64
65 private string|int $id;
66
67 /** @var array<int, Adherent> */
68 private array $unreachables = array();
69 /** @var array<int, Adherent> */
70 private array $mrecipients = array();
71 private int $current_step;
72
73 private string $mime_type;
74
75 private ?string $tmp_path;
76 private int $history_id;
77
78 /**
79 * Default constructor
80 *
81 * @param Preferences $preferences Preferences instance
82 * @param array<int, Adherent> $members An array of members
83 * @param ?integer $id Identifier, defaults to null
84 */
85 public function __construct(Preferences $preferences, array $members = [], int $id = null)
86 {
87 parent::__construct($preferences);
88 $this->id = $id ?? $this->generateNewId();
89
90 $this->current_step = self::STEP_START;
91 $this->mime_type = self::MIME_DEFAULT;
92 /** TODO: add a preference that propose default mime-type to use,
93 then init it here */
94 if (count($members)) {
95 //Check which members have a valid email address and which have not
96 $this->setRecipients($members);
97 }
98 $this->loadAttachments();
99 }
100
101 /**
102 * Generate new mailing id and temporary path
103 *
104 * @return string
105 */
106 private function generateNewId(): string
107 {
108 $id = '';
109 $chars = 'abcdefghjkmnpqrstuvwxyz0123456789';
110 $i = 0;
111 $size = 30;
112 while ($i <= $size - 1) {
113 $num = mt_rand(0, strlen($chars) - 1) % strlen($chars);
114 $id .= substr($chars, $num, 1);
115 $i++;
116 }
117
118 $this->id = $id;
119 $this->generateTmpPath($this->id);
120 return $this->id;
121 }
122
123 /**
124 * Generate temporary path
125 *
126 * @param ?string $id Random id, defaults to null
127 *
128 * @return void
129 */
130 private function generateTmpPath(string $id = null): void
131 {
132 if ($id === null) {
133 $id = $this->generateNewId();
134 }
135 $this->tmp_path = GALETTE_ATTACHMENTS_PATH . '/' . $id;
136 }
137
138 /**
139 * Load mailing attachments
140 *
141 * @return void
142 */
143 private function loadAttachments(): void
144 {
145 $dir = '';
146 if (
147 isset($this->tmp_path)
148 && trim($this->tmp_path) !== ''
149 ) {
150 $dir = $this->tmp_path;
151 } else {
152 $dir = GALETTE_ATTACHMENTS_PATH . $this->id . '/';
153 }
154
155 $files = glob($dir . '*.*');
156 foreach ($files as $file) {
157 $f = new File($dir);
158 $f->setFileName(str_replace($dir, '', $file));
159 $this->attachments[] = $f;
160 }
161 }
162
163 /**
164 * Loads a mailing from history
165 *
166 * @param ArrayObject<string, mixed> $rs Mailing entry
167 * @param boolean $new True if we create a 'new' mailing,
168 * false otherwise (from preview for example)
169 *
170 * @return boolean
171 */
172 public function loadFromHistory(ArrayObject $rs, bool $new = true): bool
173 {
174 global $zdb;
175
176 try {
177 if (Galette::isSerialized($rs->mailing_recipients)) {
178 $orig_recipients = unserialize($rs->mailing_recipients);
179 } else {
180 $orig_recipients = Galette::jsonDecode($rs->mailing_recipients);
181 }
182 } catch (\Throwable $e) {
183 Analog::log(
184 'Unable to retrieve recipients for mailing ' . $rs->mailing_id,
185 Analog::ERROR
186 );
187 $orig_recipients = [];
188 }
189
190 $_recipients = array();
191 $mdeps = ['parent' => true];
192 foreach ($orig_recipients as $k => $v) {
193 $m = new Adherent($zdb, $k, $mdeps);
194 $_recipients[] = $m;
195 }
196 $this->setRecipients($_recipients);
197 $this->subject = $rs->mailing_subject;
198 $this->message = $rs->mailing_body;
199 $this->html = $this->message != strip_tags($this->message) ? true : false;
200 if ($rs->mailing_sender_name !== null || $rs->mailing_sender_address !== null) {
201 $this->setSender(
202 $rs->mailing_sender_name,
203 $rs->mailing_sender_address
204 );
205 }
206 //if mailing has already been sent, generate a new id and copy attachments
207 if ($rs->mailing_sent && $new) {
208 $this->generateNewId();
209 $this->copyAttachments($rs->mailing_id);
210 } else {
211 $this->tmp_path = null;
212 $this->id = $rs->mailing_id;
213 if (!$this->attachments) {
214 $this->loadAttachments();
215 }
216 $this->history_id = $rs->mailing_id;
217 }
218 return true;
219 }
220
221 /**
222 * Copy attachments from another mailing
223 *
224 * @param int $id Original mailing id
225 *
226 * @return void
227 */
228 private function copyAttachments(int $id): void
229 {
230 $source_dir = GALETTE_ATTACHMENTS_PATH . $id . '/';
231 $dest_dir = GALETTE_ATTACHMENTS_PATH . $this->id . '/';
232
233 if (file_exists($source_dir)) {
234 if (file_exists($dest_dir)) {
235 throw new \RuntimeException(
236 str_replace(
237 '%s',
238 $this->id,
239 'Attachments directory already exists for mailing %s!'
240 )
241 );
242 } else {
243 //create directory
244 mkdir($dest_dir);
245 //copy attachments from source mailing and populate attachments
246 $this->attachments = array();
247 $files = glob($source_dir . '*.*');
248 foreach ($files as $file) {
249 $f = new File($source_dir);
250 $f->setFileName(str_replace($source_dir, '', $file));
251 $f->copyTo($dest_dir);
252 $this->attachments[] = $f;
253 }
254 }
255 } else {
256 Analog::log(
257 'No attachments in source directory',
258 Analog::DEBUG
259 );
260 }
261 }
262
263 /**
264 * Apply final header to email and send it :-)
265 *
266 * @return int
267 */
268 public function send(): int
269 {
270 $m = array();
271 foreach ($this->mrecipients as $member) {
272 $email = $member->getEmail();
273 $m[$email] = $member->sname;
274 }
275 parent::setRecipients($m);
276 return parent::send();
277 }
278
279 /**
280 * Set mailing recipients
281 *
282 * @phpstan-ignore-next-line
283 * @param array<int, Adherent> $members Array of Adherent objects
284 *
285 * @return bool
286 */
287 public function setRecipients(array $members): bool
288 {
289 $m = array();
290 $this->mrecipients = array();
291 $this->unreachables = array();
292
293 foreach ($members as $member) {
294 $email = $member->getEmail();
295
296 if (trim($email) != '' && self::isValidEmail($email)) {
297 if (!in_array($member, $this->mrecipients)) {
298 $this->mrecipients[] = $member;
299 }
300 $m[$email] = $member->sname;
301 } else {
302 if (!in_array($member, $this->unreachables)) {
303 $this->unreachables[] = $member;
304 }
305 }
306 }
307 return parent::setRecipients($m);
308 }
309
310 /**
311 * Store maling attachments
312 *
313 * @param array<string, string|int> $files Array of uploaded files to store
314 *
315 * @return true|int error code
316 */
317 public function store(array $files): bool|int
318 {
319 if ($this->tmp_path === null) {
320 $this->generateTmpPath();
321 }
322
323 if (!file_exists($this->tmp_path)) {
324 //directory does not exist, create it
325 mkdir($this->tmp_path);
326 }
327
328 if (!is_dir($this->tmp_path)) {
329 throw new \RuntimeException(
330 $this->tmp_path . ' should be a directory!'
331 );
332 }
333
334 //store files
335 $attachment = new File($this->tmp_path);
336 $res = $attachment->store($files);
337 if ($res < 0) {
338 return $res;
339 } else {
340 $this->attachments[] = $attachment;
341 }
342
343 return true;
344 }
345
346 /**
347 * Move attachments with final id once mailing has been stored
348 *
349 * @param int $id Mailing history id
350 *
351 * @return void
352 */
353 public function moveAttachments(int $id): void
354 {
355 if (
356 isset($this->tmp_path)
357 && trim($this->tmp_path) !== ''
358 && count($this->attachments) > 0
359 ) {
360 foreach ($this->attachments as &$attachment) {
361 $old_path = $attachment->getDestDir() . $attachment->getFileName();
362 $new_path = GALETTE_ATTACHMENTS_PATH . $id . '/' .
363 $attachment->getFileName();
364 if (!file_exists(GALETTE_ATTACHMENTS_PATH . $id)) {
365 mkdir(GALETTE_ATTACHMENTS_PATH . $id);
366 }
367 $moved = rename($old_path, $new_path);
368 if ($moved) {
369 $attachment->setDestDir(GALETTE_ATTACHMENTS_PATH);
370 }
371 }
372 rmdir($this->tmp_path);
373 $this->tmp_path = null;
374 }
375 }
376
377 /**
378 * Remove specified attachment
379 *
380 * @param string $name Filename
381 *
382 * @return void
383 */
384 public function removeAttachment(string $name): void
385 {
386 $to_remove = null;
387 if (
388 isset($this->tmp_path)
389 && trim($this->tmp_path) !== ''
390 && file_exists($this->tmp_path)
391 ) {
392 $to_remove = $this->tmp_path;
393 } elseif (file_exists(GALETTE_ATTACHMENTS_PATH . $this->id)) {
394 $to_remove = GALETTE_ATTACHMENTS_PATH . $this->id;
395 }
396
397 if ($to_remove !== null) {
398 $to_remove .= '/' . $name;
399
400 if (!$this->attachments) {
401 $this->loadAttachments();
402 }
403
404 if (file_exists($to_remove)) {
405 $i = 0;
406 foreach ($this->attachments as $att) {
407 if ($att->getFileName() == $name) {
408 unset($this->attachments[$i]);
409 unlink($to_remove);
410 break;
411 }
412 $i++;
413 }
414 } else {
415 Analog::log(
416 str_replace(
417 '%file',
418 $name,
419 'File %file does not exists and cannot be removed!'
420 ),
421 Analog::WARNING
422 );
423 }
424 } else {
425 throw new \RuntimeException(
426 'Unable to get attachments path!'
427 );
428 }
429 }
430
431 /**
432 * Remove mailing attachments
433 *
434 * @param boolean $temp Remove only temporary attachments,
435 * to avoid history breaking
436 *
437 * @return boolean
438 */
439 public function removeAttachments(bool $temp = false): bool
440 {
441 $to_remove = null;
442 if (
443 isset($this->tmp_path)
444 && trim($this->tmp_path) !== ''
445 && file_exists($this->tmp_path)
446 ) {
447 $to_remove = $this->tmp_path;
448 } elseif (file_exists(GALETTE_ATTACHMENTS_PATH . $this->id)) {
449 if ($temp === true) {
450 return false;
451 }
452 $to_remove = GALETTE_ATTACHMENTS_PATH . $this->id;
453 }
454
455 if ($to_remove !== null) {
456 $rdi = new \RecursiveDirectoryIterator(
457 $to_remove,
458 \FilesystemIterator::SKIP_DOTS
459 );
460 $contents = new \RecursiveIteratorIterator(
461 $rdi,
462 \RecursiveIteratorIterator::CHILD_FIRST
463 );
464 foreach ($contents as $path) {
465 if ($path->isFile()) {
466 unlink($path->getPathname());
467 } else {
468 rmdir($path->getPathname());
469 }
470 }
471 rmdir($to_remove);
472 }
473 return true;
474 }
475
476 /**
477 * Return textual error message
478 *
479 * @param int $code The error code
480 *
481 * @return string Localized message
482 */
483 public function getAttachmentErrorMessage(int $code): string
484 {
485 $f = new File($this->tmp_path);
486 return $f->getErrorMessage($code);
487 }
488
489 /**
490 * Does mailing already exists in history?
491 *
492 * @return boolean
493 */
494 public function existsInHistory(): bool
495 {
496 return isset($this->history_id);
497 }
498
499 /**
500 * Global getter method
501 *
502 * @param string $name name of the property we want to retrieve
503 *
504 * @return mixed the called property
505 */
506 public function __get(string $name)
507 {
508 $forbidden = array('ordered');
509 if (!in_array($name, $forbidden)) {
510 switch ($name) {
511 case 'alt_message':
512 return $this->cleanedHtml();
513 case 'step':
514 return $this->current_step;
515 case 'subject':
516 return $this->getSubject();
517 case 'message':
518 return $this->getMessage();
519 case 'wrapped_message':
520 return $this->getWrappedMessage();
521 case 'html':
522 return $this->isHTML();
523 case 'mail':
524 case '_mail':
525 return $this->getPhpMailer();
526 case 'errors':
527 return $this->getErrors();
528 case 'recipients':
529 return $this->mrecipients;
530 case 'tmp_path':
531 if (isset($this->tmp_path) && trim($this->tmp_path) !== '') {
532 return $this->tmp_path;
533 } else {
534 //no attachments
535 return false;
536 }
537 case 'attachments':
538 return $this->attachments;
539 case 'sender_name':
540 return $this->getSenderName();
541 case 'sender_address':
542 return $this->getSenderAddress();
543 case 'history_id':
544 return $this->$name;
545 default:
546 Analog::log(
547 '[' . get_class($this) . 'Trying to get ' . $name,
548 Analog::DEBUG
549 );
550 return $this->$name;
551 }
552 } else {
553 Analog::log(
554 '[' . get_class($this) . 'Unable to get ' . $name,
555 Analog::ERROR
556 );
557 return false;
558 }
559 }
560
561 /**
562 * Global isset method
563 * Required for twig to access properties via __get
564 *
565 * @param string $name name of the property we want to retrieve
566 *
567 * @return bool
568 */
569 public function __isset(string $name): bool
570 {
571 $forbidden = array('ordered');
572 if (!in_array($name, $forbidden)) {
573 switch ($name) {
574 case 'alt_message':
575 case 'step':
576 case 'subject':
577 case 'message':
578 case 'wrapped_message':
579 case 'html':
580 case 'mail':
581 case '_mail':
582 case 'errors':
583 case 'recipients':
584 case 'tmp_path':
585 case 'attachments':
586 case 'sender_name':
587 case 'sender_address':
588 return true;
589 }
590 return isset($this->$name);
591 }
592
593 return false;
594 }
595
596 /**
597 * Global setter method
598 *
599 * @param string $name name of the property we want to assign a value to
600 * @param mixed $value a relevant value for the property
601 *
602 * @return void
603 */
604 public function __set(string $name, $value): void
605 {
606 switch ($name) {
607 case 'subject':
608 $this->setSubject($value);
609 break;
610 case 'message':
611 $this->setMessage($value);
612 break;
613 case 'html':
614 if (is_bool($value)) {
615 $this->isHTML($value);
616 } else {
617 Analog::log(
618 '[' . get_class($this) . '] Value for field `' . $name .
619 '` should be boolean - (' . gettype($value) . ')' .
620 $value . ' given',
621 Analog::WARNING
622 );
623 }
624 break;
625 case 'current_step':
626 if (
627 is_int($value)
628 && ($value == self::STEP_START
629 || $value == self::STEP_PREVIEW
630 || $value == self::STEP_SEND
631 || $value == self::STEP_SENT)
632 ) {
633 $this->current_step = (int)$value;
634 } else {
635 Analog::log(
636 '[' . get_class($this) . '] Value for field `' . $name .
637 '` should be integer and know - (' . gettype($value) . ')' .
638 $value . ' given',
639 Analog::WARNING
640 );
641 }
642 break;
643 case 'id':
644 $this->id = $value;
645 break;
646 default:
647 Analog::log(
648 '[' . get_class($this) . '] Unable to set property `' . $name . '`',
649 Analog::WARNING
650 );
651 }
652 }
653 }