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-release.git


The following commit(s) were added to refs/heads/main by this push:
     new e931cdf  Convert the vote task to the improved task style
e931cdf is described below

commit e931cdfea80a6c92e3713a1ea26f31ca00bd9dc5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Mar 31 19:44:11 2025 +0100

    Convert the vote task to the improved task style
---
 atr/mail.py             |  94 +++++-----------
 atr/routes/candidate.py |  20 ++--
 atr/tasks/mailtest.py   | 185 -------------------------------
 atr/tasks/vote.py       | 282 +++++++++++++++++++-----------------------------
 atr/worker.py           |   5 +-
 poetry.lock             |  18 +++-
 pyproject.toml          |   1 +
 scripts/poetry/add      |   3 +-
 8 files changed, 171 insertions(+), 437 deletions(-)

diff --git a/atr/mail.py b/atr/mail.py
index 2f78bb0..f2227eb 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -15,16 +15,16 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import asyncio
 import datetime
 import email.utils as utils
 import io
 import logging
 import smtplib
-import ssl
 import time
 import uuid
-from typing import Any
 
+import aiosmtplib
 import dkim
 import dns.rdtypes.ANY.MX as MX
 import dns.resolver as resolver
@@ -43,25 +43,6 @@ global_email_contact: str = f"contact@{global_domain}"
 global_secret_key: str | None = None
 
 
-class ArtifactEvent:
-    """Simple data class to represent an artifact send event."""
-
-    def __init__(self, email_recipient: str, artifact_name: str, token: str) 
-> None:
-        self.artifact_name = artifact_name
-        self.email_recipient = email_recipient
-        self.token = token
-
-
-class LoggingSMTP(smtplib.SMTP):
-    def _print_debug(self, *args: Any) -> None:
-        template = ["%s"] * len(args)
-        if self.debuglevel > 1:
-            template.append("%s")
-            _LOGGER.info(" ".join(template), datetime.datetime.now().time(), 
*args)
-        else:
-            _LOGGER.info(" ".join(template), *args)
-
-
 class VoteEvent:
     """Data class to represent a release vote event."""
 
@@ -75,7 +56,7 @@ class VoteEvent:
         self.vote_end = vote_end
 
 
-def send(event: ArtifactEvent | VoteEvent) -> None:
+async def send(event: VoteEvent) -> None:
     """Send an email notification about an artifact or a vote."""
     _LOGGER.info(f"Sending email for event: {event}")
     from_addr = global_email_contact
@@ -87,8 +68,7 @@ def send(event: ArtifactEvent | VoteEvent) -> None:
     mid = f"<{uuid.uuid4()}@{global_domain}>"
 
     # Different message format depending on event type
-    if isinstance(event, VoteEvent):
-        msg_text = f"""
+    msg_text = f"""
 From: {from_addr}
 To: {to_addr}
 Subject: {event.subject}
@@ -96,27 +76,6 @@ Date: {utils.formatdate(localtime=True)}
 Message-ID: {mid}
 
 {event.body}
