Repository: privacyidea/privacyidea

Issue:
Email Notification: Some errors do not result in a Success-False in the audit log
If the email of the user notification can not be passed to the mail server, we get different errors:

https://github.com/privacyidea/privacyidea/blob/9528249a8de5b71afa11c82617e48c02bb8e74e5/privacyidea/lib/eventhandler/usernotification.py#L442

It looks like in some cases we do not get the correct return value.

# Analysis

Not all problems with sending emails can be fetched. However, if an error occurs during passing the email to the sending email server, the `ret` is processed correctly.

If the error occurs later, e.g. due to non existing mailboxes of the recipient, this problem is out of our control.

Nevertheless we can do the following.

# Implementation

enhance the `do` method of the event handlers to not only return a state of true or false but also return an additional info, which can be appended to `event_audit_data["info"]` here:

PRE:

https://github.com/privacyidea/privacyidea/blob/4fd7492ec86306d95511f70cfa8f0df9c8add367/privacyidea/lib/event.py#L91



POST:

https://github.com/privacyidea/privacyidea/blob/4fd7492ec86306d95511f70cfa8f0df9c8add367/privacyidea/lib/event.py#L134




Discussion:

Resolving patch:
diff --git a/privacyidea/lib/event.py b/privacyidea/lib/event.py
--- a/privacyidea/lib/event.py
+++ b/privacyidea/lib/event.py
@@ -93,6 +93,9 @@ def event_wrapper(*args, **kwds):
 
                     result = event_handler.do(e_handler_def.get("action"),
                                                options=options)
+                    if not result and event_handler.run_details:
+                        event_audit_data["info"] += " ({!s})".format(event_handler.run_details)
+                        event_audit.log(event_audit_data)
                     # set audit object to success
                     event_audit.log({"success": result})
                     event_audit.finalize_log()
@@ -133,6 +136,9 @@ def event_wrapper(*args, **kwds):
 
                     result = event_handler.do(e_handler_def.get("action"),
                                                options=options)
+                    if not result and event_handler.run_details:
+                        event_audit_data["info"] += " ({!s})".format(event_handler.run_details)
+                        event_audit.log(event_audit_data)
                     # In case the handler has modified the response
                     f_result = options.get("response")
                     # set audit object to success
diff --git a/privacyidea/lib/eventhandler/base.py b/privacyidea/lib/eventhandler/base.py
--- a/privacyidea/lib/eventhandler/base.py
+++ b/privacyidea/lib/eventhandler/base.py
@@ -107,6 +107,7 @@ class BaseEventHandler(object):
 
     def __init__(self):
         pass
+        self.run_details = None
 
     @property
     def allowed_positions(cls):
diff --git a/privacyidea/lib/eventhandler/usernotification.py b/privacyidea/lib/eventhandler/usernotification.py
--- a/privacyidea/lib/eventhandler/usernotification.py
+++ b/privacyidea/lib/eventhandler/usernotification.py
@@ -437,15 +437,16 @@ def do(self, action, options=None):
                                                 subject=subject, body=body,
                                                 reply_to=reply_to,
                                                 mimetype=mimetype)
-                except Exception as exx:
+                except Exception as exx:  # pragma: no cover
                     log.error("Failed to send email: {0!s}".format(exx))
+                    self.run_details = "{0!s}".format(exx)
                     ret = False
                 if ret:
                     log.info("Sent a notification email to user {0}".format(
                         recipient))
                 else:
