This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 8818629  Send email through the storage interface and add audit logging
8818629 is described below

commit 8818629dd0d121656b9b76b71dcbb5dd94335af4
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jan 22 15:18:28 2026 +0000

    Send email through the storage interface and add audit logging
---
 atr/server.py                   |   1 +
 atr/storage/__init__.py         |   4 ++
 atr/storage/writers/__init__.py |   2 +
 atr/storage/writers/mail.py     | 112 ++++++++++++++++++++++++++++++++++++++++
 atr/storage/writers/tokens.py   |  12 +----
 atr/tasks/message.py            |   9 ++--
 atr/tasks/vote.py               |  12 ++---
 7 files changed, 130 insertions(+), 22 deletions(-)

diff --git a/atr/server.py b/atr/server.py
index ad24301..835106c 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -306,6 +306,7 @@ def _app_setup_logging(app: base.QuartApp, config_mode: 
config.Mode, app_config:
     ]
 
     # Output handler: pretty console for dev (Debug and Allow Tests), JSON for 
non-dev (Docker, etc.)
+    # TODO: Align this with util.is_dev_environment()?
     is_dev = (config_mode == config.Mode.Debug) and app_config.ALLOW_TESTS
     output_handler = logging.StreamHandler(sys.stderr)
     if is_dev:
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 3a7305a..49dc769 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -139,6 +139,7 @@ class WriteAsGeneralPublic(WriteAs):
         self.cache = writers.cache.GeneralPublic(write, self, data)
         self.checks = writers.checks.GeneralPublic(write, self, data)
         self.keys = writers.keys.GeneralPublic(write, self, data)
+        self.mail = writers.mail.GeneralPublic(write, self, data)
         self.policy = writers.policy.GeneralPublic(write, self, data)
         self.project = writers.project.GeneralPublic(write, self, data)
         self.release = writers.release.GeneralPublic(write, self, data)
@@ -157,6 +158,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
         self.cache = writers.cache.FoundationCommitter(write, self, data)
         self.checks = writers.checks.FoundationCommitter(write, self, data)
         self.keys = writers.keys.FoundationCommitter(write, self, data)
+        self.mail = writers.mail.FoundationCommitter(write, self, data)
         self.policy = writers.policy.FoundationCommitter(write, self, data)
         self.project = writers.project.FoundationCommitter(write, self, data)
         self.release = writers.release.FoundationCommitter(write, self, data)
@@ -181,6 +183,7 @@ class 
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
         self.cache = writers.cache.CommitteeParticipant(write, self, data, 
committee_name)
         self.checks = writers.checks.CommitteeParticipant(write, self, data, 
committee_name)
         self.keys = writers.keys.CommitteeParticipant(write, self, data, 
committee_name)
+        self.mail = writers.mail.CommitteeParticipant(write, self, data, 
committee_name)
         self.policy = writers.policy.CommitteeParticipant(write, self, data, 
committee_name)
         self.project = writers.project.CommitteeParticipant(write, self, data, 
committee_name)
         self.release = writers.release.CommitteeParticipant(write, self, data, 
committee_name)
@@ -210,6 +213,7 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
         self.checks = writers.checks.CommitteeMember(write, self, data, 
committee_name)
         self.distributions = writers.distributions.CommitteeMember(write, 
self, data, committee_name)
         self.keys = writers.keys.CommitteeMember(write, self, data, 
committee_name)
+        self.mail = writers.mail.CommitteeMember(write, self, data, 
committee_name)
         self.policy = writers.policy.CommitteeMember(write, self, data, 
committee_name)
         self.project = writers.project.CommitteeMember(write, self, data, 
committee_name)
         self.release = writers.release.CommitteeMember(write, self, data, 
committee_name)
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 27fe59d..9769fd3 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -22,6 +22,7 @@ import atr.storage.writers.cache as cache
 import atr.storage.writers.checks as checks
 import atr.storage.writers.distributions as distributions
 import atr.storage.writers.keys as keys
+import atr.storage.writers.mail as mail
 import atr.storage.writers.policy as policy
 import atr.storage.writers.project as project
 import atr.storage.writers.release as release