-"""
-    else:
-        # ArtifactEvent
-        # This was just for testing
-        msg_text = f"""
-From: {from_addr}
-To: {to_addr}
-Subject: {event.artifact_name}
-Date: {utils.formatdate(localtime=True)}
-Message-ID: {mid}
-
-The {event.artifact_name} artifact has been uploaded.
-
-The artifact is available for download at:
-
-https://{global_domain}/artifact/{event.token}
-
-If you have any questions, please reply to this email.
-
---\x20
-[NAME GOES HERE]
 """
 
     # Convert Unix line endings to CRLF
@@ -126,7 +85,7 @@ If you have any questions, please reply to this email.
     _LOGGER.info(f"sending message: {msg_text}")
 
     try:
-        _send_many(from_addr, [to_addr], msg_text)
+        await _send_many(from_addr, [to_addr], msg_text)
     except Exception as e:
         _LOGGER.error(f"send error: {e}")
         raise e
@@ -143,10 +102,10 @@ def set_secret_key(key: str) -> None:
     global_secret_key = key
 
 
-def _resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+async def _resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+    """Resolve MX records."""
     try:
-        # Query MX records
-        mx_records = resolver.resolve(domain, "MX")
+        mx_records = await asyncio.to_thread(resolver.resolve, domain, "MX")
         mxs = []
 
         for rdata in mx_records:
@@ -154,7 +113,7 @@ def _resolve_mx_records(domain: str) -> list[tuple[str, 
int]]:
                 raise ValueError(f"Unexpected MX record type: {type(rdata)}")
             mx = rdata
             mxs.append((mx.exchange.to_text(True), mx.preference))
-        # Sort by preference, array position one
+        # Sort by preference
         mxs.sort(key=lambda x: x[1])
 
         if not mxs:
@@ -164,7 +123,7 @@ def _resolve_mx_records(domain: str) -> list[tuple[str, 
int]]:
     return mxs
 
 
-def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> None:
+async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> 
None:
     """Send an email to multiple recipients with DKIM signing."""
     message_bytes = bytes(msg_text, "utf-8")
 
@@ -195,13 +154,13 @@ def _send_many(from_addr: str, to_addrs: list[str], 
msg_text: str) -> None:
         if domain == "localhost":
             mxs = [("127.0.0.1", 0)]
         else:
-            mxs = _resolve_mx_records(domain)
+            mxs = await _resolve_mx_records(domain)
 
         # Try each MX server
         errors = []
         for mx_host, _ in mxs:
             try:
-                _send_one(mx_host, from_addr, addr, dkim_reader)
+                await _send_one(mx_host, from_addr, addr, dkim_reader)
                 # Success, no need to try other MX servers
                 break
             except Exception as e:
@@ -213,7 +172,7 @@ def _send_many(from_addr: str, to_addrs: list[str], 
msg_text: str) -> None:
             raise Exception("; ".join(errors))
 
 
-def _send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader: 
io.StringIO) -> None:
+async def _send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader: 
io.StringIO) -> None:
     """Send an email to a single recipient via the ASF mail relay."""
     default_timeout_seconds = 30
     _validate_recipient(to_addr)
@@ -222,28 +181,29 @@ def _send_one(mx_host: str, from_addr: str, to_addr: str, 
msg_reader: io.StringI
         # Connect to the ASF mail relay
         # TODO: Use asfpy for sending mail
         mail_relay = "mail-relay.apache.org"
-        _LOGGER.info(f"Connecting to {mail_relay}:587")
-        smtp = LoggingSMTP(mail_relay, 587, timeout=default_timeout_seconds)
-        smtp.set_debuglevel(2)
+        _LOGGER.info(f"Connecting async to {mail_relay}:587")
+        smtp = aiosmtplib.SMTP(hostname=mail_relay, port=587, 
timeout=default_timeout_seconds)
+        await smtp.connect()
+        _LOGGER.info(f"Connected to {smtp.hostname}:{smtp.port}")
 
         # Identify ourselves to the server
-        smtp.ehlo(global_domain)
+        await smtp.ehlo()
 
-        # Use STARTTLS for port 587
-        context = ssl.create_default_context()
-        context.minimum_version = ssl.TLSVersion.TLSv1_2
-        smtp.starttls(context=context)
-        smtp.ehlo(global_domain)
+        # # Use STARTTLS for port 587
+        # context = ssl.create_default_context()
+        # context.minimum_version = ssl.TLSVersion.TLSv1_2
+        # await smtp.starttls(tls_context=context)
+        await smtp.ehlo()
 
         # Send the message
-        smtp.mail(from_addr)
-        smtp.rcpt(to_addr)
-        smtp.data(msg_reader.read())
+        await smtp.sendmail(from_addr, [to_addr], msg_reader.read())
 
         # Close the connection
-        smtp.quit()
+        await smtp.quit()
 
     except (OSError, smtplib.SMTPException) as e:
+        # TODO: Check whether aiosmtplib raises different exceptions
+        _LOGGER.error(f"Async SMTP error: {e}")
         raise Exception(f"SMTP error: {e}")
 
 
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index fff288d..9eb4623 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -28,6 +28,8 @@ import wtforms
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
+import atr.tasks.checks as checks
+import atr.tasks.vote as tasks_vote
 import atr.user as user
 import atr.util as util
 
@@ -156,15 +158,15 @@ async def vote_project(session: routes.CommitterSession, 
project_name: str, vers
         # Create a task for vote initiation
         task = models.Task(
             status=models.TaskStatus.QUEUED,
-            task_type="vote_initiate",
-            task_args=[
-                release_name,
-                email_to,
-                vote_duration,
-                gpg_key_id,
-                commit_hash,
-                session.uid,
-            ],
+            task_type=checks.function_key(tasks_vote.initiate),
+            task_args=tasks_vote.Initiate(
+                release_name=release_name,
+                email_to=email_to,
+                vote_duration=vote_duration,
+                gpg_key_id=gpg_key_id,
+                commit_hash=commit_hash,
+                initiator_id=session.uid,
+            ).model_dump(),
         )
 
         data.add(task)
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
deleted file mode 100644
index 7394fee..0000000
--- a/atr/tasks/mailtest.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# 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.
-
-import dataclasses
-import logging
-import os
-from typing import Any, Final
-
-import atr.db.models as models
-import atr.tasks.task as task
-
-# Configure detailed logging
-_LOGGER: Final = logging.getLogger(__name__)
-_LOGGER.setLevel(logging.DEBUG)
-
-# Create file handler for test.log
-_HANDLER: Final = logging.FileHandler("tasks-mailtest.log")
-_HANDLER.setLevel(logging.DEBUG)
-
-# Create formatter with detailed information
-_HANDLER.setFormatter(
-    logging.Formatter(
-        "[%(asctime)s.%(msecs)03d] [%(process)d] [%(levelname)s] 
[%(name)s:%(funcName)s:%(lineno)d] %(message)s",
-        datefmt="%Y-%m-%d %H:%M:%S",
-    )
-)
-_LOGGER.addHandler(_HANDLER)
-# Ensure parent loggers don't duplicate messages
-_LOGGER.propagate = False
-
-_LOGGER.info("Mail test module imported")
-
-
-# TODO: Use a Pydantic model instead
[email protected]
-class Args:
-    artifact_name: str
-    email_recipient: str
-    token: str
-
-    @staticmethod
-    def from_list(args: list[str]) -> "Args":
-        """Parse command line arguments."""
-        _LOGGER.debug(f"Parsing arguments: {args}")
-
-        if len(args) != 3:
-            _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 
3")
-            raise ValueError("Invalid number of arguments")
-
-        artifact_name = args[0]
-        email_recipient = args[1]
-        token = args[2]
-
-        if not isinstance(artifact_name, str):
-            _LOGGER.error(f"Artifact name must be a string, got 
{type(artifact_name)}")
-            raise ValueError("Artifact name must be a string")
-        if not isinstance(email_recipient, str):
-            _LOGGER.error(f"Email recipient must be a string, got 
{type(email_recipient)}")
-            raise ValueError("Email recipient must be a string")
-        if not isinstance(token, str):
-            _LOGGER.error(f"Token must be a string, got {type(token)}")
-            raise ValueError("Token must be a string")
-        _LOGGER.debug("All argument validations passed")
-
-        args_obj = Args(
-            artifact_name=artifact_name,
-            email_recipient=email_recipient,
-            token=token,
-        )
-
-        _LOGGER.info(f"Args object created: {args_obj}")
-        return args_obj
-
-
-def send(args: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any, 
...]]:
-    """Send a test email."""
-    _LOGGER.info(f"Sending with args: {args}")
-    try:
-        _LOGGER.debug("Delegating to send_core function")
-        status, error, result = send_core(args)
-        _LOGGER.info(f"Send completed with status: {status}")
-        return status, error, result
-    except Exception as e:
-        _LOGGER.exception(f"Error in send function: {e}")
-        return task.FAILED, str(e), tuple()
-
-
-def send_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None, 
tuple[Any, ...]]:
-    """Send a test email."""
-    import asyncio
-
-    import atr.db.service as service
-    import atr.mail as mail
-
-    _LOGGER.info("Starting send_core")
-    try:
-        # Configure root _LOGGER to also write to our log file
-        # This ensures logs from mail.py, using the root _LOGGER, are captured
-        root_logger = logging.getLogger()
-        # Check whether our file handler is already added, to avoid duplicates
-        has_our_handler = any(
-            (isinstance(h, logging.FileHandler) and 
h.baseFilename.endswith("tasks-mailtest.log"))
-            for h in root_logger.handlers
-        )
-        if not has_our_handler:
-            # Add our file handler to the root _LOGGER
-            root_logger.addHandler(_HANDLER)
-            _LOGGER.info("Added file handler to root _LOGGER to capture 
mail.py logs")
-
-        _LOGGER.debug(f"Parsing arguments: {args_list}")
-        args = Args.from_list(args_list)
-        _LOGGER.info(
-            f"Args parsed successfully: artifact_name={args.artifact_name}, 
email_recipient={args.email_recipient}"
-        )
-
-        # Check if the recipient is allowed
-        # They must be a PMC member of tooling or [email protected]
-        email_recipient = args.email_recipient
-        local_part, domain = email_recipient.split("@", 1)
-
-        # Allow [email protected]
-        if email_recipient != "[email protected]":
-            # Must be a PMC member of tooling
-            # Since get_pmc_by_name is async, we need to run it in an event 
loop
-            # TODO: We could make a sync version
-            tooling_committee = 
asyncio.run(service.get_committee_by_name("tooling"))
-
-            if not tooling_committee:
-                error_msg = "Tooling committee not found in database"
-                _LOGGER.error(error_msg)
-                return task.FAILED, error_msg, tuple()
-
-            if domain != "apache.org":
-                error_msg = f"Email domain must be apache.org, got {domain}"
-                _LOGGER.error(error_msg)
-                return task.FAILED, error_msg, tuple()
-
-            if local_part not in tooling_committee.committee_members:
-                error_msg = f"Email recipient {local_part} is not a member of 
the tooling committee"
-                _LOGGER.error(error_msg)
-                return task.FAILED, error_msg, tuple()
-
-            _LOGGER.info(f"Recipient {email_recipient} is a tooling committee 
member, allowed")
-
-        # Load and set DKIM key
-        try:
-            project_root = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-            dkim_path = os.path.join(project_root, "state", "dkim.private")
-
-            with open(dkim_path) as f:
-                dkim_key = f.read()
-                mail.set_secret_key(dkim_key.strip())
-                _LOGGER.info("DKIM key loaded and set successfully")
-        except Exception as e:
-            error_msg = f"Failed to load DKIM key: {e}"
-            _LOGGER.error(error_msg)
-            return task.FAILED, error_msg, tuple()
-
-        event = mail.ArtifactEvent(
-            artifact_name=args.artifact_name,
-            email_recipient=args.email_recipient,
-            token=args.token,
-        )
-        mail.send(event)
-        _LOGGER.info(f"Email sent successfully to {args.email_recipient}")
-
-        return task.COMPLETED, None, tuple()
-
-    except Exception as e:
-        _LOGGER.exception(f"Error in send_core: {e}")
-        return task.FAILED, str(e), tuple()
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index cc8578a..d26004c 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -15,14 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import dataclasses
 import datetime
 import logging
 import os
 from typing import Any, Final
 
-import atr.db.models as models
-import atr.tasks.task as task
+import aiofiles
+import pydantic
+
+import atr.db as db
+import atr.mail as mail
+import atr.tasks.checks as checks
 
 # Configure detailed logging
 _LOGGER: Final = logging.getLogger(__name__)
@@ -46,147 +49,96 @@ _LOGGER.propagate = False
 _LOGGER.info("Vote module imported")
 
 
[email protected]
-class Args:
-    """Arguments for the vote_initiate task."""
-
-    release_name: str
-    email_to: str
-    vote_duration: str
-    gpg_key_id: str
-    commit_hash: str
-    initiator_id: str
-
-    @staticmethod
-    def from_list(args: list[str]) -> "Args":
-        """Parse task arguments."""
-        _LOGGER.debug(f"Parsing arguments: {args}")
-
-        if len(args) != 6:
-            _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 
6")
-            raise ValueError("Invalid number of arguments")
-
-        release_name = args[0]
-        email_to = args[1]
-        vote_duration = args[2]
-        gpg_key_id = args[3]
-        commit_hash = args[4]
-        initiator_id = args[5]
-
-        # Type checking
-        for arg_name, arg_value in [
-            ("release_name", release_name),
-            ("email_to", email_to),
-            ("vote_duration", vote_duration),
-            ("gpg_key_id", gpg_key_id),
-            ("commit_hash", commit_hash),
-            ("initiator_id", initiator_id),
-        ]:
-            if not isinstance(arg_value, str):
-                _LOGGER.error(f"{arg_name} must be a string, got 
{type(arg_value)}")
-                raise ValueError(f"{arg_name} must be a string")
-
-        _LOGGER.debug("All argument validations passed")
-
-        args_obj = Args(
-            release_name=release_name,
-            email_to=email_to,
-            vote_duration=vote_duration,
-            gpg_key_id=gpg_key_id,
-            commit_hash=commit_hash,
-            initiator_id=initiator_id,
-        )
+class VoteInitiationError(Exception): ...
+
+
+class Initiate(pydantic.BaseModel):
+    """Arguments for the task to start a vote."""
 
-        _LOGGER.info(f"Args object created: {args_obj}")
-        return args_obj
+    release_name: str = pydantic.Field(..., description="The name of the 
release to vote on")
+    email_to: str = pydantic.Field(..., description="The mailing list address 
to send the vote email to")
+    vote_duration: str = pydantic.Field(..., description="Duration of the vote 
in hours, as a string")
+    gpg_key_id: str = pydantic.Field(..., description="GPG Key ID of the 
initiator")
+    commit_hash: str = pydantic.Field(..., description="Commit hash the 
artifacts were built from")
+    initiator_id: str = pydantic.Field(..., description="ASF ID of the vote 
initiator")
 
 
-def initiate(args: list[str]) -> tuple[models.TaskStatus, str | None, 
tuple[Any, ...]]:
[email protected]_model(Initiate)
+async def initiate(args: Initiate) -> str | None:
     """Initiate a vote for a release."""
-    _LOGGER.info(f"Initiating vote with args: {args}")
     try:
-        _LOGGER.debug("Delegating to initiate_core function")
-        status, error, result = initiate_core(args)
-        _LOGGER.info(f"Vote initiation completed with status: {status}")
-        return status, error, result
+        result_data = await _initiate_core_logic(args)
+        success_message = result_data.get("message", "Vote initiated 
successfully, but message missing")
+        if not isinstance(success_message, str):
+            raise VoteInitiationError("Success message is not a string")
+        return success_message
+
+    except VoteInitiationError as e:
+        _LOGGER.error(f"Vote initiation failed: {e}")
+        raise
     except Exception as e:
-        _LOGGER.exception(f"Error in initiate function: {e}")
-        return task.FAILED, str(e), tuple()
+        _LOGGER.exception(f"Unexpected error during vote initiation: {e}")
+        raise
 
 
-def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str | 
None, tuple[Any, ...]]:
+async def _initiate_core_logic(args: Initiate) -> dict[str, Any]:
     """Get arguments, create an email, and then send it to the recipient."""
-    import atr.db.service as service
-    import atr.mail
-
     test_recipients = ["sbp"]
     _LOGGER.info("Starting initiate_core")
-    try:
-        # Configure root _LOGGER to also write to our log file
-        # This ensures logs from mail.py, using the root _LOGGER, are captured
-        root_logger = logging.getLogger()
-        # Check whether our file handler is already added, to avoid duplicates
-        has_our_handler = any(
-            (isinstance(h, logging.FileHandler) and 
h.baseFilename.endswith("tasks-vote.log"))
-            for h in root_logger.handlers
+
+    root_logger = logging.getLogger()
+    has_our_handler = any(
+        (isinstance(h, logging.FileHandler) and 
h.baseFilename.endswith("tasks-vote.log")) for h in root_logger.handlers
+    )
+    if not has_our_handler:
+        root_logger.addHandler(_HANDLER)
+
+    async with db.session() as data:
+        release = await data.release(name=args.release_name, _project=True, 
_committee=True).demand(
+            VoteInitiationError(f"Release {args.release_name} not found")
         )
-        if not has_our_handler:
-            # Add our file handler to the root _LOGGER
-            root_logger.addHandler(_HANDLER)
-            _LOGGER.info("Added file handler to root _LOGGER to capture 
mail.py logs")
-
-        _LOGGER.debug(f"Parsing arguments: {args_list}")
-        args = Args.from_list(args_list)
-        _LOGGER.info(f"Args parsed successfully: {args}")
-
-        # Get the release information
-        release = service.get_release_by_name_sync(args.release_name)
-        if not release:
-            error_msg = f"Release with key {args.release_name} not found"
-            _LOGGER.error(error_msg)
-            return task.FAILED, error_msg, tuple()
-
-        # GPG key ID, just for testing the UI
-        gpg_key_id = args.gpg_key_id
-
-        # Calculate vote end date
-        vote_duration_hours = int(args.vote_duration)
-        vote_start = datetime.datetime.now(datetime.UTC)
-        vote_end = vote_start + datetime.timedelta(hours=vote_duration_hours)
-
-        # Format dates for email
-        vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
-
-        # Load and set DKIM key
-        try:
-            project_root = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-            dkim_path = os.path.join(project_root, "state", "dkim.private")
-
-            with open(dkim_path) as f:
-                dkim_key = f.read()
-                atr.mail.set_secret_key(dkim_key.strip())
-                _LOGGER.info("DKIM key loaded and set successfully")
-        except Exception as e:
-            error_msg = f"Failed to load DKIM key: {e}"
-            _LOGGER.error(error_msg)
-            return task.FAILED, error_msg, tuple()
-
-        # Get PMC and project details
-        if release.committee is None:
-            error_msg = "Release has no associated committee"
-            _LOGGER.error(error_msg)
-            return task.FAILED, error_msg, tuple()
-
-        committee_name = release.committee.name
-        committee_display = release.committee.display_name
-        project_name = release.project.name if release.project else "Unknown"
-        version = release.version
-
-        # Create email subject
-        subject = f"[VOTE] Release Apache {committee_display} {project_name} 
{version}"
-
-        # Create email body with initiator ID
-        body = f"""Hello {committee_name},
+
+    # GPG key ID, just for testing the UI
+    gpg_key_id = args.gpg_key_id
+
+    # Calculate vote end date
+    vote_duration_hours = int(args.vote_duration)
+    vote_start = datetime.datetime.now(datetime.UTC)
+    vote_end = vote_start + datetime.timedelta(hours=vote_duration_hours)
+
+    # Format dates for email
+    vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
+
+    # Load and set DKIM key
+    try:
+        project_root = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+        dkim_path = os.path.join(project_root, "state", "dkim.private")
+
+        async with aiofiles.open(dkim_path) as f:
+            dkim_key = await f.read()
+            mail.set_secret_key(dkim_key.strip())
+            _LOGGER.info("DKIM key loaded and set successfully")
+    except Exception as e:
+        error_msg = f"Failed to load DKIM key: {e}"
+        _LOGGER.error(error_msg)
+        raise VoteInitiationError(error_msg)
+
+    # Get PMC and project details
+    if release.committee is None:
+        error_msg = "Release has no associated committee"
+        _LOGGER.error(error_msg)
+        raise VoteInitiationError(error_msg)
+
+    committee_name = release.committee.name
+    committee_display = release.committee.display_name
+    project_name = release.project.name if release.project else "Unknown"
+    version = release.version
+
+    # Create email subject
+    subject = f"[VOTE] Release Apache {committee_display} {project_name} 
{version}"
+
+    # Create email body with initiator ID
+    body = f"""Hello {committee_name},
 
 I'd like to call a vote on releasing the following artifacts as
 Apache {committee_display} {project_name} {version}.
@@ -213,44 +165,32 @@ Thanks,
 {args.initiator_id}
 """
 
