You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/04/03 14:06:35 UTC

(tooling-trusted-release) branch main updated: Show vote announcement email status and improve the mail sending code

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 8cd345a  Show vote announcement email status and improve the mail sending code
8cd345a is described below

commit 8cd345a78595da630816c02e5f2c58be4c845d7f
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Apr 3 15:06:07 2025 +0100

    Show vote announcement email status and improve the mail sending code
---
 atr/mail.py                          | 131 ++++++++++++-----------------------
 atr/routes/candidate.py              |  38 ++++++----
 atr/templates/candidate-resolve.html |  35 +++++++++-
 3 files changed, 103 insertions(+), 101 deletions(-)

diff --git a/atr/mail.py b/atr/mail.py
index f2227eb..14ad402 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -15,19 +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 Final
 
 import aiosmtplib
 import dkim
-import dns.rdtypes.ANY.MX as MX
-import dns.resolver as resolver
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -42,6 +39,10 @@ global_domain: str = "tooling-vm-ec2-de.apache.org"
 global_email_contact: str = f"contact@{global_domain}"
 global_secret_key: str | None = None
 
+_MAIL_RELAY: Final[str] = "mail-relay.apache.org"
+_SMTP_PORT: Final[int] = 587
+_SMTP_TIMEOUT: Final[int] = 30
+
 
 class VoteEvent:
     """Data class to represent a release vote event."""
@@ -56,24 +57,24 @@ class VoteEvent:
         self.vote_end = vote_end
 
 
-async def send(event: VoteEvent) -> None:
+async def send(event: VoteEvent) -> str:
     """Send an email notification about an artifact or a vote."""
     _LOGGER.info(f"Sending email for event: {event}")
     from_addr = global_email_contact
+    if not from_addr.endswith(f"@{global_domain}"):
+        raise ValueError(f"from_addr must end with @{global_domain}, got {from_addr}")
     to_addr = event.email_recipient
     _validate_recipient(to_addr)
 
     # UUID4 is entirely random, with no timestamp nor namespace
     # It does have 6 version and variant bits, so only 122 bits are random
-    mid = f"<{uuid.uuid4()}@{global_domain}>"
-
-    # Different message format depending on event type
+    mid = f"{uuid.uuid4()}@{global_domain}"
     msg_text = f"""
 From: {from_addr}
 To: {to_addr}
 Subject: {event.subject}
 Date: {utils.formatdate(localtime=True)}
-Message-ID: {mid}
+Message-ID: <{mid}>
 
 {event.body}
 """
@@ -95,6 +96,8 @@ Message-ID: {mid}
     elapsed = time.perf_counter() - start
     _LOGGER.info(f" send_many took {elapsed:.3f}s")
 
+    return mid
+
 
 def set_secret_key(key: str) -> None:
     """Set the secret key for DKIM signing."""
@@ -102,27 +105,6 @@ def set_secret_key(key: str) -> None:
     global_secret_key = key
 
 
-async def _resolve_mx_records(domain: str) -> list[tuple[str, int]]:
-    """Resolve MX records."""
-    try:
-        mx_records = await asyncio.to_thread(resolver.resolve, domain, "MX")
-        mxs = []
-
-        for rdata in mx_records:
-            if not isinstance(rdata, MX.MX):
-                raise ValueError(f"Unexpected MX record type: {type(rdata)}")
-            mx = rdata
-            mxs.append((mx.exchange.to_text(True), mx.preference))
-        # Sort by preference
-        mxs.sort(key=lambda x: x[1])
-
-        if not mxs:
-            mxs = [(domain, 0)]
-    except Exception as e:
-        raise ValueError(f"Failed to lookup MX records for {domain}: {e}")
-    return mxs
-
-
 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")
@@ -144,67 +126,44 @@ async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> None
 
     # Prepend the DKIM signature to the message
     dkim_msg = sig + message_bytes
