From 9d262bbc4babbac8c58111e48aaac811d41a01c4 Mon Sep 17 00:00:00 2001
From: Benjamin Walker <benjaminwalker@catalyst-au.net>
Date: Fri, 22 Nov 2024 15:38:27 +1000
Subject: [PATCH 1/4] Add more specific bounce handling

---
 classes/check/bounces.php               |   3 +-
 classes/event/bounce_count_reset.php    |  58 ++++++
 classes/event/notification_received.php |   9 +-
 classes/event/over_bounce_threshold.php |  57 +++++
 classes/helper.php                      |  64 +++++-
 classes/sns_client.php                  |  22 +-
 classes/sns_notification.php            | 153 +++++++++++++-
 client.php                              |  27 +--
 lang/en/tool_emailutils.php             |   2 +
 reset_bounces.php                       |   3 +-
 tests/sns_client_test.php               | 266 ++++++++++++++++++++++++
 version.php                             |   4 +-
 12 files changed, 616 insertions(+), 52 deletions(-)
 create mode 100644 classes/event/bounce_count_reset.php
 create mode 100644 classes/event/over_bounce_threshold.php

diff --git a/classes/check/bounces.php b/classes/check/bounces.php
index c9c89b5..30c8ee6 100644
--- a/classes/check/bounces.php
+++ b/classes/check/bounces.php
@@ -69,8 +69,7 @@ public function get_result() : result {
                       FROM {user_preferences} up
                  LEFT JOIN {user_preferences} up2 ON up2.name = 'email_send_count' AND up.userid = up2.userid
                      WHERE up.name = 'email_bounce_count' AND CAST(up.value AS INTEGER) > :threshold";
-            // Start with a threshold of 1 as that is the default for manually created users.
-            // TODO: Update when core is fixed.
+            // Start with a threshold of 1 as that was a historical default for manually created users.
             $bounces = $DB->get_records_sql($sql, ['threshold' => 1]);
             $userswithbounces = count($bounces);
 
diff --git a/classes/event/bounce_count_reset.php b/classes/event/bounce_count_reset.php
new file mode 100644
index 0000000..4a13817
--- /dev/null
+++ b/classes/event/bounce_count_reset.php
@@ -0,0 +1,58 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Bounce count reset event
+ *
+ * @package   tool_emailutils
+ * @author    Benjamin Walker (benjaminwalker@catalyst-au.net)
+ * @copyright 2024 Catalyst IT
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_emailutils\event;
+
+/**
+ * Event
+ */
+class bounce_count_reset extends \core\event\base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init(): void {
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name(): string {
+        return get_string('event:bouncecountreset', 'tool_emailutils');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description(): string {
+        $reason = empty($this->userid) ? 'because an email was delivered successfully' : 'manually';
+        return "The user with id '$this->relateduserid' had their email bounce count reset $reason.";
+    }
+}
diff --git a/classes/event/notification_received.php b/classes/event/notification_received.php
index ab5c153..b385ac2 100644
--- a/classes/event/notification_received.php
+++ b/classes/event/notification_received.php
@@ -24,8 +24,6 @@
  */
 namespace tool_emailutils\event;
 
-use tool_emailutils;
-
 /**
  * Event
  */
@@ -34,10 +32,9 @@ class notification_received extends \core\event\base {
     /**
      * Initialise required event data properties.
      */
-    protected function init() {
+    protected function init(): void {
         $this->data['crud'] = 'u';
         $this->data['edulevel'] = self::LEVEL_OTHER;
-        $this->data['objecttable'] = 'user';
     }
 
     /**
@@ -45,7 +42,7 @@ protected function init() {
      *
      * @return string
      */
-    public static function get_name() {
+    public static function get_name(): string {
         return get_string('event:notificationreceived', 'tool_emailutils');
     }
 
@@ -54,7 +51,7 @@ public static function get_name() {
      *
      * @return string
      */
-    public function get_description() {
+    public function get_description(): mixed {
         return $this->other;
     }
 }
diff --git a/classes/event/over_bounce_threshold.php b/classes/event/over_bounce_threshold.php
new file mode 100644
index 0000000..e2a7f05
--- /dev/null
+++ b/classes/event/over_bounce_threshold.php
@@ -0,0 +1,57 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Over bounce threshold event
+ *
+ * @package   tool_emailutils
+ * @author    Benjamin Walker (benjaminwalker@catalyst-au.net)
+ * @copyright 2024 Catalyst IT
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_emailutils\event;
+
+/**
+ * Event
+ */
+class over_bounce_threshold extends \core\event\base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init(): void {
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name(): string {
+        return get_string('event:overbouncethreshold', 'tool_emailutils');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description(): string {
+        return "The user with id '$this->relateduserid' is now over the bounce threshold.";
+    }
+}
diff --git a/classes/helper.php b/classes/helper.php
index cb397e9..30a7637 100644
--- a/classes/helper.php
+++ b/classes/helper.php
@@ -26,6 +26,8 @@
 
 namespace tool_emailutils;
 
+use tool_emailutils\event\bounce_count_reset;
+
 /**
  * Helper class for tool_emailutils.
  *
@@ -67,7 +69,8 @@ public static function get_username_fields(string $tablealias = ''): string {
      */
     public static function get_min_bounces(): int {
         global $CFG;
-        return $CFG->minbounce ?? self::DEFAULT_MIN_BOUNCES;
+        // The core check for using the default uses empty().
+        return empty($CFG->minbounces) ? self::DEFAULT_MIN_BOUNCES : $CFG->minbounces;
     }
 
     /**
@@ -76,7 +79,64 @@ public static function get_min_bounces(): int {
      */
     public static function get_bounce_ratio(): float {
         global $CFG;
-        return $CFG->bounceratio ?? self::DEFAULT_BOUNCE_RATIO;
+        // The core check for using the default uses empty().
+        return empty($CFG->bounceratio) ? self::DEFAULT_BOUNCE_RATIO : $CFG->bounceratio;
+    }
+
+    /**
+     * Is a positive bounce ratio being used?
+     * @return bool use bounce ratio?
+     */
+    public static function use_bounce_ratio(): bool {
+        if (self::get_bounce_ratio() > 0) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Does the config imply the use of consecutive bounce count?
+     *
+     * Consecutive bounce counts should be used when possible as it's best practice.
+     * This requires a non-positive bounce ratio to ignore send count and pass threshold checks.
+     * Delivery notifications may also need to be set up to accurately track successful deliveries.
+     *
+     * @return bool use consecutive bounce count?
+     */
+    public static function use_consecutive_bounces(): bool {
+        return !self::use_bounce_ratio();
+    }
+
+
+    /**
+     * Resets the bounce count of a user and fires off an event.
+     * @param \stdClass $resetuser user who will have their bounce count reset
+     * @return void
+     */
+    public static function reset_bounce_count(\stdClass $resetuser): void {
+        global $USER;
+
+        // If bounce ratio is being used, the send count also needs to be reset.
+        $resetsendcount = self::use_bounce_ratio();
+
+        $bouncecount = get_user_preferences('email_bounce_count', null, $resetuser);
+        $sendcount = get_user_preferences('email_send_count', null, $resetuser);
+        if (!isset($bouncecount) && (!$resetsendcount || !isset($sendcount))) {
+            return;
+        }
+
+        // Swap to set_bounce_count($resetuser, true) once MDL-73798 is integrated.
+        unset_user_preference('email_bounce_count', $resetuser);
+        if ($resetsendcount) {
+            unset_user_preference('email_send_count', $resetuser);
+        }
+
+        $event = bounce_count_reset::create([
+            'userid' => $USER->id,
+            'relateduserid' => $resetuser->id,
+            'context'  => \context_system::instance(),
+        ]);
+        $event->trigger();
     }
 
     /**
diff --git a/classes/sns_client.php b/classes/sns_client.php
index b92bc54..367cfc1 100644
--- a/classes/sns_client.php
+++ b/classes/sns_client.php
@@ -64,6 +64,9 @@ class sns_client {
     /** Bounce */
     const BOUNCE_TYPE = 'Bounce';
 
+    /** Delivery */
+    const DELIVERY_TYPE = 'Delivery';
+
     /**
      * SNS Message Object
      * @var \Aws\Sns\Message
@@ -182,25 +185,28 @@ public function is_authorised() {
      * Validates the incoming message and sets up the sns_notification message.
      * This accepts message types of 'Notification'.
      *
+     * @param Message|null $message message injector for unit testing
      * @return bool Successful validtion
      */
-    public function process_message() {
+    public function process_message(?Message $message = null): bool {
         $this->validator = new MessageValidator();
         $this->client = new Client();
         $this->notification = new sns_notification();
 
         // Get the message from the POST data.
-        $this->message = Message::fromRawPostData();
+        $this->message = PHPUNIT_TEST && isset($message) ? $message : Message::fromRawPostData();
 
         $isvalidmessage = false;
 
         // Validate the incoming message.
-        try {
-            $this->validator->validate($this->message);
-        } catch (InvalidSnsMessageException $e) {
-            // Message not valid!
-            http_response_code(400); // Bad request.
-            return $isvalidmessage;
+        if (!PHPUNIT_TEST) {
+            try {
+                $this->validator->validate($this->message);
+            } catch (InvalidSnsMessageException $e) {
+                // Message not valid!
+                http_response_code(400); // Bad request.
+                return $isvalidmessage;
+            }
         }
 
         // Process the message depending on its type.
diff --git a/classes/sns_notification.php b/classes/sns_notification.php
index b497f92..bfd4b41 100644
--- a/classes/sns_notification.php
+++ b/classes/sns_notification.php
@@ -25,6 +25,9 @@
 
 namespace tool_emailutils;
 
+use tool_emailutils\event\notification_received;
+use tool_emailutils\event\over_bounce_threshold;
+
 /**
  * Amazon SNS Notification Class
  *
@@ -32,6 +35,21 @@
  */
 class sns_notification {
 
+    /** Bounce subtypes that should be blocked immediately */
+    const BLOCK_IMMEDIATELY = [
+        'Permanent:General',
+        'Permanent:NoEmail',
+        'Permanent:Suppressed',
+        'Permanent:OnAccountSuppressionList',
+    ];
+
+    /** Bounce subtypes that should be blocked after a few failures */
+    const BLOCK_SOFTLY = [
+        'Undetermined:Undetermined',
+        'Transient:General',
+        'Transient:MailboxFull',
+    ];
+
     /**
      * SNS Message
      * @var mixed
@@ -173,9 +191,9 @@ public function get_complaintsubtype(): string {
 
     /**
      * Returns all the message subtypes as an array
-     * @return string subtypes as a array
+     * @return string subtypes as a string, split by ':'
      */
-    protected function get_subtypes(): array {
+    protected function get_subtypes(): string {
         $subtypes = [];
         if ($this->is_complaint()) {
             $subtypes = [
@@ -188,7 +206,7 @@ protected function get_subtypes(): array {
                 $this->get_bouncesubtype(),
             ];
         }
-        return array_filter($subtypes);
+        return implode(':', array_filter($subtypes));
     }
 
     /**
@@ -207,6 +225,133 @@ public function is_bounce(): bool {
         return $this->get_type() === sns_client::BOUNCE_TYPE;
     }
 
+    /**
+     * Is the message about a delivery?
+     * @return bool Is delivery?
+     */
+    public function is_delivery(): bool {
+        return $this->get_type() === sns_client::DELIVERY_TYPE;
+    }
+
+    /**
+     * Does this bounce type imply this should be blocked immediately?
+     * @return bool block immediately?
+     */
+    public function should_block_immediately(): bool {
+        return in_array($this->get_subtypes(), self::BLOCK_IMMEDIATELY);
+    }
+
+    /**
+     * Does this bounce type imply this should be blocked softly?
+     * @return bool block softly?
+     */
+    public function should_block_softly(): bool {
+        return in_array($this->get_subtypes(), self::BLOCK_SOFTLY);
+    }
+
+    /**
+     * Processes a delivery notification
+     * @return void
+     */
+    protected function process_delivery_notification(): void {
+        global $DB;
+
+        // Only need to process notifications if consecutive bounce count is being used.
+        if (!helper::use_consecutive_bounces()) {
+            return;
+        }
+
+        $users = $DB->get_records('user', ['email' => $this->get_destination()], 'id ASC', 'id, email');
+        foreach ($users as $user) {
+            // Clear bounces on successful delivery when using consecutive bounce counts.
+            if (!empty(get_user_preferences('email_bounce_count', 0, $user))) {
+                helper::reset_bounce_count($user);
+            }
+        }
+    }
+
+    /**
+     * Processes a bounce notification based on the subtype
+     * @param \stdClass $user
+     * @return void
+     */
+    protected function process_bounce_notification(\stdClass $user): void {
+        if (over_bounce_threshold($user)) {
+            // Can occur if multiple notifications are received close together. No action required.
+            return;
+        }
+        $sendcount = get_user_preferences('email_send_count', 0, $user);
+        $bouncecount = get_user_preferences('email_bounce_count', 0, $user);
+
+        if ($this->should_block_immediately()) {
+            // User should only be able to recover from this if they change their email or have their bounces reset.
+            // This sets the bounce ratio to 1 to improve visibility when something is a hard bounce.
+            $bouncecount = max($sendcount, helper::get_min_bounces());
+            set_user_preference('email_bounce_count', $bouncecount, $user);
+        } else if ($this->should_block_softly()) {
+            // Swap back to set_bounce_count($user) once MDL-73798 is integrated.
+            $bouncecount++;
+            set_user_preference('email_bounce_count', $bouncecount, $user);
+        }
+
+        // If send count isn't set (bug prior to MDL-73798) we need to set a placeholder.
+        if (empty($sendcount) && !empty($bouncecount)) {
+            set_user_preference('email_send_count', $bouncecount, $user);
+        }
+
+        if (over_bounce_threshold($user)) {
+            $event = over_bounce_threshold::create([
+                'relateduserid' => $user->id,
+                'context'  => \context_system::instance(),
+            ]);
+            $event->trigger();
+        }
+    }
+
+    /**
+     * Processes a notification based on the type
+     * @return void
+     */
+    public function process_notification(): void {
+        global $DB;
+
+        if ($this->is_delivery()) {
+            $this->process_delivery_notification();
+            return;
+        }
+
+        if (!$this->is_complaint() && !$this->is_bounce()) {
+            // Invalid request. We should never be here.
+            http_response_code(400);
+            return;
+        }
+
+        // Allow for shared emails. Only create a notification for the first user, but process all.
+        $users = $DB->get_records('user', ['email' => $this->get_destination()], 'id ASC', 'id, email');
+        $user = reset($users);
+
+        // Log all bounces and complaints as an event, even if user is invalid.
+        $event = notification_received::create([
+            'relateduserid' => $user->id ?? null,
+            'context'  => \context_system::instance(),
+            'other' => $this->get_messageasstring(),
+        ]);
+        $event->trigger();
+
+        if (empty($users)) {
+            return;
+        }
+
+        if ($this->is_bounce()) {
+            // Ideally bounce handling would be tracked per email instead of user.
+            foreach ($users as $user) {
+                $this->process_bounce_notification($user);
+            }
+        }
+
+        // TODO: Implement complaint handling.
+    }
+
     /**
      * Return the message as a string
      * Eg. "Type about x from y"
@@ -215,7 +360,7 @@ public function is_bounce(): bool {
     public function get_messageasstring(): string {
         if ($this->is_complaint() || $this->is_bounce()) {
             $subtypes = $this->get_subtypes();
-            $subtypestring = !empty($subtypes) ? ' (' . implode(':', $subtypes) . ')' : '';
+            $subtypestring = !empty($subtypes) ? " ($subtypes)" : '';
             $type = $this->get_type() . $subtypestring;
             return $type . ' about ' . $this->get_source_email() . ' from ' . $this->get_destination();
         } else {
diff --git a/client.php b/client.php
index a374d83..a7d2c7e 100644
--- a/client.php
+++ b/client.php
@@ -28,7 +28,6 @@
  */
 
 use tool_emailutils\sns_client;
-use tool_emailutils\event\notification_received;
 
 define('NO_MOODLE_COOKIES', true);
 
@@ -46,30 +45,6 @@
 }
 
 if ($client->process_message() && $client->is_notification()) {
-    global $DB;
-
     $notification = $client->get_notification();
-
-    $user = $DB->get_record('user', ['email' => $notification->get_destination()], 'id, email');
-
-    if ($user) {
-        if ($notification->is_complaint()) {
-            $type = 'c';
-        } else if ($notification->is_bounce()) {
-            $type = 'b';
-        } else {
-            http_response_code(400); // Invalid request.
-            exit;
-        }
-
-        // Increment the user preference email_bounce_count.
-        set_bounce_count($user);
-
-        $event = notification_received::create([
-            'relateduserid' => $user->id,
-            'context'  => context_system::instance(),
-            'other' => $notification->get_messageasstring(),
-        ]);
-        $event->trigger();
-    }
+    $notification->process_notification();
 }
diff --git a/lang/en/tool_emailutils.php b/lang/en/tool_emailutils.php
index 64712be..91dcafa 100644
--- a/lang/en/tool_emailutils.php
+++ b/lang/en/tool_emailutils.php
@@ -73,7 +73,9 @@
 $string['enabled_help'] = 'Allow the plugin to process incoming messages';
 $string['enable_suppression_list'] = 'Enable email suppression list';
 $string['enable_suppression_list_desc'] = 'When enabled, the system will maintain a suppression list of email addresses that should not receive emails.';
+$string['event:bouncecountreset'] = 'Bounce count reset';
 $string['event:notificationreceived'] = 'AWS SNS notification received';
+$string['event:overbouncethreshold'] = 'Over bounce threshold';
 $string['header'] = 'Header';
 $string['header_help'] = 'HTTP Basic Auth Header';
 $string['incorrect_access'] = 'Incorrect access detected. For use only by AWS SNS.';
diff --git a/reset_bounces.php b/reset_bounces.php
index 98d6c60..9bee4f9 100644
--- a/reset_bounces.php
+++ b/reset_bounces.php
@@ -44,8 +44,7 @@
     list($in, $params) = $DB->get_in_or_equal($SESSION->bulk_users);
     $rs = $DB->get_recordset_select('user', "id $in", $params, '', 'id, ' . \tool_emailutils\helper::get_username_fields());
     foreach ($rs as $user) {
-        // Reset the user bounce count.
-        set_bounce_count($user, true);
+        \tool_emailutils\helper::reset_bounce_count($user);
     }
     $rs->close();
     echo $OUTPUT->box_start('generalbox', 'notice');
diff --git a/tests/sns_client_test.php b/tests/sns_client_test.php
index 279fe31..f04dc11 100644
--- a/tests/sns_client_test.php
+++ b/tests/sns_client_test.php
@@ -16,6 +16,12 @@
 
 namespace tool_emailutils;
 
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/../lib/aws-sns-message-validator/src/Message.php');
+
+use Aws\Sns\Message;
+
 /**
  * Tests for SNS client.
  *
@@ -25,6 +31,9 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class sns_client_test extends \advanced_testcase {
+    /**
+     * Test email for user in unit test */
+    const TEST_EMAIL = 'user@example.com';
 
     /**
      * Test required libs are installed.
@@ -38,4 +47,261 @@ public function test_lib(): void {
         $client = new \tool_emailutils\sns_client('', '', '');
         $client->process_message();
     }
+
+    /**
+     * Creates a mock message
+     * @param string $message message contents
+     * @return string string json encoded message
+     */
+    private function get_mock_message(string $message): string {
+        // @codingStandardsIgnoreStart
+        return json_encode([
+            "Type" => "Notification",
+            "MessageId" => "165545c9-2a5c-472c-8df2-7ff2be2b3b1b",
+            "TopicArn" => "arn:aws:sns:us-west-2:123456789012:MyTopic",
+            "Message" => $message,
+            "Timestamp" => "2012-04-26T20:45:04.751Z",
+            "SignatureVersion" => "1",
+            "Signature" => "EXAMPLEpH+DcEwjAPg8O9mY8dReBSwksfg2S7WKQcikcNKWLQjwu6A4VbeS0QHVCkhRS7fUQvi2egU3N858fiTDN6bkkOxYDVrY0Ad8L10Hs3zH81mtnPk5uvvolIC1CXGu43obcgFxeL3khZl8IKvO61GWB6jI9b5+gLPoBc1Q=",
+            "SigningCertURL" => "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem",
+            "UnsubscribeURL" => "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96"
+        ]);
+        // @codingStandardsIgnoreEnd
+    }
+
+    /**
+     * Creates a mock delivery notification
+     * @return \tool_emailutils\sns_notification
+     */
+    private function get_mock_delivery_notification(): sns_notification {
+        $client = new sns_client('', '', '');
+
+        // Create mock delivery notification using data from AWS example docs.
+        $mockdelivery = json_encode([
+            "notificationType" => "Delivery",
+            "delivery" => [
+                "timestamp" => "2012-05-25T14:59:35.605Z",
+                "processingTimeMillis" => 546,
+                "recipients" => [self::TEST_EMAIL],
+                "smtpResponse" => "250 ok:  Message 64111812 accepted",
+                "reportingMTA" => "a8-70.smtp-out.amazonses.com",
+                "remoteMtaIp" => "127.0.2.0",
+            ],
+            "mail" => [
+                "timestamp" => "2012-05-25T14:59:35.605Z",
+                "source" => "sender@example.com",
+                "destination" => [self::TEST_EMAIL]
+            ],
+        ]);
+
+        $mockmessage = $this->get_mock_message($mockdelivery);
+        $client->process_message(Message::fromJsonString($mockmessage));
+        return $client->get_notification();
+    }
+
+    /**
+     * Creates a mock bounce notification.
+     * @param string $bouncetype
+     * @param string $bouncesubtype
+     * @return \tool_emailutils\sns_notification mock bounce notification
+     */
+    private function get_mock_bounce_notification(string $bouncetype, string $bouncesubtype): sns_notification {
+        $client = new sns_client('', '', '');
+
+        // Create mock bounces using data from AWS example docs.
+        $mockbounce = json_encode([
+            "notificationType" => "Bounce",
+            "bounce" => [
+                "bounceType" => $bouncetype,
+                "bounceSubType" => $bouncesubtype,
+                "bouncedRecipients" => [
+                    [
+                        "status" => "5.0.0",
+                        "action" => "failed",
+                        "diagnosticCode" => "smtp; 550 user unknown",
+                        "emailAddress" => self::TEST_EMAIL,
+                    ],
+                ],
+                "timestamp" => "2012-05-25T14:59:38.605Z",
+                "feedbackId" => "000001378603176d-5a4b5ad9-6f30-4198-a8c3-b1eb0c270a1d-000000"
+            ],
+            "mail" => [
+                "timestamp" => "2012-05-25T14:59:35.605Z",
+                "source" => "sender@example.com",
+                "destination" => [self::TEST_EMAIL]
+            ],
+        ]);
+
+        $mockmessage = $this->get_mock_message($mockbounce);
+        $client->process_message(Message::fromJsonString($mockmessage));
+        return $client->get_notification();
+    }
+
+    /**
+     * Data provider for {@see test_email_bounce_threshold}
+     *
+     * @return array
+     */
+    public function bounce_processing_provider(): array {
+        // To be tested with minbounces of 3 and bounceratio of -1.
+        return [
+            'Block immediately with low send count' => [
+                'type' => 'Permanent',
+                'subtype' => 'General',
+                'notifications' => 1,
+                'sendcount' => 1,
+                'expectedbounces' => 3,
+                'overthreshold' => true,
+            ],
+            'Block immediately with high send count' => [
+                'type' => 'Permanent',
+                'subtype' => 'General',
+                'notifications' => 1,
+                'sendcount' => 100,
+                'expectedbounces' => 100,
+                'overthreshold' => true,
+            ],
+            'Block softly' => [
+                'type' => 'Transient',
+                'subtype' => 'General',
+                'notifications' => 1,
+                'sendcount' => 1,
+                'expectedbounces' => 1,
+                'overthreshold' => false,
+            ],
+            'Multiple block softly' => [
+                'type' => 'Transient',
+                'subtype' => 'General',
+                'notifications' => 3,
+                'sendcount' => 3,
+                'expectedbounces' => 3,
+                'overthreshold' => true,
+            ],
+            'Extra block softly' => [
+                'type' => 'Transient',
+                'subtype' => 'General',
+                'notifications' => 4,
+                'sendcount' => 4,
+                'expectedbounces' => 3,
+                'overthreshold' => true,
+            ],
+            'Do nothing' => [
+                'type' => 'Transient',
+                'subtype' => 'AttachmentRejected',
+                'notifications' => 1,
+                'sendcount' => 1,
+                'expectedbounces' => 0,
+                'overthreshold' => false,
+            ],
+            'Divide by 0 sendcount' => [
+                'type' => 'Permanent',
+                'subtype' => 'General',
+                'notifications' => 1,
+                'sendcount' => 0,
+                'expectedbounces' => 3,
+                'overthreshold' => true,
+            ],
+        ];
+    }
+
+    /**
+     * Tests the email bounce thresholds
+     *
+     * @dataProvider bounce_processing_provider
+     * @param string $type bounce type
+     * @param string $subtype bounce subtype
+     * @param int $notifications the number of notifications to process
+     * @param int $sendcount the email send count
+     * @param int $expectedbounces expected bounce conut
+     * @param bool $overthreshold expected to be over the threshold
+     * @covers \tool_emailutils\sns_notification::process_notification()
+     **/
+    public function test_bounce_processing(string $type, string $subtype, int $notifications, int $sendcount,
+            int $expectedbounces, bool $overthreshold): void {
+        global $CFG;
+
+        // Setup config and users.
+        $this->resetAfterTest();
+        $CFG->handlebounces = true;
+        $CFG->minbounces = 3;
+        $CFG->bounceratio = -1;
+        $CFG->allowaccountssameemail = true;
+
+        $user1 = $this->getDataGenerator()->create_user(['email' => self::TEST_EMAIL]);
+        $user2 = $this->getDataGenerator()->create_user(['email' => self::TEST_EMAIL]);
+        set_user_preference('email_send_count', $sendcount, $user1->id);
+        set_user_preference('email_send_count', $sendcount, $user2->id);
+
+        // Process notification and store events.
+        $sink = $this->redirectEvents();
+        $notification = $this->get_mock_bounce_notification($type, $subtype);
+        for ($i = 0; $i < $notifications; $i++) {
+            $notification->process_notification();
+        }
+        $events = $sink->get_events();
+        $sink->close();
+
+        // Confirm bouncecount and over threshold.
+        $this->assertEquals($expectedbounces, get_user_preferences('email_bounce_count', 0, $user1));
+        $this->assertSame($overthreshold, over_bounce_threshold($user1));
+
+        // There should be one event for each bounce notification plus one threshold event per user over the threshold.
+        $bounceevents = 2 * (int) $overthreshold;
+        $this->assertCount($notifications + $bounceevents, $events);
+
+        // Confirm that shared email addresses have the same status.
+        $this->assertSame($overthreshold, over_bounce_threshold($user2));
+    }
+
+    /**
+     * Tests the email delivery processing
+     *
+     * @covers \tool_emailutils\sns_notification::process_notification()
+     **/
+    public function test_delivery_processing(): void {
+        global $CFG;
+
+        // Setup config and users.
+        $this->resetAfterTest();
+        $CFG->handlebounces = true;
+        $CFG->minbounces = 3;
+        $CFG->bounceratio = -1;
+        $CFG->allowaccountssameemail = true;
+
+        $user1 = $this->getDataGenerator()->create_user(['email' => self::TEST_EMAIL]);
+        $user2 = $this->getDataGenerator()->create_user(['email' => self::TEST_EMAIL]);
+        $user3 = $this->getDataGenerator()->create_user(['email' => self::TEST_EMAIL]);
+
+        // Testing 3 scenarios with a single notification - above minbounces, below minbounces and no bounces.
+        set_user_preference('email_bounce_count', 5, $user1->id);
+        set_user_preference('email_bounce_count', 2, $user2->id);
+        set_user_preference('email_bounce_count', 0, $user3->id);
+
+        $this->assertTrue(helper::use_consecutive_bounces());
+
+        // Process notification and store events.
+        $sink = $this->redirectEvents();
+        $notification = $this->get_mock_delivery_notification();
+        $notification->process_notification();
+
+        $events = $sink->get_events();
+        $sink->close();
+
+        // Confirm both were reset.
+        $this->assertEquals(0, get_user_preferences('email_bounce_count', 0, $user1->id));
+        $this->assertEquals(0, get_user_preferences('email_bounce_count', 0, $user2->id));
+        $this->assertFalse(over_bounce_threshold($user1));
+        $this->assertFalse(over_bounce_threshold($user2));
+
+        // There should be one event for each user who had their bounce count reset.
+        // This also confirms the third user didn't have their count reset.
+        $this->assertCount(2, $events);
+
+        // Ensure bounces aren't reset when bounce ratio config is positive.
+        $CFG->bounceratio = 0.5;
+        $this->assertFalse(helper::use_consecutive_bounces());
+        set_user_preference('email_bounce_count', 5, $user1->id);
+        $notification->process_notification();
+        $this->assertEquals(5, get_user_preferences('email_bounce_count', 0, $user1->id));
+    }
 }
diff --git a/version.php b/version.php
index ce1d0e1..1e075a7 100644
--- a/version.php
+++ b/version.php
@@ -25,8 +25,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2024111801;
-$plugin->release  = 2024111801;
+$plugin->version  = 2024112200;
+$plugin->release  = 2024112200;
 $plugin->requires = 2024042200;
 $plugin->component = 'tool_emailutils';
 $plugin->maturity  = MATURITY_STABLE;

From 215f9c2644eb7a56466cdc1074d769b178225db9 Mon Sep 17 00:00:00 2001
From: Benjamin Walker <benjaminwalker@catalyst-au.net>
Date: Wed, 27 Nov 2024 10:37:47 +1000
Subject: [PATCH 2/4] Add reset bounces action to bounces report

---
 bounces.php                                   | 24 +++++++++++-
 classes/hook_callbacks.php                    |  2 +-
 .../local/systemreports/email_bounces.php     | 28 +++++++++++++
 lang/en/tool_emailutils.php                   |  5 ++-
 reset_bounces.php                             | 39 +++++++++++++------
 5 files changed, 83 insertions(+), 15 deletions(-)

diff --git a/bounces.php b/bounces.php
index e8c3688..f73655a 100644
--- a/bounces.php
+++ b/bounces.php
@@ -30,6 +30,8 @@
 
 require(__DIR__.'/../../../config.php');
 require_once($CFG->libdir.'/adminlib.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/user/lib.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/user/user_bulk_forms.php');
 
 admin_externalpage_setup('tool_emailutils_bounces', '', [], '', ['pagelayout' => 'report']);
 
@@ -47,8 +49,28 @@
     ]), 'info');
 }
 
-$report = system_report_factory::create(email_bounces::class, context_system::instance());
+echo html_writer::start_div('', ['data-region' => 'report-user-list-wrapper']);
+
+// Exclude all actions besides reset bounces.
+$actionames = array_keys((new user_bulk_action_form())->get_actions());
+$excludeactions = array_diff($actionames, ['tool_emailutils_reset_bounces']);
+
+$bulkactions = new user_bulk_action_form(new moodle_url('/admin/user/user_bulk.php'),
+    ['excludeactions' => $excludeactions, 'passuserids' => true, 'hidesubmit' => true],
+    'post', '',
+    ['id' => 'user-bulk-action-form']);
+$bulkactions->set_data(['returnurl' => $PAGE->url->out_as_local_url(false)]);
+
+$report = system_report_factory::create(email_bounces::class, context_system::instance(),
+    parameters: ['withcheckboxes' => $bulkactions->has_bulk_actions()]);
 
 echo $report->output();
 
+if ($bulkactions->has_bulk_actions()) {
+    $PAGE->requires->js_call_amd('core_admin/bulk_user_actions', 'init');
+    $bulkactions->display();
+}
+
+echo html_writer::end_div();
+
 echo $OUTPUT->footer();
diff --git a/classes/hook_callbacks.php b/classes/hook_callbacks.php
index 87b399f..4279a14 100644
--- a/classes/hook_callbacks.php
+++ b/classes/hook_callbacks.php
@@ -33,7 +33,7 @@ class hook_callbacks {
      */
     public static function extend_bulk_user_actions(\core_user\hook\extend_bulk_user_actions $hook): void {
         if (has_capability('moodle/site:config', \context_system::instance())) {
-            $hook->add_action('tool_ses_reset_bounces', new \action_link(
+            $hook->add_action('tool_emailutils_reset_bounces', new \action_link(
                 new \moodle_url('/admin/tool/emailutils/reset_bounces.php'),
                 get_string('resetbounces', 'tool_emailutils')
             ));
diff --git a/classes/reportbuilder/local/systemreports/email_bounces.php b/classes/reportbuilder/local/systemreports/email_bounces.php
index 721a38d..6854519 100644
--- a/classes/reportbuilder/local/systemreports/email_bounces.php
+++ b/classes/reportbuilder/local/systemreports/email_bounces.php
@@ -20,6 +20,7 @@
 use tool_emailutils\reportbuilder\local\entities\email_bounce;
 use core_reportbuilder\system_report;
 use core_reportbuilder\local\entities\user;
+use core_reportbuilder\local\report\action;
 
 /**
  * Email bounces report class implementation.
@@ -44,6 +45,16 @@ protected function initialise(): void {
         $this->add_entity($entitymain);
         $this->add_base_condition_simple("{$entitymainalias}.name", 'email_bounce_count');
 
+        // Any columns required by actions should be defined here to ensure they're always available.
+        $this->add_base_fields("{$entitymainalias}.userid");
+
+        if ($this->get_parameter('withcheckboxes', false, PARAM_BOOL)) {
+            $canviewfullnames = has_capability('moodle/site:viewfullnames', context_system::instance());
+            $this->set_checkbox_toggleall(static function(\stdClass $row) use ($canviewfullnames): array {
+                return [$row->userid, fullname($row, $canviewfullnames)];
+            });
+        }
+
         // We can join the "user" entity to our "main" entity using standard SQL JOIN.
         $entityuser = new user();
         $entityuseralias = $entityuser->get_table_alias('user');
@@ -54,6 +65,7 @@ protected function initialise(): void {
         // Now we can call our helper methods to add the content we want to include in the report.
         $this->add_columns();
         $this->add_filters();
+        $this->add_actions();
 
         // Set if report can be downloaded.
         $this->set_downloadable(true, get_string('reportbounces', 'tool_emailutils'));
@@ -107,4 +119,20 @@ protected function add_filters(): void {
 
         $this->add_filters_from_entities($filters);
     }
+
+    /**
+     * Add the system report actions. An extra column will be appended to each row, containing all actions added here
+     *
+     * Note the use of ":id" placeholder which will be substituted according to actual values in the row
+     */
+    protected function add_actions(): void {
+        // Action to reset the bounce count.
+        $this->add_action((new action(
+            new \moodle_url('/admin/tool/emailutils/reset_bounces.php', ['id' => ':userid']),
+            new \pix_icon('i/reload', ''),
+            [],
+            false,
+            new \lang_string('resetbounces', 'tool_emailutils'),
+        )));
+    }
 }
diff --git a/lang/en/tool_emailutils.php b/lang/en/tool_emailutils.php
index 91dcafa..14a0162 100644
--- a/lang/en/tool_emailutils.php
+++ b/lang/en/tool_emailutils.php
@@ -93,8 +93,9 @@
 $string['privacy:metadata:tool_emailutils_list'] = 'Information.';
 $string['privacy:metadata:tool_emailutils_list:updatedid'] = 'The ID of updated user.';
 $string['privacy:metadata:tool_emailutils_list:userid'] = 'The ID of the user.';
-$string['reportbounces'] = 'Email bounces report';
-$string['resetbounces'] = 'Reset the number of bounces';
+$string['reportbounces'] = 'Email bounces';
+$string['resetbounceratio'] = 'A bounce ratio is being used, so resetting the bounce count will also reset the send count.';
+$string['resetbounces'] = 'Reset bounce count';
 $string['selectoractivate'] = 'Activate key pair';
 $string['selectoractivateconfirm'] = 'This will set $CFG->emaildkimselector to this selector and it will be used for signing outgoing emails.';
 $string['selectoractive'] = 'Active selector';
diff --git a/reset_bounces.php b/reset_bounces.php
index 9bee4f9..9d2625a 100644
--- a/reset_bounces.php
+++ b/reset_bounces.php
@@ -26,14 +26,17 @@
 require_once(__DIR__ . '/../../../config.php');
 require_once($CFG->libdir . '/adminlib.php');
 
-admin_externalpage_setup('userbulk');
+admin_externalpage_setup('tool_emailutils_bounces');
 require_capability('moodle/user:update', context_system::instance());
 
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
+$userid = optional_param('id', null, PARAM_INT);
+$returnurl = optional_param('returnurl', null, PARAM_LOCALURL);
 
-$return = new moodle_url('/admin/user/user_bulk.php');
+$users = empty($userid) ? $SESSION->bulk_users : [$userid];
+$return = new moodle_url($returnurl ?? '/admin/tool/emailutils/bounces.php');
 
-if (empty($SESSION->bulk_users)) {
+if (empty($users)) {
     redirect($return);
 }
 
@@ -41,7 +44,7 @@
 echo $OUTPUT->heading(get_string('resetbounces', 'tool_emailutils'));
 
 if ($confirm && confirm_sesskey()) {
-    list($in, $params) = $DB->get_in_or_equal($SESSION->bulk_users);
+    list($in, $params) = $DB->get_in_or_equal($users);
     $rs = $DB->get_recordset_select('user', "id $in", $params, '', 'id, ' . \tool_emailutils\helper::get_username_fields());
     foreach ($rs as $user) {
         \tool_emailutils\helper::reset_bounce_count($user);
@@ -50,16 +53,30 @@
     echo $OUTPUT->box_start('generalbox', 'notice');
     echo $OUTPUT->notification(get_string('bouncesreset', 'tool_emailutils'), 'notifysuccess');
 
-    $continue = new single_button(new moodle_url($return), get_string('continue'), 'post');
+    $continue = new single_button(new moodle_url($return), get_string('continue'));
     echo $OUTPUT->render($continue);
     echo $OUTPUT->box_end();
 } else {
-    list($in, $params) = $DB->get_in_or_equal($SESSION->bulk_users);
+    list($in, $params) = $DB->get_in_or_equal($users);
     $userlist = $DB->get_records_select_menu('user', "id $in", $params, 'fullname', 'id,'.$DB->sql_fullname().' AS fullname');
-    $usernames = implode(', ', $userlist);
-    echo $OUTPUT->heading(get_string('confirmation', 'admin'));
-    $formcontinue = new single_button(new moodle_url('reset_bounces.php', ['confirm' => 1]), get_string('yes'));
-    $formcancel = new single_button(new moodle_url('/admin/user/user_bulk.php'), get_string('no'), 'get');
-    echo $OUTPUT->confirm(get_string('bouncecheckfull', 'tool_emailutils', $usernames), $formcontinue, $formcancel);
+
+    if (empty($userlist)) {
+        echo $OUTPUT->notification(get_string('invaliduserid', 'error'));
+        $continue = new single_button(new moodle_url($return), get_string('continue'));
+        echo $OUTPUT->render($continue);
+    } else {
+        if (\tool_emailutils\helper::use_bounce_ratio()) {
+            echo $OUTPUT->notification(get_string('resetbounceratio', 'tool_emailutils'), 'warning');
+        }
+        $usernames = implode(', ', $userlist);
+        $params = array_filter([
+            'confirm' => 1,
+            'id' => $userid,
+            'returnurl' => $returnurl,
+        ]);
+        $formcontinue = new single_button(new moodle_url('reset_bounces.php', $params), get_string('yes'));
+        $formcancel = new single_button($return, get_string('no'));
+        echo $OUTPUT->confirm(get_string('bouncecheckfull', 'tool_emailutils', $usernames), $formcontinue, $formcancel);
+    }
 }
 echo $OUTPUT->footer();

From 9d81c12f4bedac59fbf9f3c327a1fc2dd0886429 Mon Sep 17 00:00:00 2001
From: Benjamin Walker <benjaminwalker@catalyst-au.net>
Date: Thu, 28 Nov 2024 16:08:56 +1000
Subject: [PATCH 3/4] Store notifications in emailutils log

---
 classes/helper.php           | 76 ++++++++++++++++++++++++++++++++++++
 classes/sns_notification.php | 17 +++++++-
 db/install.xml               |  4 +-
 db/upgrade.php               | 24 ++++++++++++
 tests/sns_client_test.php    | 22 +++++------
 version.php                  |  4 +-
 6 files changed, 131 insertions(+), 16 deletions(-)

diff --git a/classes/helper.php b/classes/helper.php
index 30a7637..c74947b 100644
--- a/classes/helper.php
+++ b/classes/helper.php
@@ -151,4 +151,80 @@ public static function get_bounce_config(): array {
             self::get_bounce_ratio(),
         ];
     }
+
+    /**
+     * Get the most recent bounce recorded for an email address
+     *
+     * @param string $email
+     * @return \stdClass|false
+     */
+    public static function get_most_recent_bounce(string $email): mixed {
+        global $DB;
+
+        $params = [
+            'email' => $email,
+        ];
+        $sql = "SELECT *
+                  FROM {tool_emailutils_log}
+                 WHERE type = 'Bounce' AND email = :email
+                 ORDER BY time DESC
+                 LIMIT 1";
+        return $DB->get_record_sql($sql, $params);
+    }
+
+    /**
+     * Gets the sum of the send count for multiple users
+     * @param array $users array of users or userids
+     * @return int sum of send count
+     */
+    public static function get_sum_send_count(array $users): int {
+        $sendcount = 0;
+        foreach ($users as $user) {
+            $sendcount += get_user_preferences('email_send_count', 0, $user);
+        }
+        return $sendcount;
+    }
+
+    /**
+     * Gets the max bounce count from a group of users
+     * @param array $users array of users or userids
+     * @return int max bounce count
+     */
+    public static function get_max_bounce_count(array $users): int {
+        $maxbounces = 0;
+        foreach ($users as $user) {
+            $maxbounces = max($maxbounces, get_user_preferences('email_bounce_count', 0, $user));
+        }
+        return $maxbounces;
+    }
+
+    /**
+     * Checks whether bounces are likely to be consecutive, and if not reset the bounce count.
+     * This is a fallback in case delivery notifications are not enabled.
+     *
+     * @param string $email
+     * @param array $users
+     * @return void
+     */
+    public static function check_consecutive_bounces(string $email, array $users): void {
+        $recentbounce = self::get_most_recent_bounce($email);
+        if (empty($recentbounce)) {
+            // No data on the previous bounce.
+            return;
+        }
+
+        $sendcount = self::get_sum_send_count($users);
+        $prevsendcount = $recentbounce->sendcount ?? 0;
+        $sincelastbounce = $sendcount - $prevsendcount;
+
+        // The only things we can compare are the previous send count and time.
+        // A direct comparison in sendcount isn't accurate because notifications may be delayed, so use a buffer.
+        // Minbounces is ideal since future notifications would push it over the threshold.
+        $buffer = min(self::get_min_bounces(), 5);
+        if ($sincelastbounce >= $buffer) {
+            foreach ($users as $user) {
+                self::reset_bounce_count($user);
+            }
+        }
+    }
 }
diff --git a/classes/sns_notification.php b/classes/sns_notification.php
index bfd4b41..2536900 100644
--- a/classes/sns_notification.php
+++ b/classes/sns_notification.php
@@ -285,8 +285,7 @@ protected function process_bounce_notification(\stdClass $user): void {
 
         if ($this->should_block_immediately()) {
             // User should only be able to recover from this if they change their email or have their bounces reset.
-            // This sets the bounce ratio to 1 to improve visibility when something is a hard bounce.
-            $bouncecount = max($sendcount, helper::get_min_bounces());
+            $bouncecount = helper::get_min_bounces();
             set_user_preference('email_bounce_count', $bouncecount, $user);
         } else if ($this->should_block_softly()) {
             // Swap back to set_bounce_count($user) once MDL-73798 is integrated.
@@ -344,12 +343,26 @@ public function process_notification(): void {
 
         if ($this->is_bounce()) {
             // Ideally bounce handling would be tracked per email instead of user.
+            if (helper::use_consecutive_bounces()) {
+                helper::check_consecutive_bounces($this->get_destination(), $users);
+            }
             foreach ($users as $user) {
                 $this->process_bounce_notification($user);
             }
         }
 
         // TODO: Implement complaint handling.
+
+        // Save to emailutils log. This should be done last as processing may increase send count.
+        // Time should represent when this was processed - send time will be in message if needed.
+        $DB->insert_record('tool_emailutils_log', [
+            'time' => time(),
+            'type' => $this->get_type(),
+            'subtypes' => $this->get_subtypes(),
+            'email' => $this->get_destination(),
+            'message' => $this->messageraw,
+            'sendcount' => helper::get_sum_send_count($users),
+        ]);
     }
 
     /**
diff --git a/db/install.xml b/db/install.xml
index 3d98ded..fc05989 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/emailutils/db" VERSION="20241114" COMMENT="XMLDB file for plugin admin/tool/emailutils"
+<XMLDB PATH="admin/tool/emailutils/db" VERSION="20241128" COMMENT="XMLDB file for plugin admin/tool/emailutils"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -9,8 +9,10 @@
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="time" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="type" TYPE="char" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="subtypes" TYPE="char" LENGTH="32" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="email" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="message" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="sendcount" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
diff --git a/db/upgrade.php b/db/upgrade.php
index 2fbeefe..b049d5c 100644
--- a/db/upgrade.php
+++ b/db/upgrade.php
@@ -78,5 +78,29 @@ function xmldb_tool_emailutils_upgrade($oldversion) {
         // Emailutils savepoint reached.
         upgrade_plugin_savepoint(true, 2024111800, 'tool', 'emailutils');
     }
+
+    if ($oldversion < 2024112801) {
+
+        // Define field subtypes to be added to tool_emailutils_log.
+        $table = new xmldb_table('tool_emailutils_log');
+        $field = new xmldb_field('subtypes', XMLDB_TYPE_CHAR, '32', null, null, null, null, 'type');
+
+        // Conditionally launch add field subtypes.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field sendcount to be added to tool_emailutils_log.
+        $field = new xmldb_field('sendcount', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'message');
+
+        // Conditionally launch add field sendcount.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Emailutils savepoint reached.
+        upgrade_plugin_savepoint(true, 2024112801, 'tool', 'emailutils');
+    }
+
     return true;
 }
diff --git a/tests/sns_client_test.php b/tests/sns_client_test.php
index f04dc11..d324c49 100644
--- a/tests/sns_client_test.php
+++ b/tests/sns_client_test.php
@@ -145,7 +145,7 @@ private function get_mock_bounce_notification(string $bouncetype, string $bounce
     public function bounce_processing_provider(): array {
         // To be tested with minbounces of 3 and bounceratio of -1.
         return [
-            'Block immediately with low send count' => [
+            'Block immediately' => [
                 'type' => 'Permanent',
                 'subtype' => 'General',
                 'notifications' => 1,
@@ -153,14 +153,6 @@ public function bounce_processing_provider(): array {
                 'expectedbounces' => 3,
                 'overthreshold' => true,
             ],
-            'Block immediately with high send count' => [
-                'type' => 'Permanent',
-                'subtype' => 'General',
-                'notifications' => 1,
-                'sendcount' => 100,
-                'expectedbounces' => 100,
-                'overthreshold' => true,
-            ],
             'Block softly' => [
                 'type' => 'Transient',
                 'subtype' => 'General',
@@ -218,7 +210,7 @@ public function bounce_processing_provider(): array {
      **/
     public function test_bounce_processing(string $type, string $subtype, int $notifications, int $sendcount,
             int $expectedbounces, bool $overthreshold): void {
-        global $CFG;
+        global $CFG, $DB;
 
         // Setup config and users.
         $this->resetAfterTest();
@@ -249,6 +241,10 @@ public function test_bounce_processing(string $type, string $subtype, int $notif
         $bounceevents = 2 * (int) $overthreshold;
         $this->assertCount($notifications + $bounceevents, $events);
 
+        // Confirm bounce notification stored in emailutils log table.
+        $records = $DB->get_records('tool_emailutils_log', null, 'id ASC');
+        $this->assertCount($notifications, $records);
+
         // Confirm that shared email addresses have the same status.
         $this->assertSame($overthreshold, over_bounce_threshold($user2));
     }
@@ -259,7 +255,7 @@ public function test_bounce_processing(string $type, string $subtype, int $notif
      * @covers \tool_emailutils\sns_notification::process_notification()
      **/
     public function test_delivery_processing(): void {
-        global $CFG;
+        global $CFG, $DB;
 
         // Setup config and users.
         $this->resetAfterTest();
@@ -297,6 +293,10 @@ public function test_delivery_processing(): void {
         // This also confirms the third user didn't have their count reset.
         $this->assertCount(2, $events);
 
+        // Confirm delivery notification isn't stored in emailutils log table.
+        $records = $DB->get_records('tool_emailutils_log');
+        $this->assertCount(0, $records);
+
         // Ensure bounces aren't reset when bounce ratio config is positive.
         $CFG->bounceratio = 0.5;
         $this->assertFalse(helper::use_consecutive_bounces());
diff --git a/version.php b/version.php
index 1e075a7..335cf76 100644
--- a/version.php
+++ b/version.php
@@ -25,8 +25,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2024112200;
-$plugin->release  = 2024112200;
+$plugin->version  = 2024112801;
+$plugin->release  = 2024112801;
 $plugin->requires = 2024042200;
 $plugin->component = 'tool_emailutils';
 $plugin->maturity  = MATURITY_STABLE;

From 872cd93ccbdbdb1d2186ca3f32c6c2af7c415176 Mon Sep 17 00:00:00 2001
From: Benjamin Walker <benjaminwalker@catalyst-au.net>
Date: Thu, 28 Nov 2024 16:13:26 +1000
Subject: [PATCH 4/4] Add bounce types to bounces table

---
 .../local/entities/notification_log.php       | 192 ++++++++++++++++++
 .../local/systemreports/email_bounces.php     |  17 ++
 lang/en/tool_emailutils.php                   |   3 +
 3 files changed, 212 insertions(+)
 create mode 100644 classes/reportbuilder/local/entities/notification_log.php

diff --git a/classes/reportbuilder/local/entities/notification_log.php b/classes/reportbuilder/local/entities/notification_log.php
new file mode 100644
index 0000000..1f2fc29
--- /dev/null
+++ b/classes/reportbuilder/local/entities/notification_log.php
@@ -0,0 +1,192 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace tool_emailutils\reportbuilder\local\entities;
+
+use lang_string;
+use core_reportbuilder\local\entities\base;
+use core_reportbuilder\local\filters\date;
+use core_reportbuilder\local\filters\text;
+use core_reportbuilder\local\helpers\format;
+use core_reportbuilder\local\report\column;
+use core_reportbuilder\local\report\filter;
+
+/**
+ * Notification log list entity class class implementation.
+ *
+ * Defines all the columns and filters that can be added to reports that use this entity.
+ *
+ * @package    tool_emailutils
+ * @author     Benjamin Walker <benjaminwalker@catalyst-au.net>
+ * @copyright  Catalyst IT 2024
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ */
+class notification_log extends base {
+
+    /**
+     * Database tables that this entity uses
+     *
+     * @return string[]
+     */
+    protected function get_default_tables(): array {
+        return [
+            'tool_emailutils_log',
+        ];
+    }
+
+    /**
+     * The default title for this entity
+     *
+     * @return lang_string
+     */
+    protected function get_default_entity_title(): lang_string {
+        return new lang_string('notificationlog', 'tool_emailutils');
+    }
+
+    /**
+     * Initialize the entity
+     *
+     * @return base
+     */
+    public function initialise(): base {
+        $columns = $this->get_all_columns();
+        foreach ($columns as $column) {
+            $this->add_column($column);
+        }
+
+        $filters = $this->get_all_filters();
+        foreach ($filters as $filter) {
+            $this->add_filter($filter);
+            $this->add_condition($filter);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Returns list of all available columns
+     *
+     * @return column[]
+     */
+    protected function get_all_columns(): array {
+        $tablealias = $this->get_table_alias('tool_emailutils_log');
+
+        // Email column.
+        $columns[] = (new column(
+            'email',
+            new lang_string('email'),
+            $this->get_entity_name()
+        ))
+            ->add_joins($this->get_joins())
+            ->set_type(column::TYPE_TEXT)
+            ->add_fields("{$tablealias}.email")
+            ->set_is_sortable(true);
+
+        // Type column.
+        $columns[] = (new column(
+            'type',
+            new lang_string('type', 'tool_emailutils'),
+            $this->get_entity_name()
+        ))
+            ->add_joins($this->get_joins())
+            ->set_type(column::TYPE_TEXT)
+            ->add_fields("{$tablealias}.type")
+            ->set_is_sortable(true);
+
+        // Subtypes column.
+        $columns[] = (new column(
+            'subtypes',
+            new lang_string('subtypes', 'tool_emailutils'),
+            $this->get_entity_name()
+        ))
+            ->add_joins($this->get_joins())
+            ->set_type(column::TYPE_TEXT)
+            ->add_fields("{$tablealias}.subtypes")
+            ->set_is_sortable(true);
+
+        // Time column.
+        $columns[] = (new column(
+            'time',
+            new lang_string('time'),
+            $this->get_entity_name()
+        ))
+            ->add_joins($this->get_joins())
+            ->set_type(column::TYPE_TIMESTAMP)
+            ->add_fields("{$tablealias}.time")
+            ->set_is_sortable(true)
+            ->add_callback([format::class, 'userdate'], get_string('strftimedatetimeshortaccurate', 'core_langconfig'));
+
+        return $columns;
+    }
+
+    /**
+     * Return list of all available filters
+     *
+     * @return filter[]
+     */
+    protected function get_all_filters(): array {
+        $tablealias = $this->get_table_alias('tool_emailutils_log');
+
+        // Email filter.
+        $filters[] = (new filter(
+            text::class,
+            'email',
+            new lang_string('email'),
+            $this->get_entity_name(),
+            "{$tablealias}.email"
+        ))
+            ->add_joins($this->get_joins());
+
+        // Type filter.
+        $filters[] = (new filter(
+            text::class,
+            'type',
+            new lang_string('type', 'tool_emailutils'),
+            $this->get_entity_name(),
+            "{$tablealias}.type"
+        ))
+            ->add_joins($this->get_joins());
+
+        // Subtypes filter.
+        $filters[] = (new filter(
+            text::class,
+            'subtypes',
+            new lang_string('subtypes', 'tool_emailutils'),
+            $this->get_entity_name(),
+            "{$tablealias}.subtypes"
+        ))
+            ->add_joins($this->get_joins());
+
+        // Time filter.
+        $filters[] = (new filter(
+            date::class,
+            'time',
+            new lang_string('time'),
+            $this->get_entity_name(),
+            "{$tablealias}.time"
+        ))
+            ->add_joins($this->get_joins())
+            ->set_limited_operators([
+                date::DATE_ANY,
+                date::DATE_RANGE,
+                date::DATE_PREVIOUS,
+                date::DATE_CURRENT,
+            ]);
+
+        return $filters;
+    }
+}
diff --git a/classes/reportbuilder/local/systemreports/email_bounces.php b/classes/reportbuilder/local/systemreports/email_bounces.php
index 6854519..b5c3b0c 100644
--- a/classes/reportbuilder/local/systemreports/email_bounces.php
+++ b/classes/reportbuilder/local/systemreports/email_bounces.php
@@ -18,6 +18,7 @@
 
 use context_system;
 use tool_emailutils\reportbuilder\local\entities\email_bounce;
+use tool_emailutils\reportbuilder\local\entities\notification_log;
 use core_reportbuilder\system_report;
 use core_reportbuilder\local\entities\user;
 use core_reportbuilder\local\report\action;
@@ -62,6 +63,21 @@ protected function initialise(): void {
             ->add_join("LEFT JOIN {user} {$entityuseralias} ON {$entityuseralias}.id = {$entitymainalias}.userid")
         );
 
+        // Join with the latest entry in the notification log for each user.
+        $entitylog = new notification_log();
+        $entitylogalias = $entitylog->get_table_alias('tool_emailutils_log');
+        $this->add_entity($entitylog
+            ->add_join("LEFT JOIN (
+                           SELECT l1.*
+                            FROM {tool_emailutils_log} l1
+                            WHERE l1.id = (
+                                SELECT MAX(l2.id)
+                                FROM {tool_emailutils_log} l2
+                                WHERE l2.email = l1.email
+                            )
+                        ) {$entitylogalias} ON {$entitylogalias}.email = {$entityuseralias}.email")
+        );
+
         // Now we can call our helper methods to add the content we want to include in the report.
         $this->add_columns();
         $this->add_filters();
@@ -94,6 +110,7 @@ protected function add_columns(): void {
             'email_bounce:bounces',
             'email_bounce:send',
             'email_bounce:ratio',
+            'notification_log:subtypes',
         ];
 
         $this->add_columns_from_entities($columns);
diff --git a/lang/en/tool_emailutils.php b/lang/en/tool_emailutils.php
index 14a0162..0f2a6b5 100644
--- a/lang/en/tool_emailutils.php
+++ b/lang/en/tool_emailutils.php
@@ -84,6 +84,7 @@
 $string['mxrecords'] = 'MX records';
 $string['mxtoolbox'] = 'MXtoolbox links';
 $string['not_implemented'] = 'Not implemented yet. Search the user report for emails ending with ".b.invalid" and ".c.invalid".';
+$string['notificationlog'] = 'SNS notification log';
 $string['password'] = 'Password';
 $string['password_help'] = 'HTTP Basic Auth Password - Leave empty if you\'re not changing the password';
 $string['pluginname'] = 'Email utilities';
@@ -114,9 +115,11 @@
 $string['selectornotblank'] = 'Selector cannot be empty';
 $string['sendcount'] = 'Send count';
 $string['settings'] = 'AWS SES settings';
+$string['subtypes'] = 'Reason';
 $string['suppressionreason'] = 'Reason';
 $string['suppressioncreated'] = 'Created at';
 $string['task_update_suppression_list'] = 'Update email suppression list';
+$string['type'] = 'Type';
 $string['username'] = 'Username';
 $string['username_help'] = 'HTTP Basic Auth Username';
 $string['userdomains'] = 'Most common user email domains';