-        # Store the original recipient for logging
-        original_recipient = args.email_to
-        # Only one test recipient is required for now
-        test_recipient = test_recipients[0] + "@apache.org"
-        _LOGGER.info(f"TEMPORARY: Overriding recipient from 
{original_recipient} to {test_recipient}")
-
-        # Create mail event with test recipient
-        # Use test account instead of actual PMC list
-        event = atr.mail.VoteEvent(
-            release_name=args.release_name,
-            email_recipient=test_recipient,
-            subject=subject,
-            body=body,
-            vote_end=vote_end,
-        )
-
-        # Send the email
-        atr.mail.send(event)
-        _LOGGER.info(
-            f"Vote email sent successfully to test account {test_recipient} 
(would have been {original_recipient})"
-        )
+    # Store the original recipient for logging
+    original_recipient = args.email_to
+    # Only one test recipient is required for now
+    test_recipient = test_recipients[0] + "@apache.org"
+    _LOGGER.info(f"TEMPORARY: Overriding recipient from {original_recipient} 
to {test_recipient}")
+
+    # Create mail event with test recipient
+    # Use test account instead of actual PMC list
+    event = mail.VoteEvent(
+        release_name=args.release_name,
+        email_recipient=test_recipient,
+        subject=subject,
+        body=body,
+        vote_end=vote_end,
+    )
 
-        # TODO: Update release status to indicate a vote is in progress
-        # This would involve updating the database with the vote details 
somehow
-        return (
-            task.COMPLETED,
-            None,
-            (
-                {
-                    "message": "Vote initiated successfully (sent to test 
account)",
-                    "original_email_to": original_recipient,
-                    "actual_email_to": test_recipient,
-                    "vote_end": vote_end_str,
-                    "subject": subject,
-                },
-            ),
-        )
+    # Send the email
+    await mail.send(event)
+    _LOGGER.info(
+        f"Vote email sent successfully to test account {test_recipient} (would 
have been {original_recipient})"
+    )
 