-    dkim_reader = io.StringIO(str(dkim_msg, "utf-8"))
 
     _LOGGER.info("email_send_many")
 
+    errors = []
     for addr in to_addrs:
-        _, domain = _split_address(addr)
-
-        if domain == "localhost":
-            mxs = [("127.0.0.1", 0)]
-        else:
-            mxs = await _resolve_mx_records(domain)
-
-        # Try each MX server
-        errors = []
-        for mx_host, _ in mxs:
-            try:
-                await _send_one(mx_host, from_addr, addr, dkim_reader)
-                # Success, no need to try other MX servers
-                break
-            except Exception as e:
-                errors.append(f"Failed to send to {mx_host}: {e}")
-                # Reset reader for next attempt
-                dkim_reader.seek(0)
-        else:
-            # If we get here, all MX servers failed
-            raise Exception("; ".join(errors))
-
-
-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
+        try:
+            await _send_via_relay(from_addr, addr, dkim_msg)
+        except Exception as e:
+            _LOGGER.exception(f"Failed to send to {addr}:")
+            errors.append(f"failed to send to {addr}: {e}")
+
+    if errors:
+        # Raising an exception will ensure that any calling task is marked as failed
+        raise Exception("Failed to send to one or more recipients: " + "; ".join(errors))
+
+
+async def _send_via_relay(from_addr: str, to_addr: str, dkim_msg_bytes: bytes) -> None:
+    """Send a DKIM signed email to a single recipient via the ASF mail relay."""
     _validate_recipient(to_addr)
 
-    try:
-        # Connect to the ASF mail relay
-        # TODO: Use asfpy for sending mail
-        mail_relay = "mail-relay.apache.org"
-        _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
-        await smtp.ehlo()
-
-        # # 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
-        await smtp.sendmail(from_addr, [to_addr], msg_reader.read())
-
-        # Close the connection
-        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}")
+    # Connect to the ASF mail relay
+    # NOTE: Our code is very different from the asfpy code:
+    # - Uses types
+    # - Uses asyncio
+    # - Performs DKIM signing
+    # Due to the divergence, we should probably not contribute upstream
+    # In effect, these are two different "packages" of functionality
+    # We can't even sign it first and pass it to asfpy, due to its different design
+    _LOGGER.info(f"Connecting async to {_MAIL_RELAY}:{_SMTP_PORT}")
+    context = ssl.create_default_context()
+    context.minimum_version = ssl.TLSVersion.TLSv1_2
+
+    smtp = aiosmtplib.SMTP(hostname=_MAIL_RELAY, port=_SMTP_PORT, timeout=_SMTP_TIMEOUT, tls_context=context)
+    await smtp.connect()
+    _LOGGER.info(f"Connected to {smtp.hostname}:{smtp.port}")
+    await smtp.ehlo()
+    await smtp.sendmail(from_addr, [to_addr], dkim_msg_bytes)
+    await smtp.quit()
 
 
 def _split_address(addr: str) -> tuple[str, str]:
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index c1e819e..f17e478 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -317,21 +317,33 @@ async def _resolve_get(session: routes.CommitterSession) -> str:
             phase=models.ReleasePhase.RELEASE_CANDIDATE_DURING_VOTE,
             _committee=True,
             _project=True,
+            _tasks=True,
         ).all()
-        user_candidates = session.only_user_releases(releases)
-
-        # Create a unique form for each candidate
-        candidate_forms = {}
-        for candidate in user_candidates:
-            form = await ResolveForm.create_form()
-            candidate_forms[candidate.name] = form
+    user_candidates = session.only_user_releases(releases)
+
+    # Create a unique form for each candidate and find the latest vote initiation task
+    candidate_forms = {}
+    candidate_vote_tasks = {}
+    for candidate in user_candidates:
+        form = await ResolveForm.create_form()
+        candidate_forms[candidate.name] = form
+
+        # Find the most recent VOTE_INITIATE task for this release
+        # TODO: Make this a proper query
+        latest_vote_task = None
+        for task in sorted(candidate.tasks, key=lambda t: t.added, reverse=True):
+            if task.task_type == models.TaskType.VOTE_INITIATE:
+                latest_vote_task = task
+                break
+        candidate_vote_tasks[candidate.name] = latest_vote_task
 
