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