-    except Exception as e:
-        _LOGGER.exception(f"Error in initiate_core: {e}")
-        return task.FAILED, str(e), tuple()
+    return {
+        "message": "Vote initiated successfully (sent to test account)",
+        "original_email_to": original_recipient,
+        "actual_email_to": test_recipient,
+        "vote_end": vote_end_str,
+        "subject": subject,
+    }
diff --git a/atr/worker.py b/atr/worker.py
index 13af8bf..04f6cbd 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -44,7 +44,6 @@ import atr.tasks.checks.hashing as hashing
 import atr.tasks.checks.license as license
 import atr.tasks.checks.rat as rat
 import atr.tasks.checks.signature as signature
-import atr.tasks.mailtest as mailtest
 import atr.tasks.rsync as rsync
 import atr.tasks.sbom as sbom
 import atr.tasks.task as task
@@ -195,6 +194,7 @@ async def _task_process(task_id: int, task_type: str, 
task_args: list[str] | dic
             checks.function_key(rat.check): rat.check,
             checks.function_key(signature.check): signature.check,
             checks.function_key(rsync.analyse): rsync.analyse,
+            checks.function_key(vote.initiate): vote.initiate,
         }
         # TODO: We should use a decorator to register these automatically
         dict_task_handlers = {
@@ -204,8 +204,6 @@ async def _task_process(task_id: int, task_type: str, 
task_args: list[str] | dic
         # We plan to convert these to async dict handlers
         list_task_handlers = {
             "generate_cyclonedx_sbom": sbom.generate_cyclonedx,
-            "mailtest_send": mailtest.send,
-            "vote_initiate": vote.initiate,
         }
 
         task_results: tuple[Any, ...]
@@ -224,6 +222,7 @@ async def _task_process(task_id: int, task_type: str, 
task_args: list[str] | dic
                 task_results = tuple()
                 status = task.FAILED
                 error = str(e)
+                _LOGGER.exception(f"Task {task_id} ({task_type}) failed: {e}")
         elif isinstance(task_args, dict):
             dict_handler = dict_task_handlers.get(task_type)
             if not dict_handler:
diff --git a/poetry.lock b/poetry.lock
index f15de57..49ce804 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -154,6 +154,22 @@ files = [
 [package.dependencies]
 frozenlist = ">=1.1.0"
 
+[[package]]
+name = "aiosmtplib"
+version = "4.0.0"
+description = "asyncio SMTP client"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+    {file = "aiosmtplib-4.0.0-py3-none-any.whl", hash = 
"sha256:33c72021cd9e9da495823952751e2dd927014a04a0eca711ee4af9812f2f04af"},
+    {file = "aiosmtplib-4.0.0.tar.gz", hash = 
"sha256:9629a0d8786ab1e5f790ebbbf5cbe7886fedf949a3f52fd7b27a0360f6233422"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints 
(>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
+uvloop = ["uvloop (>=0.18)"]
+
 [[package]]
 name = "aiosqlite"
 version = "0.21.0"
@@ -2770,4 +2786,4 @@ propcache = ">=0.2.0"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"945a3ec633b734097efa3579b9619948d29ce4cf87092ab812ebc28a324ed68a"
+content-hash = 
"19419e871975d018df394bc852c0a913a8f7a5bcc867e795a2f177a668e5cf4c"
diff --git a/pyproject.toml b/pyproject.toml
index d1b5419..d0e6060 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ requires-python = "~=3.13"
 dependencies = [
   "aiofiles>=24.1.0,<25.0.0",
   "aioshutil (>=1.5,<2.0)",
+  "aiosmtplib (>=4.0.0,<5.0.0)",
   "aiosqlite>=0.21.0,<0.22.0",
   "alembic~=1.14",
   "asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@main";,
diff --git a/scripts/poetry/add b/scripts/poetry/add
index d683611..a5de81a 100755
--- a/scripts/poetry/add
+++ b/scripts/poetry/add
@@ -1,2 +1,3 @@
 #!/bin/sh
-exec poetry add "$1"
+poetry add "$1"
+poetry lock


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

Reply via email to