-        return await quart.render_template(
-            "candidate-resolve.html",
-            candidates=user_candidates,
-            candidate_forms=candidate_forms,
-            format_artifact_name=_format_artifact_name,
-        )
+    return await quart.render_template(
+        "candidate-resolve.html",
+        candidates=user_candidates,
+        candidate_forms=candidate_forms,
+        candidate_vote_tasks=candidate_vote_tasks,
+        format_artifact_name=_format_artifact_name,
+    )
 
 
 async def _resolve_post(session: routes.CommitterSession) -> response.Response:
diff --git a/atr/templates/candidate-resolve.html b/atr/templates/candidate-resolve.html
index c8dc9e8..7cda021 100644
--- a/atr/templates/candidate-resolve.html
+++ b/atr/templates/candidate-resolve.html
@@ -34,14 +34,45 @@
     {% for candidate in candidates %}
       <div class="card mb-3 bg-light">
         <div class="card-body">
-          <h3 class="card-title mb-2">{{ candidate.committee.display_name }}</h3>
+          <h3 class="card-title mb-2">{{ candidate.project.display_name }} {{ candidate.version }}</h3>
           <div class="d-flex flex-wrap gap-3 pb-3 mb-2 border-bottom text-secondary fs-6">
-            <span class="atr-candidate-meta-item">Version: {{ candidate.version }}</span>
             <span class="atr-candidate-meta-item">Stage: {{ candidate.stage.value.upper() }}</span>
             <span class="atr-candidate-meta-item">Phase: {{ candidate.phase.value.upper() }}</span>
             <span class="atr-candidate-meta-item">Created: {{ candidate.created.strftime("%Y-%m-%d %H:%M UTC") }}</span>
           </div>
 
+          {% set vote_task = candidate_vote_tasks[candidate.name] %}
+          <div class="mt-2 p-3 border rounded bg-white mb-3">
+            {% if vote_task %}
+              {% if vote_task.status.value == "completed" %}
+                <p class="mb-0 text-success fw-semibold">
+                  <i class="bi bi-check-circle-fill me-1"></i> Vote email sent: {{ vote_task.completed.strftime("%Y-%m-%d %H:%M UTC") }}
+                </p>
+              {% elif vote_task.status.value == "failed" %}
+                <p class="mb-1 text-danger fw-semibold">
+                  <i class="bi bi-x-octagon-fill me-1"></i> Vote email failed: {{ vote_task.completed.strftime("%Y-%m-%d %H:%M UTC") }}
+                </p>
+                <div class="alert alert-danger mt-1 mb-0 p-2" role="alert">
+                  <strong>Error:</strong>
+                  <pre class="mb-0 fs-6"><code class="text-danger">{{ vote_task.error }}</code></pre>
+                </div>
+              {% else %}
+                <p class="mb-0 text-warning fw-semibold">
+                  <i class="bi bi-hourglass-split me-1"></i> Vote email status: {{ vote_task.status.value.upper() }}
+                  {% if vote_task.started %}
+                    (Started: {{ vote_task.started.strftime("%Y-%m-%d %H:%M UTC") }})
+                  {% else %}
+                    (Added: {{ vote_task.added.strftime("%Y-%m-%d %H:%M UTC") }})
+                  {% endif %}
+                </p>
+              {% endif %}
+            {% else %}
+              <p class="mb-0 text-muted">
+                <i class="bi bi-question-circle me-1"></i> Vote email: No task found
+              </p>
+            {% endif %}
+          </div>
+
           <form method="post"
                 action="{{ as_url(routes.candidate.resolve) }}"
                 class="mt-3 p-3 border rounded bg-white">


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org