@@ -38,6 +39,7 @@ __all__ = [
     "checks",
     "distributions",
     "keys",
+    "mail",
     "policy",
     "project",
     "release",
diff --git a/atr/storage/writers/mail.py b/atr/storage/writers/mail.py
new file mode 100644
index 0000000..63ac78b
--- /dev/null
+++ b/atr/storage/writers/mail.py
@@ -0,0 +1,112 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import atr.db as db
+import atr.log as log
+import atr.mail as mail
+import atr.storage as storage
+import atr.util as util
+
+
+class GeneralPublic:
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsGeneralPublic,
+        data: db.Session,
+    ) -> None:
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+
+
+class FoundationCommitter(GeneralPublic):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsFoundationCommitter,
+        data: db.Session,
+    ) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+
+    async def send(self, message: mail.Message) -> tuple[str, list[str]]:
+        is_dev = util.is_dev_environment()
+
+        if is_dev:
+            log.info(f"Dev environment detected, not sending email to 
{message.email_recipient}")
+            mid = util.DEV_TEST_MID
+            errors: list[str] = []
+        else:
+            mid, errors = await mail.send(message)
+
+        self.__write_as.append_to_audit_log(
+            sent=not is_dev,
+            email_sender=message.email_sender,
+            email_recipient=message.email_recipient,
+            subject=message.subject,
+            mid=mid,
+            in_reply_to=message.in_reply_to,
+        )
+
+        return mid, errors
+
+
+class CommitteeParticipant(FoundationCommitter):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeParticipant,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
+
+
+class CommitteeMember(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeMember,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data, committee_name)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index f6b45ed..d7d8001 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -26,11 +26,9 @@ import sqlmodel
 
 import atr.db as db
 import atr.jwtoken as jwtoken
-import atr.log as log
 import atr.mail as mail
 import atr.models.sql as sql
 import atr.storage as storage
-import atr.util as util
 
 # TODO: Check that this is known and that its emails are correctly discarded
 NOREPLY_EMAIL_ADDRESS: Final[str] = "[email protected]"
@@ -81,10 +79,7 @@ class FoundationCommitter(GeneralPublic):
             body=f"A new API token called '{label}' was created for your 
account. "
             "If you did not create this token, please revoke it immediately.",
         )
-        if util.is_dev_environment():
-            log.info("Dev environment detected, pretending to send mail")
-        else:
-            await mail.send(message)
+        await self.__write_as.mail.send(message)
         return pat
 
     async def delete_token(self, token_id: int) -> None:
@@ -109,10 +104,7 @@ class FoundationCommitter(GeneralPublic):
                 body=f"An API token called '{label}' was deleted from your 
account. "
                 "If you did not delete this token, please check your account 
immediately.",
             )
-            if util.is_dev_environment():
-                log.info("Dev environment detected, pretending to send mail")
-            else:
-                await mail.send(message)
+            await self.__write_as.mail.send(message)
 
     async def issue_jwt(self, pat_text: str) -> str:
         pat_hash = hashlib.sha3_256(pat_text.encode()).hexdigest()
diff --git a/atr/tasks/message.py b/atr/tasks/message.py
index 75d9b21..4e59603 100644
--- a/atr/tasks/message.py
+++ b/atr/tasks/message.py
@@ -19,6 +19,7 @@ import atr.log as log
 import atr.mail as mail
 import atr.models.results as results
 import atr.models.schema as schema
+import atr.storage as storage
 import atr.tasks.checks as checks
 
 
@@ -60,10 +61,10 @@ async def send(args: Send) -> results.Results | None:
         in_reply_to=args.in_reply_to,
     )
 
-    # Send the email
-    # TODO: Move this call into send itself?
-    # await mail.set_secret_key_default()
-    mid, mail_errors = await mail.send(message)
+    async with storage.write(sender_asf_uid) as write:
+        wafc = write.as_foundation_committer()
+        mid, mail_errors = await wafc.mail.send(message)
+
     if mail_errors:
         log.warning(f"Mail sending to {args.email_recipient} for subject 
'{args.subject}' encountered errors:")
         for error in mail_errors:
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 9c39481..688d4c0 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -23,6 +23,7 @@ import atr.log as log
 import atr.mail as mail
 import atr.models.results as results
 import atr.models.schema as schema
+import atr.storage as storage
 import atr.tasks.checks as checks
 import atr.util as util
 
@@ -118,14 +119,9 @@ async def _initiate_core_logic(args: Initiate) -> 
results.Results | None:
         body=body,
     )
 
-    if util.is_dev_environment():
-        # Pretend to send the mail
-        log.info("Dev environment detected, pretending to send mail")
-        mid = util.DEV_TEST_MID
-        mail_errors = []
-    else:
-        # Send the mail
-        mid, mail_errors = await mail.send(message)
+    async with storage.write(args.initiator_id) as write:
+        wafc = write.as_foundation_committer()
+        mid, mail_errors = await wafc.mail.send(message)
 
     # Original success message structure
     result = results.VoteInitiate(


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to