Overview
Comment:Add handle bounce script
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | emails
Files: files | file ages | folders
SHA3-256: a3a07e43187653f054e1c841eba6893202ca9917a6027090a52c7ceb110ab45b
User & Date: bohwaz on 2022-05-31 01:43:28
Other Links: branch diff | manifest | tags
Context
2022-05-31
13:11
Handle Brindille errors in mailing check-in: d69d7b6df6 user: bohwaz tags: emails
01:43
Add handle bounce script check-in: a3a07e4318 user: bohwaz tags: emails
2022-05-30
21:46
Move POST unsubscribe to index.php as POST does not always handle redirects check-in: d2eaaa7d7b user: bohwaz tags: emails
Changes

Modified src/include/lib/Garradin/Users/Emails.php from [2bec96d971] to [42cc96f10d].

22
23
24
25
26
27
28



29
30
31
32
33
34
35
36
37
38
39
40
41
{
	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







>
>
>





|







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
	const RENDER_FORMATS = [
		null => 'Texte brut',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_MARKDOWN => 'MarkDown',
	];

	/**
	 * Email sending contexts
	 */
	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_SYSTEM = 0;

	/**
	 * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
	 */
	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
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
149
150
151
152
153
154
155
156
157



158
159
160
161
162
163
164
165
166
167


168
169
170
171
172
173
174

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




	static public function getEmailFromOptout(string $code): ?Email
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
	}




	static public function markAddressAsInvalid(string $address): void
	{
		$e = self::getEmail($address);

		if (!$e) {
			return;
		}

		$e->set('invalid', true);


		$e->save();
	}




	static public function getEmail(string $address): ?Email
	{
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash($address));
	}




	static public function getOrCreateEmail(string $address): Email
	{

		$e = self::getEmail($address);

		if (!$e) {
			$e = new Email;
			$e->added = new \DateTime;
			$e->hash = $e::getHash($address);
			$e->validate($address);
			$e->save();
		}

		return $e;
	}




	static public function runQueue()
	{
		$db = DB::getInstance();

		$queue = self::listQueueAndMarkAsSending();
		$ids = [];

		// listQueue nettoie déjà la queue
		foreach ($queue as $row) {
			// Don't send emails to opt-out address, unless it's a password reminder


			if ($row->context != self::CONTEXT_SYSTEM && $row->optout) {
				self::deleteFromQueue($row->id);
				continue;
			}

			// Create email address in database
			if (!$row->email_hash) {







>
>
>












>
>
>









>
>



>
>
>


|


>
>
>


>













>
>
>
|









>
>







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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197

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

	/**
	 * Return an Email entity from the optout code
	 */
	static public function getEmailFromOptout(string $code): ?Email
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
	}

	/**
	 * Sets the address as invalid (no email can be sent to this address ever)
	 */
	static public function markAddressAsInvalid(string $address): void
	{
		$e = self::getEmail($address);

		if (!$e) {
			return;
		}

		$e->set('invalid', true);
		$e->set('optout', false);
		$e->set('verified', false);
		$e->save();
	}

	/**
	 * Return an Email entity from an email address
	 */
	static public function getEmail(string $address): ?Email
	{
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address)));
	}

	/**
	 * Return or create a new email entity
	 */
	static public function getOrCreateEmail(string $address): Email
	{
		$address = strtolower($address);
		$e = self::getEmail($address);

		if (!$e) {
			$e = new Email;
			$e->added = new \DateTime;
			$e->hash = $e::getHash($address);
			$e->validate($address);
			$e->save();
		}

		return $e;
	}

	/**
	 * Run the queue of emails that are waiting to be sent
	 */
	static public function runQueue(): void
	{
		$db = DB::getInstance();

		$queue = self::listQueueAndMarkAsSending();
		$ids = [];

		// listQueue nettoie déjà la queue
		foreach ($queue as $row) {
			// Don't send emails to opt-out address, unless it's a password reminder
 			// Invalid and failed-too-many addresses are purged from the queue before processing, no need to handle them here
 			// We still allow emails to be sent to failed or optout addresses if it's a system email
			if ($row->context != self::CONTEXT_SYSTEM && $row->optout) {
				self::deleteFromQueue($row->id);
				continue;
			}

			// Create email address in database
			if (!$row->email_hash) {
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234

235
236
237
238
239
240
241


242

243
244
245
246
247
248
249
250
251
252
































253
254
255
256
257
258
259
			UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
			DELETE FROM emails_queue WHERE sending = 2;
		END;', $db->where('id', $ids), Email::TABLE));
	}

	/**
	 * Liste la file d'attente et marque les éléments listés comme "en cours de traitement"
	 * @return array
	 */
	static protected function listQueueAndMarkAsSending()
	{
		$queue = self::listQueue();

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids));

		return $queue;
	}

	/**
	 * Renvoie la liste des emails en attente d'envoi dans la queue,
	 * sauf ceux qui correspondent à des adresses bloquées.
	 *
	 * Ne pas utiliser pour les envois, sinon risque d'avoir plusieurs tâches

	 * qui envoient le me email !
	 * @return array
	 */
	static protected function listQueue(): array
	{
		// Nettoyage de la queue jà
		self::purgeQueueFromRejected();


		self::resetFailed();

		return DB::getInstance()->get('SELECT q.*, e.optout, e.verified, e.hash AS email_hash
			FROM emails_queue q
			LEFT JOIN emails e ON e.hash = q.recipient_hash
			WHERE q.sending = 0;');
	}

	static public function countQueue(): int
	{
		return DB::getInstance()->count('emails_queue');
	}

































	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'identity' => [







|


|




















|
<

<
>
|




|

>
>

>










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







223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254

255

256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
			UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
			DELETE FROM emails_queue WHERE sending = 2;
		END;', $db->where('id', $ids), Email::TABLE));
	}

	/**
	 * Lists the queue, marks listed elements as "sending"
	 * @return array
	 */
	static protected function listQueueAndMarkAsSending(): array
	{
		$queue = self::listQueue();

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids));

		return $queue;
	}

	/**
	 * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much

	 *

	 * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending
	 * the same email over and over.
	 * @return array
	 */
	static protected function listQueue(): array
	{
		// Clean-up the queue from reject emails
		self::purgeQueueFromRejected();

		// Reset messages that failed during the queue run
		self::resetFailed();

		return DB::getInstance()->get('SELECT q.*, e.optout, e.verified, e.hash AS email_hash
			FROM emails_queue q
			LEFT JOIN emails e ON e.hash = q.recipient_hash
			WHERE q.sending = 0;');
	}

	static public function countQueue(): int
	{
		return DB::getInstance()->count('emails_queue');
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function purgeQueueFromRejected(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails WHERE invalid = 1 OR fail_count >= ?)',
			self::FAIL_LIMIT);
	}

	/**
	 * If emails have been marked as sending but sending failed, mark them for resend after a while
	 */
	static protected function resetFailed(): void
	{
		$sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL
			WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 * @param  integer $id
	 * @return boolean
	 */
	static protected function deleteFromQueue($id)
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'identity' => [
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
		$conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		return $list;
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function purgeQueueFromRejected(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails WHERE invalid = 1 OR fail_count >= ?)',
			self::FAIL_LIMIT);
	}

	/**
	 * If emails have been marked as sending but sending failed, mark them for resend after a while
	 */
	static protected function resetFailed(): void
	{
		$sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL
			WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 * @param  integer $id
	 * @return boolean
	 */
	static protected function deleteFromQueue($id)
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html): void
	{
		$config = Config::getInstance();

		$message = new Mail_Message;
		$message->setHeaders($headers);








<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







355
356
357
358
359
360
361
































362
363
364
365
366
367
368
		$conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		return $list;
	}

































	static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html): void
	{
		$config = Config::getInstance();

		$message = new Mail_Message;
		$message->setHeaders($headers);

441
442
443
444
445
446
447



448
449
450
451
452
453
454
			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)) {







>
>
>







466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
			return;
		}

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

	/**
	 * Create a mass mailing
	 */
	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)) {
491
492
493
494
495
496
497



498
499
500
501
502
503
504
			'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)) {







>
>
>







519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
			'subject' => $subject,
			'html'    => $html,
		];

		return $message;
	}

	/**
	 * Send a mass mailing
	 */
	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)) {

Added src/scripts/handle_bounce.php version [5eda369c22].











































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

namespace Garradin;

use Garradin\Users\Emails;

require_once __DIR__ . '/../include/init.php';

if (PHP_SAPI != 'cli') {
	echo "This command can only be called from the command-line.\n";
	exit(1);
}

$message = file_get_contents('php://stdin');

if (empty($message)) {
	echo "No STDIN content was provided.\nPlease provide the email message on STDIN.\n";
	exit(2);
}

Emails::handleBounce($message);