]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Core/Mailing.php
Drop old (and problematic!) underscore prefix
[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 array $errors
45 * @property-read array $recipients
46 * @property-read array $unreachables
47 * @property-read string|false $tmp_path
48 * @property array $attachments
49 * @property-read string $sender_name
50 * @property-read string $sender_address
51 * @property integer $history_id
52 */
53 class Mailing extends GaletteMail
54 {
55 public const STEP_START = 0;
56 public const STEP_PREVIEW = 1;
57 public const STEP_SEND = 2;
58 public const STEP_SENT = 3;
59
60 public const MIME_HTML = 'text/html';
61 public const MIME_TEXT = 'text/plain';
62 public const MIME_DEFAULT = self::MIME_TEXT;
63
64 private string|int $id;
65
66 /** @var array<int, Adherent> */
67 private array $unreachables = array();
68 /** @var array<int, Adherent> */
69 private array $mrecipients = array();
70 private int $current_step;
71
72 private string $mime_type;
73
74 private ?string $tmp_path;
75 private int $history_id;
76
77 /**
78 * Default constructor
79 *
80 * @param Preferences $preferences Preferences instance
81 * @param array<int, Adherent> $members An array of members
82 * @param ?integer $id Identifier, defaults to null
83 */
84 public function __construct(Preferences $preferences, array $members = [], int $id = null)
85 {
86 parent::__construct($preferences);
87 $this->id = $id ?? $this->generateNewId();
88
89 $this->current_step = self::STEP_START;
90 $this->mime_type = self::MIME_DEFAULT;
91 /** TODO: add a preference that propose default mime-type to use,
92 then init it here */
93 if (count($members)) {
94 //Check which members have a valid email address and which have not
95 $this->setRecipients($members);
96 }
97 $this->loadAttachments();
98 }
99
100 /**
101 * Generate new mailing id and temporary path
102 *
103 * @return string
104 */
105 private function generateNewId(): string
106 {
107 $id = '';
108 $chars = 'abcdefghjkmnpqrstuvwxyz0123456789';
109 $i = 0;
110 $size = 30;
111 while ($i <= $size - 1) {
112 $num = mt_rand(0, strlen($chars) - 1) % strlen($chars);
113 $id .= substr($chars, $num, 1);
114 $i++;
115 }
116
117 $this->id = $id;
118 $this->generateTmpPath($this->id);
119 return $this->id;
120 }
121
122 /**
123 * Generate temporary path
124 *
125 * @param ?string $id Random id, defaults to null
126 *
127 * @return void
128 */
129 private function generateTmpPath(string $id = null): void
130 {
131 if ($id === null) {
132 $id = $this->generateNewId();
133 }
134 $this->tmp_path = GALETTE_ATTACHMENTS_PATH . '/' . $id;
135 }
136
137 /**
138 * Load mailing attachments
139 *
140 * @return void
141 */
142 private function loadAttachments(): void
143 {
144 $dir = '';
145 if (
146 isset($this->tmp_path)
147 && trim($this->tmp_path) !== ''
148 ) {
149 $dir = $this->tmp_path;
150 } else {
151 $dir = GALETTE_ATTACHMENTS_PATH . $this->id . '/';
152 }
153
154 $files = glob($dir . '*.*');
155 foreach ($files as $file) {
156 $f = new File($dir);
157 $f->setFileName(str_replace($dir, '', $file));
158 $this->attachments[] = $f;
159 }
160 }
161
162 /**
163 * Loads a mailing from history
164 *
165 * @param ArrayObject<string, mixed> $rs Mailing entry
166 * @param boolean $new True if we create a 'new' mailing,
167 * false otherwise (from preview for example)
168 *
169 * @return boolean
170 */
171 public function loadFromHistory(ArrayObject $rs, bool $new = true): bool
172 {
173 global $zdb;
174
175 try {
176 if (Galette::isSerialized($rs->mailing_recipients)) {
177 $orig_recipients = unserialize($rs->mailing_recipients);
178 } else {
179 $orig_recipients = Galette::jsonDecode($rs->mailing_recipients);
180 }
181 } catch (\Throwable $e) {
182 Analog::log(
183 'Unable to retrieve recipients for mailing ' . $rs->mailing_id,
184 Analog::ERROR
185 );
186 $orig_recipients = [];
187 }
188
189 $_recipients = array();
190 $mdeps = ['parent' => true];
191 foreach ($orig_recipients as $k => $v) {
192 $m = new Adherent($zdb, $k, $mdeps);
193 $_recipients[] = $m;
194 }
195 $this->setRecipients($_recipients);
196 $this->subject = $rs->mailing_subject;
197 $this->message = $rs->mailing_body;
198 $this->html = $this->message != strip_tags($this->message) ? true : false;
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 return $this->getPhpMailer();
524 case 'errors':
525 return $this->getErrors();
526 case 'recipients':
527 return $this->mrecipients;
528 case 'tmp_path':
529 if (isset($this->tmp_path) && trim($this->tmp_path) !== '') {
530 return $this->tmp_path;
531 } else {
532 //no attachments
533 return false;
534 }
535 case 'attachments':
536 return $this->attachments;
537 case 'sender_name':
538 return $this->getSenderName();
539 case 'sender_address':
540 return $this->getSenderAddress();
541 case 'history_id':
542 return $this->$name;
543 default:
544 Analog::log(
545 '[' . get_class($this) . 'Trying to get ' . $name,
546 Analog::DEBUG
547 );
548 return $this->$name;
549 }
550 } else {
551 Analog::log(
552 '[' . get_class($this) . 'Unable to get ' . $name,
553 Analog::ERROR
554 );
555 return false;
556 }
557 }
558
559 /**
560 * Global isset method
561 * Required for twig to access properties via __get
562 *
563 * @param string $name name of the property we want to retrieve
564 *
565 * @return bool
566 */
567 public function __isset(string $name): bool
568 {
569 $forbidden = array('ordered');
570 if (!in_array($name, $forbidden)) {
571 switch ($name) {
572 case 'alt_message':
573 case 'step':
574 case 'subject':
575 case 'message':
576 case 'wrapped_message':
577 case 'html':
578 case 'mail':
579 case 'errors':
580 case 'recipients':
581 case 'tmp_path':
582 case 'attachments':
583 case 'sender_name':
584 case 'sender_address':
585 return true;
586 }
587 return isset($this->$name);
588 }
589
590 return false;
591 }
592
593 /**
594 * Global setter method
595 *
596 * @param string $name name of the property we want to assign a value to
597 * @param mixed $value a relevant value for the property
598 *
599 * @return void
600 */
601 public function __set(string $name, $value): void
602 {
603 switch ($name) {
604 case 'subject':
605 $this->setSubject($value);
606 break;
607 case 'message':
608 $this->setMessage($value);
609 break;
610 case 'html':
611 if (is_bool($value)) {
612 $this->isHTML($value);
613 } else {
614 Analog::log(
615 '[' . get_class($this) . '] Value for field `' . $name .
616 '` should be boolean - (' . gettype($value) . ')' .
617 $value . ' given',
618 Analog::WARNING
619 );
620 }
621 break;
622 case 'current_step':
623 if (
624 is_int($value)
625 && ($value == self::STEP_START
626 || $value == self::STEP_PREVIEW
627 || $value == self::STEP_SEND
628 || $value == self::STEP_SENT)
629 ) {
630 $this->current_step = (int)$value;
631 } else {
632 Analog::log(
633 '[' . get_class($this) . '] Value for field `' . $name .
634 '` should be integer and know - (' . gettype($value) . ')' .
635 $value . ' given',
636 Analog::WARNING
637 );
638 }
639 break;
640 case 'id':
641 $this->id = $value;
642 break;
643 default:
644 Analog::log(
645 '[' . get_class($this) . '] Unable to set property `' . $name . '`',
646 Analog::WARNING
647 );
648 }
649 }
650 }