-                    log.warning("Failed to send a notification email to user "
-                                "{0}".format(recipient))
+                    log.warning("Failed to send a notification email to user {0}".format(recipient))
+                    self.run_details = "Failed: {0!s}.".format(useremail)
 
             elif action.lower() == "savefile":
                 spooldir = get_app_config_value("PI_NOTIFICATION_HANDLER_SPOOLDIRECTORY",


Test patch:
diff --git a/tests/test_api_events.py b/tests/test_api_events.py
--- a/tests/test_api_events.py
+++ b/tests/test_api_events.py
@@ -7,6 +7,7 @@
 from privacyidea.lib.user import User
 from .base import MyApiTestCase
 from . import smtpmock
+import mock
 from privacyidea.lib.config import set_privacyidea_config
 
 # TODO: this should be imported from lib.event when available
@@ -720,3 +721,87 @@ def test_02_user_attribute_with_handler_logged_in_user(self):
 
         delete_event(eid)
         user.delete_attribute('foo')
+
+
+from privacyidea.lib.smtpserver import add_smtpserver
+from . import smtpmock
+
+
+class EventWrapperTestCase(MyApiTestCase):
+    # Test the wrapper/decorator in lib/event.py
+    # In other cases we test specific event handlers, but not the calling of the event handler
+    # We do this here via an API call.
+    serial = "myToken"
+
+    def test_00_setup(self):
+        # setup realms
+        self.setUp_user_realms()
+
+        r = add_smtpserver(identifier="myserver", server="1.2.3.4", tls=False)
+        self.assertTrue(r > 0)
+
+    @smtpmock.activate
+    def test_01_sendmail_post(self):
+
+        r = set_event("send email", "token_init", "UserNotification", "sendmail",
+                      conditions={},
+                      options={"emailconfig": "myserver",
+                               "To": "email",
+                               "To email": "pretzel@example.com",
+                               "reply_to": "email",
+                               "reply_to email": "privacyidea@example.com"})
+        self.assertTrue(r > 0)
+
+        smtpmock.setdata(response={"pretzel@example.com": (450, "Mailbox not available")},
+                         support_tls=False)
+
+        with mock.patch("logging.Logger.warning") as mock_log:
+            with self.app.test_request_context('/token/init',
+                                               data={"genkey": 1,
+                                                     "serial": self.serial},
+                                               headers={'Authorization': self.at},
+                                               method='POST'):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 200, res)
+                result = res.json.get("result")
+                self.assertTrue(result.get("value"), result)
+            # Check warning in log
+            expected = "Failed to send a notification email to user {'email': ['pretzel@example.com']}"
+            mock_log.assert_called_once_with(expected)
+            msg = smtpmock.get_sent_message()
+            self.assertIn('To: pretzel@example.com', msg)
+        delete_event(r)
+
+    @smtpmock.activate
+    def test_02_sendmail_pre(self):
+
+        r = set_event("send email", "token_init", "UserNotification", "sendmail",
+                      conditions={},
+                      position="pre",
+                      options={"emailconfig": "myserver",
+                               "To": "email",
+                               "To email": "donut@example.com",
+                               "reply_to": "email",
+                               "reply_to email": "privacyidea@example.com"})
+        self.assertTrue(r > 0)
+
+        smtpmock.setdata(response={"donut@example.com": (450, "Mailbox not available")},
+                         support_tls=False)
+
+        with mock.patch("logging.Logger.warning") as mock_log:
+            with self.app.test_request_context('/token/init',
+                                               data={"genkey": 1,
+                                                     "serial": self.serial},
+                                               headers={'Authorization': self.at},
+                                               method='POST'):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 200, res)
+                result = res.json.get("result")
+                self.assertTrue(result.get("value"), result)
+            # Check warning in log
+            expected = "Failed to send a notification email to user {'email': ['donut@example.com']}"
+            mock_log.assert_called_once_with(expected)
+
+            msg = smtpmock.get_sent_message()
+            self.assertIn('To: donut@example.com', msg)
+        delete_event(r)
diff --git a/tests/test_lib_eventhandler_usernotification.py b/tests/test_lib_eventhandler_usernotification.py
--- a/tests/test_lib_eventhandler_usernotification.py
+++ b/tests/test_lib_eventhandler_usernotification.py
@@ -119,6 +119,55 @@ def test_02_sendmail(self):
         msg = smtpmock.get_sent_message()
         assert 'To: user@localhost.localdomain' in msg
 
+    @smtpmock.activate
+    def test_02a_sendmail(self):
+        # setup realms
+        self.setUp_user_realms()
+
+        r = add_smtpserver(identifier="myserver", server="1.2.3.4", tls=False)
+        self.assertTrue(r > 0)
+
+        smtpmock.setdata(response={"user@localhost.localdomain": (450, "Mailbox not available")},
+                         support_tls=False)
+
+        g = FakeFlaskG()
+        audit_object = FakeAudit()
+        audit_object.audit_data["serial"] = "123456"
+
+        g.logged_in_user = {"username": "admin",
+                            "role": "admin",
+                            "realm": ""}
+        g.audit_object = audit_object
+
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "OATH123456"},
+                                 headers={})
+
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {"serial": "SomeSerial",
+                        "user": "cornelius"}
+        req.User = User("cornelius", self.realm1)
+        resp = Response()
+        resp.data = """{"result": {"value": true}}"""
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"emailconfig": "myserver"}
+                                   }
+                   }
+
+        un_handler = UserNotificationEventHandler()
+        res = un_handler.do("sendmail", options=options)
+        self.assertFalse(res)
+        msg = smtpmock.get_sent_message()
+        self.assertIn('To: user@localhost.localdomain', msg)
+
+
     @smtpmock.activate
     def test_03_sendsms(self):
         # setup realms