Overview
Comment:Implement Skriv/Markdown emails + HTML + templates
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | emails
Files: files | file ages | folders
SHA3-256: 1cda3002a9a3c7dd2b814cfa66c4cc06985f00a4e9d2349c558c77bb7138c966
User & Date: bohwaz on 2022-05-30 20:52:43
Other Links: branch diff | manifest | tags
Context
2022-05-30
21:00
Add more variables for the email template check-in: fcf4b2a9d2 user: bohwaz tags: emails
20:52
Implement Skriv/Markdown emails + HTML + templates check-in: 1cda3002a9 user: bohwaz tags: emails
20:51
Move emails queue run to a separate task check-in: 922fd4f0a6 user: bohwaz tags: emails
Changes

Modified src/include/data/1.1.0_schema.sql from [89a0b562ed] to [0a44b3a9cc].

389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
	id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,







|







389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,

Modified src/include/data/schema.sql from [89a0b562ed] to [0a44b3a9cc].

389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
	id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,







|







389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,

Modified src/include/lib/Garradin/Entities/Users/Email.php from [26439d5e99] to [6c0166112c].

69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
			throw new UserException('Adresse email inconnue');
		}

		$message = "Bonjour,\n\nPour vérifier votre adresse e-mail pour notre association,\ncliquez sur le lien ci-dessous :\n\n";
		$message.= self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le.";

		Emails::queue(Emails::CONTEXT_SYSTEM, [$email], null, 'Confirmez votre adresse e-mail', $message);
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}







|







69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
			throw new UserException('Adresse email inconnue');
		}

		$message = "Bonjour,\n\nPour vérifier votre adresse e-mail pour notre association,\ncliquez sur le lien ci-dessous :\n\n";
		$message.= self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le.";

		Emails::queue(Emails::CONTEXT_SYSTEM, [$email => null], null, 'Confirmez votre adresse e-mail', $message);
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}

Modified src/include/lib/Garradin/Membres.php from [91973ae324] to [8be04b1af9].

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use Garradin\Users\Emails;


class Membres
{
    const ITEMS_PER_PAGE = 50;

    // Gestion des données ///////////////////////////////////////////////////////













>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use Garradin\Users\Emails;
use Garradin\UserTemplate\UserTemplate;

class Membres
{
    const ITEMS_PER_PAGE = 50;

    // Gestion des données ///////////////////////////////////////////////////////

329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
            $operator = 'LIKE ? ESCAPE \'\\\'';
        }

