]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Core/Mailing.php
aa80ba1d4df2c783ae03f75e8c5992cde67fa58b
[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 if ($rs->mailing_sender_name !== null || $rs->mailing_sender_address !== null) {
200 $this->setSender(
201 $rs->mailing_sender_name,
202 $rs->mailing_sender_address
203 );
204 }
205 //if mailing has already been sent, generate a new id and copy attachments
206 if ($rs->mailing_sent && $new) {
207 $this->generateNewId();
208 $this->copyAttachments($rs->mailing_id);
209 } else {
210 $this->tmp_path = null;
211 $this->id = $rs->mailing_id;
212 if (!$this->attachments) {
213 $this->loadAttachments();
214 }
215 $this->history_id = $rs->mailing_id;
216 }
217 return true;
218 }
219
220 /**
221 * Copy attachments from another mailing
222 *
223 * @param int $id Original mailing id
224 *
225 * @return void
226 */
227 private function copyAttachments(int $id): void
228 {
229 $source_dir = GALETTE_ATTACHMENTS_PATH . $id . '/';
230 $dest_dir = GALETTE_ATTACHMENTS_PATH . $this->id . '/';
231
232 if (file_exists($source_dir)) {
233 if (file_exists($dest_dir)) {
234 throw new \RuntimeException(
235 str_replace(
236 '%s',
237 $this->id,
238 'Attachments directory already exists for mailing %s!'
239 )
240 );
241 } else {
242 //create directory
243 mkdir($dest_dir);
244 //copy attachments from source mailing and populate attachments
245 $this->attachments = array();
246 $files = glob($source_dir . '*.*');
247 foreach ($files as $file) {
248 $f = new File($source_dir);
249 $f->setFileName(str_replace($source_dir, '', $file));
250 $f->copyTo($dest_dir);
251 $this->attachments[] = $f;
252 }
253 }
254 } else {
255 Analog::log(
256 'No attachments in source directory',
257 Analog::DEBUG
258 );
259 }
260 }
261
262 /**
263 * Apply final header to email and send it :-)
264 *
265 * @return int
266 */
267 public function send(): int
268 {
269 $m = array();
270 foreach ($this->mrecipients as $member) {
271 $email = $member->getEmail();
272 $m[$email] = $member->sname;
273 }
274 parent::setRecipients($m);
275 return parent::send();
276 }
277
278 /**
279 * Set mailing recipients
280 *
281 * @phpstan-ignore-next-line
282 * @param array<int, Adherent> $members Array of Adherent objects
283 *
284 * @return bool
285 */
286 public function setRecipients(array $members): bool
287 {
288 $m = array();
289 $this->mrecipients = array();
290 $this->unreachables = array();
291
292 foreach ($members as $member) {
293 $email = $member->getEmail();
294
295 if (trim($email) != '' && self::isValidEmail($email)) {
296 if (!in_array($member, $this->mrecipients)) {
297 $this->mrecipients[] = $member;
298 }
299 $m[$email] = $member->sname;
300 } else {
301 if (!in_array($member, $this->unreachables)) {
302 $this->unreachables[] = $member;
303 }
304 }
305 }
306 return parent::setRecipients($m);
307 }
308
309 /**
310 * Store maling attachments
311 *
312 * @param array<string, string|int> $files Array of uploaded files to store
313 *
314 * @return true|int error code
315 */
316 public function store(array $files): bool|int
317 {
318 if ($this->tmp_path === null) {
319 $this->generateTmpPath();
320 }
321
322 if (!file_exists($this->tmp_path)) {
323 //directory does not exist, create it
324 mkdir($this->tmp_path);
325 }
326
327 if (!is_dir($this->tmp_path)) {
328 throw new \RuntimeException(
329 $this->tmp_path . ' should be a directory!'
330 );
331 }
332
333 //store files
334 $attachment = new File($this->tmp_path);
335 $res = $attachment->store($files);
336 if ($res < 0) {
337 return $res;
338 } else {
339 $this->attachments[] = $attachment;
340 }
341
342 return true;
343 }
344
345 /**
346 * Move attachments with final id once mailing has been stored
347 *
348 * @param int $id Mailing history id
349 *
350 * @return void
351 */
352 public function moveAttachments(int $id): void
353 {
354 if (
355 isset($this->tmp_path)
356 && trim($this->tmp_path) !== ''
357 && count($this->attachments) > 0
358 ) {
359 foreach ($this->attachments as &$attachment) {
360 $old_path = $attachment->getDestDir() . $attachment->getFileName();
361 $new_path = GALETTE_ATTACHMENTS_PATH . $id . '/' .
362 $attachment->getFileName();
363 if (!file_exists(GALETTE_ATTACHMENTS_PATH . $id)) {
364 mkdir(GALETTE_ATTACHMENTS_PATH . $id);
365 }
366 $moved = rename($old_path, $new_path);
367 if ($moved) {
368 $attachment->setDestDir(GALETTE_ATTACHMENTS_PATH);
369 }
370 }
371 rmdir($this->tmp_path);
372 $this->tmp_path = null;
373 }
374 }
375
376 /**
377 * Remove specified attachment
378 *
379 * @param string $name Filename
380 *
381 * @return void
382 */
383 public function removeAttachment(string $name): void
384 {
385 $to_remove = null;
386 if (
387 isset($this->tmp_path)
388 && trim($this->tmp_path) !== ''
389 && file_exists($this->tmp_path)
390 ) {
391 $to_remove = $this->tmp_path;
392 } elseif (file_exists(GALETTE_ATTACHMENTS_PATH . $this->id)) {
393 $to_remove = GALETTE_ATTACHMENTS_PATH . $this->id;
394 }
395
396 if ($to_remove !== null) {
397 $to_remove .= '/' . $name;
398
399 if (!$this->attachments) {
400 $this->loadAttachments();
401 }
402
403 if (file_exists($to_remove)) {
404 $i = 0;
405 foreach ($this->attachments as $att) {
406 if ($att->getFileName() == $name) {
407 unset($this->attachments[$i]);
408 unlink($to_remove);
409 break;
410 }
411 $i++;
412 }
413 } else {
414 Analog::log(
415 str_replace(
416 '%file',
417 $name,
418 'File %file does not exists and cannot be removed!'
419 ),
420 Analog::WARNING
421 );
422 }
423 } else {
424 throw new \RuntimeException(
425 'Unable to get attachments path!'
426 );
427 }
428 }
429
430 /**
431 * Remove mailing attachments
432 *
433 * @param boolean $temp Remove only temporary attachments,
434 * to avoid history breaking
435 *
436 * @return boolean
437 */
438 public function removeAttachments(bool $temp = false): bool
439 {
440 $to_remove = null;
441 if (
442 isset($this->tmp_path)
443 && trim($this->tmp_path) !== ''
444 && file_exists($this->tmp_path)
445 ) {
446 $to_remove = $this->tmp_path;
447 } elseif (file_exists(GALETTE_ATTACHMENTS_PATH . $this->id)) {
448 if ($temp === true) {
449 return false;
450 }
451 $to_remove = GALETTE_ATTACHMENTS_PATH . $this->id;
452 }
453
454 if ($to_remove !== null) {
455 $rdi = new \RecursiveDirectoryIterator(
456 $to_remove,
457 \FilesystemIterator::SKIP_DOTS
458 );
459 $contents = new \RecursiveIteratorIterator(
460 $rdi,
461 \RecursiveIteratorIterator::CHILD_FIRST
462 );
463 foreach ($contents as $path) {
464 if ($path->isFile()) {
465 unlink($path->getPathname());
466 } else {
467 rmdir($path->getPathname());
468 }
469 }
470 rmdir($to_remove);
471 }
472 return true;
473 }
474
475 /**
476 * Return textual error message
477 *
478 * @param int $code The error code
479 *
480 * @return string Localized message
481 */
482 public function getAttachmentErrorMessage(int $code): string
483 {
484 $f = new File($this->tmp_path);
485 return $f->getErrorMessage($code);
486 }
487
488 /**
489 * Does mailing already exists in history?
490 *
491 * @return boolean
492 */
493 public function existsInHistory(): bool
494 {
495 return isset($this->history_id);
496 }
497
498 /**
499 * Global getter method
500 *
501 * @param string $name name of the property we want to retrieve
502 *
503 * @return mixed the called property
504 */
505 public function __get(string $name)
506 {
507 $forbidden = array('ordered');
508 if (!in_array($name, $forbidden)) {
509 switch ($name) {
510 case 'alt_message':
511 return $this->cleanedHtml();
512 case 'step':
513 return $this->current_step;
514 case 'subject':
515 return $this->getSubject();
516 case 'message':
517 return $this->getMessage();
518 case 'wrapped_message':
519 return $this->getWrappedMessage();
520 case 'html':
521 return $this->isHTML();
522 case 'mail':
523 case '_mail':
524 return $this->getPhpMailer();
525 case 'errors':
526 return $this->getErrors();
527 case 'recipients':
528 return $this->mrecipients;
529 case 'tmp_path':
530 if (isset($this->tmp_path) && trim($this->tmp_path) !== '') {
531 return $this->tmp_path;
532 } else {
533 //no attachments
534 return false;
535 }
536 case 'attachments':
537 return $this->attachments;
538 case 'sender_name':
539 return $this->getSenderName();
540 case 'sender_address':
541 return $this->getSenderAddress();
542 case 'history_id':
543 return $this->$name;
544 default:
545 Analog::log(
546 '[' . get_class($this) . 'Trying to get ' . $name,
547 Analog::DEBUG
548 );
549 return $this->$name;
550 }
551 } else {
552 Analog::log(
553 '[' . get_class($this) . 'Unable to get ' . $name,
554 Analog::ERROR
555 );
556 return false;
557 }
558 }
559
560 /**
561 * Global isset method
562 * Required for twig to access properties via __get
563 *
564 * @param string $name name of the property we want to retrieve
565 *
566 * @return bool
567 */
568 public function __isset(string $name): bool
569 {
570 $forbidden = array('ordered');
571 if (!in_array($name, $forbidden)) {
572 switch ($name) {
573 case 'alt_message':
574 case 'step':
575 case 'subject':
576 case 'message':
577 case 'wrapped_message':
578 case 'html':
579 case 'mail':
580 case '_mail':
581 case 'errors':
582 case 'recipients':
583 case 'tmp_path':
584 case 'attachments':
585 case 'sender_name':
586 case 'sender_address':
587 return true;
588 }
589 return isset($this->$name);
590 }
591
592 return false;
593 }
594
595 /**
596 * Global setter method
597 *
598 * @param string $name name of the property we want to assign a value to
599 * @param mixed $value a relevant value for the property
600 *
601 * @return void
602 */
603 public function __set(string $name, $value): void
604 {
605 switch ($name) {
606 case 'subject':
607 $this->setSubject($value);
608 break;
609 case 'message':
610 $this->setMessage($value);
611 break;
612 case 'html':
613 if (is_bool($value)) {
614 $this->isHTML($value);
615 } else {
616 Analog::log(
617 '[' . get_class($this) . '] Value for field `' . $name .
618 '` should be boolean - (' . gettype($value) . ')' .
619 $value . ' given',
620 Analog::WARNING
621 );
622 }
623 break;
624 case 'current_step':
625 if (
626 is_int($value)
627 && ($value == self::STEP_START
628 || $value == self::STEP_PREVIEW
629 || $value == self::STEP_SEND
630 || $value == self::STEP_SENT)
631 ) {
632 $this->current_step = (int)$value;
633 } else {
634 Analog::log(
635 '[' . get_class($this) . '] Value for field `' . $name .
636 '` should be integer and know - (' . gettype($value) . ')' .
637 $value . ' given',
638 Analog::WARNING
639 );
640 }
641 break;
642 case 'id':
643 $this->id = $value;
644 break;
645 default:
646 Analog::log(
647 '[' . get_class($this) . '] Unable to set property `' . $name . '`',
648 Analog::WARNING
649 );
650 }
651 }
652 }