        $sql = sprintf('SELECT id, numero, %s AS identite FROM membres WHERE %s %s ORDER BY %1$s LIMIT 50;', $identity, $column, $operator);
        return DB::getInstance()->get($sql, $query);
    }

    public function sendMessage(array $recipients, $subject, $message, $send_copy)
    {
        $config = Config::getInstance();

        foreach ($recipients as $key => &$recipient) {
            if (empty($recipient->email)) {
                unset($recipients[$key]);
            }

            $recipient = $recipient->email;
        }

        unset($recipient);

        if (!count($recipients)) {
            throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
        }

        Emails::queue(Emails::CONTEXT_BULK, $recipients, null, $subject, $message);

        if ($send_copy)
        {
            Emails::queue(Emails::CONTEXT_BULK, $config->get('email_asso'), null, $subject, $message);
        }

        return true;
    }

    public function listAllEmailsButHidden(): array
    {
        return DB::getInstance()->get('SELECT id, email FROM membres
            WHERE id_category IN (SELECT id FROM users_categories WHERE hidden = 0)
                AND email IS NOT NULL AND email != \'\';');
    }

    public function listAllByCategory($id_category, $only_with_email = false)
    {
        $where = $only_with_email ? ' AND email IS NOT NULL' : '';
        return DB::getInstance()->get('SELECT email FROM membres WHERE id_category = ?' . $where, (int)$id_category);
    }

    public function listByCategory(?int $id_category): DynamicList
    {
        $config = Config::getInstance();
        $db = DB::getInstance();
        $identity = $config->get('champ_identite');







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|

|







|







330
331
332
333
334
335
336




























337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
            $operator = 'LIKE ? ESCAPE \'\\\'';
        }

        $sql = sprintf('SELECT id, numero, %s AS identite FROM membres WHERE %s %s ORDER BY %1$s LIMIT 50;', $identity, $column, $operator);
        return DB::getInstance()->get($sql, $query);
    }





























    public function listAllButHidden(): array
    {
        return DB::getInstance()->get('SELECT * FROM membres
            WHERE id_category IN (SELECT id FROM users_categories WHERE hidden = 0)
                AND email IS NOT NULL AND email != \'\';');
    }

    public function listAllByCategory($id_category, $only_with_email = false)
    {
        $where = $only_with_email ? ' AND email IS NOT NULL' : '';
        return DB::getInstance()->get('SELECT * FROM membres WHERE id_category = ?' . $where, (int)$id_category);
    }

    public function listByCategory(?int $id_category): DynamicList
    {
        $config = Config::getInstance();
        $db = DB::getInstance();
        $identity = $config->get('champ_identite');

Modified src/include/lib/Garradin/Membres/Session.php from [7fdbb21d3f] to [710cbbf9c2].

260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";

		if ($membre->clef_pgp) {
			$content = Security::encryptWithPublicKey($membre->clef_pgp, $message);
		}

		Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email], null, 'Mot de passe perdu ?', $message);
		return true;
	}

	public function recoverPasswordCheck($code, &$membre = null)
	{
		if (substr_count($code, '.') !== 2)
		{







|







260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";

		if ($membre->clef_pgp) {
			$content = Security::encryptWithPublicKey($membre->clef_pgp, $message);
		}

		Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe perdu ?', $message);
		return true;
	}

	public function recoverPasswordCheck($code, &$membre = null)
	{
		if (substr_count($code, '.') !== 2)
		{
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$membre->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('membres', ['passe' => $password], 'id = :id', ['id' => (int)$membre->id]);

		return Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email], null, 'Mot de passe changé', $message);
	}

	public function editUser($data)
	{
		(new Membres)->edit($this->user->id, $data, false);
		$this->refresh();








|







330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$membre->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('membres', ['passe' => $password], 'id = :id', ['id' => (int)$membre->id]);

		return Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe changé', $message);
	}

	public function editUser($data)
	{
		(new Membres)->edit($this->user->id, $data, false);
		$this->refresh();

397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;

		$dest = $copie ? [$dest, $user->email] : [$dest];

		return Emails::queue(Emails::CONTEXT_PRIVATE, $dest, null, $sujet, $content);
	}

	public function editSecurity(Array $data = [])
	{
		$allowed_fields = ['passe', 'clef_pgp', 'secret_otp'];







|







397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;

		$dest = $copie ? [$dest => null, $user->email => null] : [$dest => null];

		return Emails::queue(Emails::CONTEXT_PRIVATE, $dest, null, $sujet, $content);
	}

	public function editSecurity(Array $data = [])
	{
		$allowed_fields = ['passe', 'clef_pgp', 'secret_otp'];

Modified src/include/lib/Garradin/Services/Reminders.php from [d684207af3] to [0a0fd5e832].

112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

		// Envoi du mail
		Emails::queue(Emails::CONTEXT_PRIVATE, [$reminder->email], null, $subject, $text);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,







|







112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

		// Envoi du mail
		Emails::queue(Emails::CONTEXT_PRIVATE, [$reminder->email => $reminder], null, $subject, $text);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [47bb397359] to [0ec906a8b9].

17
18
19
20
21
22
23
24
25
26


27
28
29
30
31
32
33
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT};

class UserTemplate extends Brindille
{
	protected $path;
	protected $modified;
	protected $file;



	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {
			return self::$root_variables;







|

|
>
>







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT};

class UserTemplate extends Brindille
{
	protected $path = null;
	protected $modified;
	protected $file = null;
	protected $code = null;
	protected $cache_path = USER_TEMPLATES_CACHE_ROOT;

	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {
			return self::$root_variables;
107
108
109
110
111
112
113

114
115


















116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140




141




142
143
144
145
146
147
148
		foreach (Sections::SECTIONS_LIST as $name) {
			$this->registerSection($name, [Sections::class, $name]);
		}
	}

	public function setSource(string $path)
	{

		$this->path = $path;
		$this->modified = filemtime($path);


















	}

	public function display(): void
	{
		// Use custom cache for user templates
		if ($this->file) {
			$compiled_path = sprintf('%s/%s.php', USER_TEMPLATES_CACHE_ROOT, sha1($this->file->path));
		}
		// Use shared cache for default templates
		else {
			$compiled_path = sprintf('%s/%s.php', SHARED_USER_TEMPLATES_CACHE_ROOT, sha1($this->path));
		}

		if (!is_dir(dirname($compiled_path))) {
			// Force cache directory mkdir
			Utils::safe_mkdir(dirname($compiled_path), 0777, true);
		}

		if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) {
			require $compiled_path;
			return;
		}

		$tmp_path = $compiled_path . '.tmp';





		$source = $this->file ? $this->file->fetch() : file_get_contents($this->path);





		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}







>


>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




<
<
|
<
<
<
<
<













>
>
>
>
|
>
>
>
>







109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140


141





142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
		foreach (Sections::SECTIONS_LIST as $name) {
			$this->registerSection($name, [Sections::class, $name]);
		}
	}

	public function setSource(string $path)
	{
		$this->file = null;
		$this->path = $path;
		$this->modified = filemtime($path);
		// Use shared cache for default templates
		$this->cache_path = SHARED_USER_TEMPLATES_CACHE_ROOT;
	}

	public function setCode(string $code)
	{
		$this->code = $code;
		$this->file = null;
		$this->path = null;
		$this->modified = time();
		// Use custom cache for user templates
		$this->cache_path = USER_TEMPLATES_CACHE_ROOT;
	}

	protected function _getCachePath()
	{
		$hash = sha1($this->file ? $this->file->path : ($this->code ?: $this->path));
		return sprintf('%s/%s.php', $this->cache_path, $hash);
	}

	public function display(): void
	{


		$compiled_path = $this->_getCachePath(true);






		if (!is_dir(dirname($compiled_path))) {
			// Force cache directory mkdir
			Utils::safe_mkdir(dirname($compiled_path), 0777, true);
		}

		if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) {
			require $compiled_path;
			return;
		}

		$tmp_path = $compiled_path . '.tmp';

		if ($this->code) {
			$source = $this->code;
		}
		elseif ($this->file) {
			$source = $this->file->fetch();
		}
		else {
			$source = file_get_contents($this->path);
		}

		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}

Modified src/include/lib/Garradin/Users/Emails.php from [5b79111d94] to [b04ac7886f].

1
2
3
4
5
6
7
8
9



10
11
12
13
14
15
16
17
18
19






20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43



44
45

46
47
48
49
50
51

52

53





54
55
56
57


58

59



60




61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?php

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Entities\Users\Email;




use const Garradin\{USE_CRON};
use const Garradin\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY};

use KD2\SMTP;
use KD2\Mail_Message;
use KD2\DB\EntityManager as EM;

class Emails
{






    const CONTEXT_BULK = 1;
    const CONTEXT_PRIVATE = 2;
    const CONTEXT_SYSTEM = 0;

	/**
	 * Seuil à partir duquel on n'essaye plus d'envoyer de message à cette adresse
	 */
	const FAIL_LIMIT = 5;

	/**
	 * Add a message to the sending queue using templates
	 * @param  int          $context
	 * @param  array        $recipients List of recipients, 'From' email address as the key, and an array as a value, that contains variables to be used in the email template
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate $template
	 * @param  UserTemplate $template_html
	 * @return void
	 */
	static public function queueTemplate(int $context, array $recipients, string $sender, string $subject, UserTemplate $template, ?UserTemplate $template_html): void
	{
		// Remove duplicates
		array_walk($recipients, 'strtolower');
		$recipients = array_unique($recipients);




		$db = DB::getInstance();

		$st = $db->prepare('INSERT INTO emails_queue (sender, subject, recipient, content, content_html, context)
			VALUES (:sender, :subject, :recipient, :content, :content_html, :context);');

		$st->bindValue(':sender', $sender);
		$st->bindValue(':subject', $subject);
		$st->bindValue(':context', $context);



		foreach ($recipients as $to => $variables) {





			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$hash = Email::getHash($to);



			$content = $template->fetch($variables);

			$content_html = $template_html ? $template_html->fetch($variables) : null;








			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $content);
			$st->bindValue(':content_html', $content_html);
			$st->execute();

			$st->reset();
			$st->clear();
		}

		Plugin::fireSignal('email.queue.added');

		if (!USE_CRON) {
			self::runQueue();
		}
	}

	/**
	 * Add a message to the sending queue
	 * @param  int          $context
	 * @param  array        $recipients List of recipients emails
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate $template
	 * @param  UserTemplate $template_html
	 * @return void
	 */
	static public function queue(int $context, array $recipients, ?string $sender, string $subject, string $text): void
	{
		// Remove duplicates
		array_walk($recipients, fn($a) => strtolower($a));
		$recipients = array_unique($recipients);

		$db = DB::getInstance();
		$st = $db->prepare('INSERT INTO emails_queue (sender, subject, recipient, recipient_hash, content, context)
			VALUES (:sender, :subject, :recipient, :recipient_hash, :content, :context);');

		foreach ($recipients as $to) {
			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$hash = Email::getHash($to);

			$st->bindValue(':sender', $sender);
			$st->bindValue(':subject', $subject);
			$st->bindValue(':context', $context);
			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $text);
			$st->execute();

			$st->reset();
			$st->clear();
		}

		Plugin::fireSignal('email.queue.added');

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}









>
>
>










>
>
>
>
>
>
|
|
|












|
<


|

|
|
|
>
>
>


>
|
|

|
<
<
>
|
>

>
>
>
>
>




>
>
|
>
|
>
>
>
|
>
>
>
>










<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61


62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97

98









































99
100
101
102
103
104
105
<?php

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Entities\Users\Email;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;
use Garradin\Web\Skeleton;

use const Garradin\{USE_CRON};
use const Garradin\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY};

use KD2\SMTP;
use KD2\Mail_Message;
use KD2\DB\EntityManager as EM;

class Emails
{
	const RENDER_FORMATS = [
		null => 'Texte brut',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_MARKDOWN => 'MarkDown',
	];

	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_SYSTEM = 0;

	/**
	 * Seuil à partir duquel on n'essaye plus d'envoyer de message à cette adresse
	 */
	const FAIL_LIMIT = 5;

	/**
	 * Add a message to the sending queue using templates
	 * @param  int          $context
	 * @param  array        $recipients List of recipients, 'From' email address as the key, and an array as a value, that contains variables to be used in the email template
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate|string $content

	 * @return void
	 */
	static public function queue(int $context, array $recipients, ?string $sender, string $subject, $content, ?string $render = null): void
	{
		// Remove duplicates due to case changes
		$recipients = array_change_key_case($recipients, CASE_LOWER);

		$template = ($content instanceof UserTemplate) ? $content : null;
		$skel = null;
		$content_html = null;

		$db = DB::getInstance();
		$db->begin();
		$st = $db->prepare('INSERT INTO emails_queue (sender, subject, recipient, recipient_hash, content, content_html, context)
			VALUES (:sender, :subject, :recipient, :recipient_hash, :content, :content_html, :context);');

		if ($render) {


			$skel = new Skeleton('email.html');
		}

		foreach ($recipients as $to => $variables) {
			// Ignore invalid addresses
			if (!preg_match('/.+@.+\..+$/', $to)) {
				continue;
			}

			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$hash = Email::getHash($to);

			if ($template) {
				$template->assignArray((array) $variables);
				$content = $template->fetch();
			}

			if ($render) {
				$content_html = Render::render($render, null, $content);
				$content_html = $skel->fetch(['html' => $content_html]);
			}

			$st->bindValue(':sender', $sender);
			$st->bindValue(':subject', $subject);
			$st->bindValue(':context', $context);
			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $content);
			$st->bindValue(':content_html', $content_html);
			$st->execute();

			$st->reset();
			$st->clear();
		}


		$db->commit();










































		Plugin::fireSignal('email.queue.added');

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
		if ($context != self::CONTEXT_SYSTEM) {
			$url = Email::getOptoutURL($recipient_hash);

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$optout_text = "Vous recevez ce message car vous êtes inscrit comme membre de\nl'association.\n"
				. "Pour ne plus jamais recevoir de message de notre part cliquez sur le lien suivant :\n";

			$content .= "\n\n-- \n" . $optout_text . $url;

			if (null !== $content_html) {
				$optout_text = '<hr /><p style="color: #000; background: #fff">' . nl2br(htmlspecialchars($optout_text));
				$optout_text.= sprintf('<a href="%s" style="color: blue; text-decoration: underline; padding: 5px; border-radius: 5px; background: #eee;">Me désinscrire</a>', $url);

				if (stripos($content_html, '</body>') !== false) {
					$content_html = str_ireplace('</body>', $optout_text . '</body>', $content_html);
				}
				else {
					$content_html .= $optout_text;
				}







|





|
|







345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
		if ($context != self::CONTEXT_SYSTEM) {
			$url = Email::getOptoutURL($recipient_hash);

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$optout_text = "Vous recevez ce message car vous êtes inscrit comme membre de l'association.\n"
				. "Pour ne plus jamais recevoir de message de notre part cliquez sur le lien suivant :\n";

			$content .= "\n\n-- \n" . $optout_text . $url;

			if (null !== $content_html) {
				$optout_text = '<hr style="border-top: 2px solid #999; background: none;" /><p style="color: #000; background: #fff; padding: 10px; text-align: center; font-size: 9pt">' . nl2br(htmlspecialchars($optout_text));
				$optout_text.= sprintf('<br /><a href="%s" style="color: blue; text-decoration: underline; padding: 5px; border-radius: 5px; background: #ddd;">Me désinscrire</a></p>', $url);

				if (stripos($content_html, '</body>') !== false) {
					$content_html = str_ireplace('</body>', $optout_text . '</body>', $content_html);
				}
				else {
					$content_html .= $optout_text;
				}
449
450
451
452
453
454
455
456












































































		if (!$email) {
			return;
		}

		$email->hasFailed($return);
		$email->save();
	}
}



















































































|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
		if (!$email) {
			return;
		}

		$email->hasFailed($return);
		$email->save();
	}

	static public function createMailing(array $recipients, string $subject, string $message, bool $send_copy, ?string $render): \stdClass
	{
		$config = Config::getInstance();
		$list = [];

		foreach ($recipients as $recipient) {
			if (empty($recipient->email)) {
				continue;
			}

			$list[$recipient->email] = $recipient;
		}

		if (!count($list)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		$html = $message;
		$tpl = null;

		$random = array_rand($list);

		if (false !== strpos($message, '{{')) {
			$tpl = new UserTemplate;
			$tpl->setCode($message);
			$tpl->assignArray((array)$list[$random]);
			$html = $tpl->fetch();
		}

		if ($render) {
			$html = Render::render($render, null, $html);
		}
		else {
			$html = '<pre>' . htmlspecialchars($html) . '</pre>';
		}

		$recipients = $list;

		$sender = sprintf('"%s" <%s>', $config->nom_asso, $config->email_asso);
		$message = (object) compact('recipients', 'subject', 'message', 'sender', 'tpl', 'send_copy', 'render');
		$message->preview = (object) [
			'to'      => $random,
			'from'    => $sender,
			'subject' => $subject,
			'html'    => $html,
		];

		return $message;
	}

	static public function sendMailing(\stdClass $mailing): void
	{
		if (!isset($mailing->recipients, $mailing->subject, $mailing->message, $mailing->send_copy)) {
			throw new \InvalidArgumentException('Invalid $mailing object');
		}

		if (!count($mailing->recipients)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		Emails::queue(Emails::CONTEXT_BULK,
			$mailing->recipients,
			null, // Default sender
			$mailing->subject,
			$mailing->tpl ?? $mailing->message,
			$mailing->render ?? null
		);

		if ($mailing->send_copy)
		{
			$config = Config::getInstance();
			Emails::queue(Emails::CONTEXT_BULK, [$config->get('email_asso') => null], null, $mailing->subject, $mailing->message);
		}
	}

}

Modified src/include/lib/Garradin/Web/Render/Render.php from [e445f0f98d] to [dac2d2f09d].

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';
	const FORMAT_BLOCKS = 'blocks';

	static protected $attachments = [];

	static public function render(string $format, File $file, string $content = null, string $link_prefix = null)
	{
		return self::getRenderer($format, $file, $link_prefix)->render($content);
	}

	static public function getRenderer(string $format, File $file, string $link_prefix = null)
	{
		if ($format == self::FORMAT_SKRIV) {
			return new Skriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_ENCRYPTED) {
			return new EncryptedSkriv($file, $link_prefix);
		}







|




|







9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';
	const FORMAT_BLOCKS = 'blocks';

	static protected $attachments = [];

	static public function render(string $format, ?File $file, string $content = null, string $link_prefix = null)
	{
		return self::getRenderer($format, $file, $link_prefix)->render($content);
	}

	static public function getRenderer(string $format, ?File $file, string $link_prefix = null)
	{
		if ($format == self::FORMAT_SKRIV) {
			return new Skriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_ENCRYPTED) {
			return new EncryptedSkriv($file, $link_prefix);
		}

Modified src/templates/admin/membres/_details.tpl from [2d6b70a94a] to [cae6c2a26a].

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
				{/if}
			{/foreach}
			</ul>
		{else}
			{$value|display_champ_membre:$c_config|raw}
		{/if}
	</dd>
		{if $c_config.type == 'email' && ($email = Users\Emails::getEmail($value))}
		<dd class="help">
			{if $email.optout}
				<b class="alert">A demandé à ne plus recevoir de messages</b>
			{elseif $email.invalid}
				<b class="error">Adresse invalide</b> | {$email.fail_log|escape|nl2br}
			{elseif $email->hasReachedFailLimit()}
				<b class="error">Trop d'erreurs</b> | {$email.fail_log|escape|nl2br}







|







44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
				{/if}
			{/foreach}
			</ul>
		{else}
			{$value|display_champ_membre:$c_config|raw}
		{/if}
	</dd>
		{if $c_config.type == 'email' && $value && ($email = Users\Emails::getEmail($value))}
		<dd class="help">
			{if $email.optout}
				<b class="alert">A demandé à ne plus recevoir de messages</b>
			{elseif $email.invalid}
				<b class="error">Adresse invalide</b> | {$email.fail_log|escape|nl2br}
			{elseif $email->hasReachedFailLimit()}
				<b class="error">Trop d'erreurs</b> | {$email.fail_log|escape|nl2br}

Modified src/templates/admin/membres/emails.tpl from [d64ed6b92f] to [e255deaf2b].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{include file="admin/_head.tpl" title="Adresses rejetées" current="membres/message"}

<nav class="tabs">
    <ul>
    	<li><a href="message_collectif.php">Envoyer</a></li>
    	<li class="current"><a href="emails.php">Adresses rejetées</a></li>
    </ul>
</nav>

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{/if}



|
|
|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
{include file="admin/_head.tpl" title="Adresses rejetées" current="membres/message"}

<nav class="tabs">
	<ul>
		<li><a href="message_collectif.php">Envoyer</a></li>
		<li class="current"><a href="emails.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{/if}
46
47
48
49
50
51
52

53
54

55
56
57
58
59
60
61
62
63
64
65
66
	</table>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

	<div class="block help">
		<h3>Statuts possibles d'une adresse e-mail&nbsp;:</h3>
		<dl class="cotisation">

			<dt>Vérifiée</dt>
			<dd>L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.</dd>

			<dt>Désinscription</dt>
			<dd>Le destinataire a demandé à être désinscrit et ne recevra plus de messages.</dd>
			<dt>Invalide</dt>
			<dd>L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.</dd>
			<dt>Trop de tentatives</dt>
			<dd>Le service destinataire a répondu une erreur plus de {$max_fail_count} fois. Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.</dd>
		</dl>
	</div>

{/if}

{include file="admin/_foot.tpl"}







>


>





|






46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
	</table>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

	<div class="block help">
		<h3>Statuts possibles d'une adresse e-mail&nbsp;:</h3>
		<dl class="cotisation">
			{*
			<dt>Vérifiée</dt>
			<dd>L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.</dd>
			*}
			<dt>Désinscription</dt>
			<dd>Le destinataire a demandé à être désinscrit et ne recevra plus de messages.</dd>
			<dt>Invalide</dt>
			<dd>L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.</dd>
			<dt>Trop de tentatives</dt>
			<dd>Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois.<br />Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.</dd>
		</dl>
	</div>

{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/admin/membres/message.tpl from [43ce250d36] to [29a83a20df].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{include file="admin/_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
    <fieldset class="memberMessage">
        <legend>Message</legend>
        <dl>
            <dt>Expéditeur</dt>
            <dd>{$user.identite} &lt;{$user.email}&gt;</dd>
            <dt>Destinataire</dt>
            <dd>{$membre.identite} ({$categorie.name})</dd>
            <dt><label for="f_sujet">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="sujet" id="f_sujet" value="{form_field name=sujet}" required="required" /></dd>
            <dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><textarea name="message" id="f_message" cols="72" rows="25" required="required">{form_field name=message}</textarea></dd>
            <dd>
                <input type="checkbox" name="copie" id="f_copie" value="1" />
                <label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
            </dd>
        </dl>





|






|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{include file="admin/_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
    <fieldset class="mailing">
        <legend>Message</legend>
        <dl>
            <dt>Expéditeur</dt>
            <dd>{$user.identite} &lt;{$user.email}&gt;</dd>
            <dt>Destinataire</dt>
            <dd>{$membre.identite} ({$categorie.name})</dd>
            <dt><label for="f_subject">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="sujet" id="f_subject" value="{form_field name=sujet}" required="required" /></dd>
            <dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><textarea name="message" id="f_message" cols="72" rows="25" required="required">{form_field name=message}</textarea></dd>
            <dd>
                <input type="checkbox" name="copie" id="f_copie" value="1" />
                <label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
            </dd>
        </dl>

Modified src/templates/admin/membres/message_collectif.tpl from [49ced21e88] to [d2963da0b5].

1
2
3
4
5
6
7
8
9




10
11
12

13
































14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

53
54
55
56
57
58
59

60
61
62
63
{include file="admin/_head.tpl" title="Envoyer un message collectif" current="membres/message"}

<nav class="tabs">
    <ul>
    	<li class="current"><a href="{$self_url}">Envoyer</a></li>
    	<li><a href="emails.php">Adresses rejetées</a></li>
    </ul>
</nav>





{form_errors}

<form method="post" action="{$self_url}">

	<fieldset class="memberMessage">
































		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			<dd>{$config.nom_asso} &lt;{$config.email_asso}&gt;</dd>
			<dt>Destinataires</dt>
			<dd>
				<select name="recipients">
					<option value="all_but_hidden">Tous les membres (sauf ceux appartenant à une catégorie cachée)</option>
					<optgroup label="Catégorie de membres">
						{foreach from=$categories key="id" item="nom"}
						<option value="categorie_{$id}" {form_field name="recipients" selected="categorie_%d"|args:$id}>{$nom}</option>
						{/foreach}
					</optgroup>
					<optgroup label="Recherches enregistrées">
						{foreach from=$recherches item="r"}
						<option value="recherche_{$r.id}" {form_field name="recipients" selected="recherche_%d"|args:$r.qid}>{$r.intitule}</option>
						{/foreach}
					</optgroup>
				</select>
			</dd>
			<dd class="help">
				Vous pouvez cibler précisément des membres en créant une <a href="{$admin_url}membres/recherche.php">recherche enregistrée</a>.
				Les recherches enregistrées apparaîtront dans ce formulaire.
			</dd>
			{* FIXME : pas encore possible, en attente de refonte gestion cotisations
			<dd>
				<label><input type="checkbox" name="paid_members_only" value="1" {form_field name="paid_members_only" checked=1 default=1} />
					Seulement les membres à jour de cotisation
				</label>
			</dd>
			*}
			<dt><label for="f_sujet">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
			<dd><input type="text" name="sujet" id="f_sujet" value="{form_field name=sujet}" required="required" /></dd>
			<dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
			<dd><textarea name="message" id="f_message" cols="35" rows="25" required="required">{form_field name=message}</textarea></dd>
			<dd>
				<input type="checkbox" name="copie" id="f_copie" value="1" />
				<label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
			</dd>

		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="send_message_co"}
		{button type="submit" name="send" label="Envoyer" shape="right" class="main"}
	</p>

</form>


{include file="admin/_foot.tpl"}
|








>
>
>
>


|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|

|
|

|
|



|
|








<
<
<
<
<
<
<
<
|
<
<
<
|
|
<
>




|
|

>




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74








75



76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
{include file="admin/_head.tpl" title="Envoyer un message collectif" current="membres/message" custom_css=["!web/css.php"]}

<nav class="tabs">
    <ul>
    	<li class="current"><a href="{$self_url}">Envoyer</a></li>
    	<li><a href="emails.php">Adresses rejetées</a></li>
    </ul>
</nav>

{if $sent}
	<p class="block confirm">Votre message a été envoyé.</p>
{/if}

{form_errors}

<form method="post" action="{$self_url_no_qs}">
	{if $preview}
		<fieldset class="mailing">
			<legend>Prévisualisation du message</legend>
			<p class="help">
				Ce message sera envoyé à <strong>{$recipients_count}</strong> destinataires.<br />
				Voici un exemple du message pour un de ces destinataires.
			</p>
			<dl>
				<dt>Expéditeur</dt>
				<dd>{$preview.from}</dd>
				<dt>Destinataire</dt>
				<dd>
					{$preview.to}
				</dd>
				<dt>Sujet</dt>
				<dd>{$preview.subject}</dd>
				<dt>Message</dt>
				<dd class="preview">{$preview.html|raw}</dd>
			</dl>
		</fieldset>

		<p class="submit">
			{input type="hidden" name="subject"}
			{input type="hidden" name="message"}
			{input type="hidden" name="target"}
			{input type="hidden" name="send_copy"}
			{input type="hidden" name="render"}
			{csrf_field key=$csrf_key}
			{button type="submit" name="back" label="Retour à l'édition" shape="left"}
			{button type="submit" name="send" label="Envoyer" shape="right" class="main"}
		</p>

	{else}
	<fieldset class="mailing">
		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			<dd>{$config.nom_asso} &lt;{$config.email_asso}&gt;</dd>
			<dt><label for="f_target">Destinataires</label></dt>
			<dd>
				<select name="target" id="f_target" required="required">
					<option value="all_">Tous les membres (sauf ceux appartenant à une catégorie cachée)</option>
					<optgroup label="Catégorie de membres">
						{foreach from=$categories key="id" item="label"}
						<option value="category_{$id}" {form_field name="target" selected="category_%d"|args:$id}>{$label}</option>
						{/foreach}
					</optgroup>
					<optgroup label="Recherches enregistrées">
						{foreach from=$search_list item="s"}
						<option value="search_{$s.id}" {form_field name="target" selected="search_%d"|args:$s.id}>{$s.intitule}</option>
						{/foreach}
					</optgroup>
				</select>
			</dd>
			<dd class="help">
				Vous pouvez cibler précisément des membres en créant une <a href="{$admin_url}membres/recherche.php">recherche enregistrée</a>.
				Les recherches enregistrées apparaîtront dans ce formulaire.
			</dd>








			{input type="text" name="subject" required=true label="Sujet"}



			{input type="textarea" name="message" cols=35 rows=25 required=true label="Message"}
			{input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"}

			{input type="select" name="render" label="Format de rendu" options=$render_formats help="Pour enrichir le contenu du mail, inclure des liens, du gras, des titres, etc."}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="preview" label="Prévisualiser" shape="right" class="main"}
	</p>
	{/if}
</form>


{include file="admin/_foot.tpl"}

Modified src/www/admin/membres/message_collectif.php from [c1205018b1] to [e8bbbf3b36].

1
2
3
4

5
6
7
8
9
10
11
12
13
14

15
16
17
18
19
20
21


22
23
24
25
26
27

28
29
30
31
32

33

34
35
36
37


38
39
40
41
42


43
44
45

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62


63
64


65
66






67
<?php
namespace Garradin;

use Garradin\Users\Categories;


require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$recherche = new Recherche;

if (f('send'))
{
    $form->check('send_message_co', [

        'sujet'      => 'required|string',
        'message'    => 'required|string',
        'recipients' => 'required|string',
    ]);

    if (f('recipients') == 'all_but_hidden') {
        $recipients = $membres->listAllEmailsButHidden();


    }
    elseif (preg_match('/^(categorie|recherche)_(\d+)$/', f('recipients'), $match))
    {
        if ($match[1] == 'categorie')
        {
            $recipients = $membres->listAllByCategory($match[2], true);

        }
        else
        {
            try {
                $recipients = $recherche->search($match[2], ['membres.email'], true);

            }

            catch (UserException $e) {
                $form->addError($e->getMessage());
            }
        }


    }
    else
    {
        $form->addErrror('Destinataires invalides : ' . f('recipients'));
    }



    if (empty($recipients) || !count($recipients))
    {

        $form->addError('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
    }

    if (!$form->hasErrors())
    {
        try {
            $membres->sendMessage($recipients, f('sujet'),
                f('message'), (bool) f('copie'));

            Utils::redirect(ADMIN_URL . 'membres/?sent');
        }
        catch (UserException $e)
        {
            $form->addError($e->getMessage());
        }
    }
}



$tpl->assign('categories', Categories::listNotHidden());


$tpl->assign('recherches', $recherche->getList($user->id, 'membres'));







$tpl->display('admin/membres/message_collectif.tpl');




>






|
<
|
|
>
|
<
<
<
|
|
<
>
>
|
<
|
|
<
<
>
|
<
|
<
<
>
|
>
|
<
|
|
>
>
|
|
<
|
|
>
>
|
<
|
>
|
|

<
<
<
<
|
|
<
|
<
<
|
<
<
<
>
>


>
>
|

>
>
>
>
>
>

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16



17
18

19
20
21

22
23


24
25

26


27
28
29
30

31
32
33
34
35
36

37
38
39
40
41

42
43
44
45
46




47
48

49


50



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php
namespace Garradin;

use Garradin\Users\Categories;
use Garradin\Users\Emails;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$recherche = new Recherche;
$csrf_key = 'send_mailing';


$form->runIf(f('send') || f('subject'), function () use ($membres, &$mailing, $recherche) {
	if (!trim(f('subject'))) {
		throw new UserException('Le sujet ne peut rester vide.');



	}


	if (!trim(f('message'))) {
		throw new UserException('Le message ne peut rester vide.');
	}


	if (!f('target')) {


		throw new UserException('Aucun destinataire sélectionné.');
	}




	$target = explode('_', f('target'));

	if (count($target) !== 2) {
		throw new UserException('Destinataire invalide');

	}

	if ($target[0] == 'all') {
		$recipients = $membres->listAllButHidden();
	}
	elseif ($target[0] == 'category') {

		$recipients = $membres->listAllByCategory($target[1], true);
	}
	elseif ($target[0] == 'search') {
		$recipients = $recherche->search($target[1], ['membres.*'], true);
	}


	if (!count($recipients)) {
		throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
	}





	$mailing = Emails::createMailing($recipients, f('subject'), f('message'), (bool) f('send_copy'), f('render') ?: null);
}, $csrf_key);




$form->runIf('send', function () use ($membres, $mailing) {



	Emails::sendMailing($mailing);
}, $csrf_key, '!membres/message_collectif.php?sent');

$tpl->assign('categories', Categories::listNotHidden());
$tpl->assign('preview', f('preview') && $mailing ? $mailing->preview : null);
$tpl->assign('recipients_count', $mailing ? count($mailing->recipients) : 0);
$tpl->assign('search_list', $recherche->getList($user->id, 'membres'));

$tpl->assign('render_formats', Emails::RENDER_FORMATS);

$tpl->assign(compact('csrf_key'));

$tpl->assign('sent', null !== qg('sent'));

$tpl->display('admin/membres/message_collectif.tpl');

Modified src/www/admin/static/styles/03-forms.css from [2ce7e6ae49] to [e17ff4b17b].

532
533
534
535
536
537
538
539
540
541
542





543

544
545
546
547
548
549
550
}

.datepicker tbody input:hover {
    background: #ccf;
    color: darkred;
}

fieldset.memberMessage {
    max-width: 30em;
}






fieldset.memberMessage #f_sujet, fieldset.memberMessage #f_message, fieldset.memberMessage select, #queryBuilderForm textarea {

    width: calc(100% - 2em);
}


#queryBuilder .column select, #queryBuilderForm .actions select {
    max-width: 15em;
}







|
|


>
>
>
>
>
|
>







532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
}

.datepicker tbody input:hover {
    background: #ccf;
    color: darkred;
}

fieldset.mailing {
    max-width: 40em;
}

fieldset.mailing dd.preview > * {
    border-radius: .5em;
    background: var(--gLightBackgroundColor);
    padding: 1em;
}

#queryBuilderForm textarea, fieldset.mailing textarea, fieldset.mailing #f_subject, fieldset.mailing #f_target {
    width: calc(100% - 2em);
}


#queryBuilder .column select, #queryBuilderForm .actions select {
    max-width: 15em;
}

Modified src/www/skel-dist/content.css from [fa05be07ee] to [ce4188bfc6].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin: .8em 0;

}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {









|
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin: 0;
    margin-bottom: .8em;
}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {

Added src/www/skel-dist/email.html version [855f91c8f8].





































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{{* Ce squelette est utilisé pour l'envoi d'un e-mail au format Skriv ou Markdown *}}
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
	* { margin: 0; padding: 0; }
	body {
		background: #eee;
		color: #000;
	}
	a {
		color: #009;
		text-decoration: underline;
	}
	.web-content, .footer {
		padding: 10px;
		background: #fff;
		max-width: 750px;
		margin: 10px auto;
		border-radius: 5px;
	}
	.footer {
		background: #ddd;
	}
	.footer h3, .footer h4, .footer h5 {
		text-align: center;
		margin: 5px 0;
	}
	{{* Inclure le contenu de content.css pour s'assurer que le style du texte Markdown/Skriv est correct *}}
	{{:include file="content.css"}}
</style>
</head>
<body>

{{* Le contenu du mail est dans la variable $html, ne pas supprimer sinon le message sera vide ! *}}
{{$html|raw}}

<div class="footer">
	<h3><a href="{{$config.site_asso}}">{{$config.nom_asso}}</a></h3>
	{{if $config.adresse_asso}}
		<h4>{{$config.adresse_asso}}</h4>
	{{/if}}
	{{if $config.telephone_asso}}
		<h5><a href="tel:{{$config.telephone_asso}}">{{$config.telephone_asso|raw}}</a></h5>
	{{/if}}
</div>

{{* Le lien de désinscription sera ajouté automatiquement en bas du message, il n'est pas possible de le modifier ou le supprimer. *}}
</body>
</html>