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-atr-experiments.git


The following commit(s) were added to refs/heads/main by this push:
     new fa54317  Add a test email task
fa54317 is described below

commit fa5431748d6bda7330e75e5b0d738a6be922b8da
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 5 21:11:08 2025 +0200

    Add a test email task
---
 atr/mail.py                       | 220 ++++++++++++++++++++++++++++++++++++++
 atr/manager.py                    |  33 +++++-
 atr/routes/__init__.py            |  27 +++++
 atr/routes/candidate.py           |  28 +----
 atr/routes/dev.py                 |  86 +++++++++++++++
 atr/routes/download.py            |  29 +----
 atr/routes/keys.py                |  28 +----
 atr/routes/package.py             |  27 +----
 atr/routes/release.py             |  30 +-----
 atr/server.py                     |   3 +-
 atr/tasks/mailtest.py             | 148 +++++++++++++++++++++++++
 atr/templates/dev-send-email.html | 153 ++++++++++++++++++++++++++
 atr/worker.py                     |   6 +-
 docs/plan.html                    |   2 +
 docs/plan.md                      |   2 +
 poetry.lock                       | 102 +++++++++++++-----
 pyproject.toml                    |   8 ++
 uv.lock                           |  37 +++++--
 18 files changed, 791 insertions(+), 178 deletions(-)

diff --git a/atr/mail.py b/atr/mail.py
new file mode 100644
index 0000000..3e6158e
--- /dev/null
+++ b/atr/mail.py
@@ -0,0 +1,220 @@
+# 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 datetime
+import logging
+import smtplib
+import ssl
+import time
+import uuid
+from email.utils import formatdate
+from io import StringIO
+from typing import Any
+
+import dkim
+import dns.rdtypes.ANY.MX
+import dns.resolver
+
+# TODO: We should choose a pattern for globals
+# We could e.g. use uppercase instead of global_
+# It's not always worth identifying globals as globals
+# But in many cases we should do so
+# TODO: Get at least global_domain from configuration
+# And probably global_dkim_selector too
+global_dkim_selector = "202501"
+global_domain = "tooling-vm-ec2-de.apache.org"
+global_email_contact = f"contact@{global_domain}"
+global_secret_key: str | None = None
+
+
+def set_secret_key(key: str) -> None:
+    """Set the secret key for DKIM signing."""
+    global global_secret_key
+    global_secret_key = key
+
+
+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
+
+
+def split_address(addr: str) -> tuple[str, str]:
+    """Split an email address into local and domain parts."""
+    parts = addr.split("@", 1)
+    if len(parts) != 2:
+        raise ValueError("Invalid mail address")
+    return parts[0], parts[1]
+
+
+def send(event: ArtifactEvent) -> None:
+    """Send an email notification about an artifact."""
+    logging.info(f"Sending email for event: {event}")
+    from_addr = global_email_contact
+    to_addr = event.email_recipient
+    # 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}>"
+    msg_text = f"""
+From: {from_addr}
+To: {to_addr}
+Subject: {event.artifact_name}
+Date: {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
+    msg_text = msg_text.strip().replace("\n", "\r\n") + "\r\n"
+
+    start = time.perf_counter()
+    logging.info(f"sending message: {msg_text}")
+
+    try:
+        send_many(from_addr, [to_addr], msg_text)
+        logging.info(f"sent to {to_addr}")
+    except Exception as e:
+        logging.error(f"send error: {e}")
+
+    elapsed = time.perf_counter() - start
+    logging.info(f" send_many took {elapsed:.3f}s")
+
+
+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")
+
+    if global_secret_key is None:
+        raise ValueError("global_secret_key is not set")
+
+    # DKIM sign the message
+    private_key = bytes(global_secret_key, "utf-8")
+
+    # Create a DKIM signature
+    sig = dkim.sign(
+        message=message_bytes,
+        selector=bytes(global_dkim_selector, "utf-8"),
+        domain=bytes(global_domain, "utf-8"),
+        privkey=private_key,
+        include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"],
+    )
+
+    # Prepend the DKIM signature to the message
+    dkim_msg = sig + message_bytes
+    dkim_reader = StringIO(str(dkim_msg, "utf-8"))
+
+    logging.info("email_send_many")
+
+    for addr in to_addrs:
+        _, domain = split_address(addr)
+
+        if domain == "localhost":
+            mxs = [("127.0.0.1", 0)]
+        else:
+            mxs = resolve_mx_records(domain)
+
+        # Try each MX server
+        errors = []
+        for mx_host, _ in mxs:
+            try:
+                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))
+
+
+def resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+    try:
+        # Query MX records
+        mx_records = dns.resolver.resolve(domain, "MX")
+        mxs = []
+
+        for rdata in mx_records:
+            if not isinstance(rdata, dns.rdtypes.ANY.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, array position one
+        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
+
+
+class LoggingSMTP(smtplib.SMTP):
+    def _print_debug(self, *args: Any) -> None:
+        template = ["%s"] * len(args)
+        if self.debuglevel > 1:
+            template.append("%s")
+            logging.info(" ".join(template), datetime.datetime.now().time(), 
*args)
+        else:
+            logging.info(" ".join(template), *args)
+
+
+def send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader: StringIO) 
-> None:
+    """Send an email to a single recipient via a specific MX server."""
+    default_timeout_seconds = 30
+
+    try:
+        # Connect to the SMTP server
+        logging.info(f"Connecting to {mx_host}:25")
+        smtp = LoggingSMTP(mx_host, 25, timeout=default_timeout_seconds)
+        smtp.set_debuglevel(2)
+
+        # Identify ourselves to the server
+        smtp.ehlo(global_domain)
+
+        # If STARTTLS is available, use it
+        if smtp.has_extn("STARTTLS"):
+            context = ssl.create_default_context()
+            context.minimum_version = ssl.TLSVersion.TLSv1_2
+            smtp.starttls(context=context)
+            # Re-identify after TLS
+            smtp.ehlo(global_domain)
+
+        # Send the message
+        smtp.mail(from_addr)
+        smtp.rcpt(to_addr)
+        smtp.data(msg_reader.read())
+
+        # Close the connection
+        smtp.quit()
+
+    except (OSError, smtplib.SMTPException) as e:
+        raise Exception(f"SMTP error: {e}")
diff --git a/atr/manager.py b/atr/manager.py
index b48d952..a1b1cbb 100644
--- a/atr/manager.py
+++ b/atr/manager.py
@@ -23,6 +23,7 @@ import os
 import signal
 import sys
 from datetime import UTC, datetime
+from io import TextIOWrapper
 
 from sqlalchemy import text
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,9 @@ logging.basicConfig(
 )
 logger = logging.getLogger(__name__)
 
+# Global debug flag to control worker process output capturing
+global_worker_debug = False
+
 
 class WorkerProcess:
     """Interface to control a worker process."""
@@ -172,13 +176,30 @@ class WorkerManager:
             # Get absolute path to worker script
             worker_script = os.path.join(project_root, "atr", "worker.py")
 
+            # Handle stdout and stderr based on debug setting
+            stdout_target: int | TextIOWrapper = asyncio.subprocess.DEVNULL
+            stderr_target: int | TextIOWrapper = asyncio.subprocess.DEVNULL
+
+            # Generate a unique log file name for this worker if debugging is 
enabled
+            log_file_path = None
+            if global_worker_debug:
+                timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
+                log_file_name = f"worker_{timestamp}_{os.getpid()}.log"
+                log_file_path = os.path.join(project_root, "state", 
log_file_name)
+
+                # Open log file for writing
+                log_file = await asyncio.to_thread(open, log_file_path, "w")
+                stdout_target = log_file
+                stderr_target = log_file
+                logger.info(f"Worker output will be logged to {log_file_path}")
+
             # Start worker process with the updated environment
             # Use preexec_fn to create new process group
             process = await asyncio.create_subprocess_exec(
                 sys.executable,
                 worker_script,
-                stdout=asyncio.subprocess.DEVNULL,
-                stderr=asyncio.subprocess.DEVNULL,
+                stdout=stdout_target,
+                stderr=stderr_target,
                 env=env,
                 preexec_fn=os.setsid,
             )
@@ -187,10 +208,12 @@ class WorkerManager:
             if worker.pid:
                 self.workers[worker.pid] = worker
                 logger.info(f"Started worker process {worker.pid}")
+                if global_worker_debug and log_file_path:
+                    logger.info(f"Worker {worker.pid} logs: {log_file_path}")
             else:
-                # TODO: We should count failures
-                # We could perhaps stop the manager over a certain count
-                logger.error("Failed to start worker process")
+                logger.error("Failed to start worker process: No PID assigned")
+                if global_worker_debug and isinstance(stdout_target, 
TextIOWrapper):
+                    await asyncio.to_thread(stdout_target.close)
         except Exception as e:
             logger.error(f"Error spawning worker: {e}")
 
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 91e7632..e48f47a 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -23,6 +23,8 @@ from collections.abc import Awaitable, Callable, Coroutine
 from typing import Any, ParamSpec, TypeVar
 
 import aiofiles
+from quart import Request
+from werkzeug.datastructures import MultiDict
 
 from asfquart import APP
 
@@ -247,3 +249,28 @@ def app_route_performance_measure(route_path: str, 
http_methods: list[str] | Non
         return wrapper
 
     return decorator
+
+
+async def get_form(request: Request) -> MultiDict:
+    # The request.form() method in Quart calls a synchronous tempfile method
+    # It calls quart.wrappers.request.form _load_form_data
+    # Which calls quart.formparser parse and parse_func and parser.parse
+    # Which calls _write which calls tempfile, which is synchronous
+    # It's getting a tempfile back from some prior call
+    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
+    from asfquart import APP
+
+    if APP is ...:
+        raise RuntimeError("APP is not set")
+
+    # Or quart.current_app?
+    blockbuster = APP.config["blockbuster"]
+
+    # Turn blockbuster off
+    if blockbuster is not None:
+        blockbuster.deactivate()
+    form = await request.form
+    # Turn blockbuster on
+    if blockbuster is not None:
+        blockbuster.activate()
+    return form
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 2493c5d..9967784 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -29,7 +29,6 @@ from quart import Request, redirect, render_template, 
request, url_for
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
 from sqlmodel import select
-from werkzeug.datastructures import MultiDict
 from werkzeug.wrappers.response import Response
 
 from asfquart import APP
@@ -47,7 +46,7 @@ from atr.db.models import (
     ReleaseStage,
     Task,
 )
-from atr.routes import app_route
+from atr.routes import app_route, get_form
 
 if APP is ...:
     raise RuntimeError("APP is not set")
@@ -95,31 +94,6 @@ def format_artifact_name(project_name: str, product_name: 
str, version: str, is_
     return f"apache-{project_name}-{product_name}-{version}"
 
 
-async def get_form(request: Request) -> MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-    from asfquart import APP
-
-    if APP is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = APP.config["blockbuster"]
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
 # Release functions
 
 
diff --git a/atr/routes/dev.py b/atr/routes/dev.py
new file mode 100644
index 0000000..57990c2
--- /dev/null
+++ b/atr/routes/dev.py
@@ -0,0 +1,86 @@
+# 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 quart import render_template, request
+from quart.typing import ResponseReturnValue
+
+from asfquart import APP
+from asfquart.auth import Requirements, require
+from asfquart.base import ASFQuartException
+from asfquart.session import read as session_read
+from atr.db import get_session
+from atr.db.models import Task, TaskStatus
+from atr.routes import app_route, get_form
+
+if APP is ...:
+    raise RuntimeError("APP is not set")
+
+
+@app_route("/dev/send-email", methods=["GET", "POST"])
+@require(Requirements.committer)
+async def dev_email_send() -> ResponseReturnValue:
+    """Simple endpoint for testing email functionality."""
+    session = await session_read()
+    if session is None:
+        raise ASFQuartException("Not authenticated", errorcode=401)
+    asf_id = session.uid
+
+    if request.method == "POST":
+        form = await get_form(request)
+
+        email = form.get("email_recipient", "")
+        name = form.get("artifact_name", "")
+        token = form.get("token", "")
+
+        if not email:
+            return await render_template(
+                "dev-send-email.html",
+                asf_id=asf_id,
+                error="Email recipient is required",
+            )
+
+        if not name:
+            return await render_template(
+                "dev-send-email.html",
+                asf_id=asf_id,
+                error="Artifact name is required",
+            )
+
+        # Create a task for mail testing
+        async with get_session() as db_session:
+            async with db_session.begin():
+                task = Task(
+                    status=TaskStatus.QUEUED,
+                    task_type="mailtest_send",
+                    task_args=[name, email, token],
+                )
+                db_session.add(task)
+                # Flush to get the task ID
+                await db_session.flush()
+
+        return await render_template(
+            "dev-send-email.html",
+            asf_id=asf_id,
+            success=True,
+            message=f"Email task queued with ID {task.id}. It will be 
processed by a worker.",
+            email_recipient=email,
+            artifact_name=name,
+            token=token,
+        )
+
+    return await render_template("dev-send-email.html", asf_id=asf_id)
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 4d35339..43fb846 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -31,16 +31,15 @@ from typing import cast
 
 import aiofiles
 import aiofiles.os
-from quart import Request, flash, redirect, send_file, url_for
+from quart import flash, redirect, send_file, url_for
 from quart.wrappers.response import Response as QuartResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
 from sqlmodel import select
-from werkzeug.datastructures import FileStorage, MultiDict
+from werkzeug.datastructures import FileStorage
 from werkzeug.wrappers.response import Response
 
-from asfquart import APP
 from asfquart.auth import Requirements, require
 from asfquart.base import ASFQuartException
 from asfquart.session import ClientSession
@@ -109,30 +108,6 @@ async def file_hash_save(base_dir: Path, file: 
FileStorage) -> tuple[str, int]:
         raise e
 
 
-async def get_form(request: Request) -> MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-
-    if APP is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = APP.config["blockbuster"]
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
 async def key_user_session_add(
     session: ClientSession,
     public_key: str,
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index ecc514b..7946cb2 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -34,10 +34,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
 from sqlmodel import select
-from werkzeug.datastructures import MultiDict
 from werkzeug.wrappers.response import Response
 
-from asfquart import APP
 from asfquart.auth import Requirements, require
 from asfquart.base import ASFQuartException
 from asfquart.session import ClientSession
@@ -48,7 +46,7 @@ from atr.db.models import (
     PMCKeyLink,
     PublicSigningKey,
 )
-from atr.routes import FlashError, algorithms, app_route
+from atr.routes import FlashError, algorithms, app_route, get_form
 
 
 @asynccontextmanager
@@ -63,30 +61,6 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
         await asyncio.to_thread(shutil.rmtree, temp_dir)
 
 
-async def get_form(request: Request) -> MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-
-    if APP is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = APP.config["blockbuster"]
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
 async def key_add_post(session: ClientSession, request: Request, user_pmcs: 
Sequence[PMC]) -> dict | None:
     form = await get_form(request)
     public_key = form.get("public_key")
diff --git a/atr/routes/package.py b/atr/routes/package.py
index 0670247..d6f35f4 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -37,7 +37,6 @@ from sqlmodel import select
 from werkzeug.datastructures import FileStorage, MultiDict
 from werkzeug.wrappers.response import Response
 
-from asfquart import APP
 from asfquart.auth import Requirements, require
 from asfquart.base import ASFQuartException
 from asfquart.session import read as session_read
@@ -51,7 +50,7 @@ from atr.db.models import (
     Task,
     TaskStatus,
 )
-from atr.routes import FlashError, app_route
+from atr.routes import FlashError, app_route, get_form
 from atr.util import compute_sha512, get_release_storage_dir
 
 
@@ -114,30 +113,6 @@ def format_file_size(size_in_bytes: int) -> str:
         return f"{formatted_bytes} bytes"
 
 
-async def get_form(request: Request) -> MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-
-    if APP is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = APP.config["blockbuster"]
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
 # Package functions
 
 
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 082e25b..a906549 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -24,12 +24,11 @@ from typing import cast
 
 import aiofiles
 import aiofiles.os
-from quart import Request, flash, redirect, render_template, request, url_for
+from quart import flash, redirect, render_template, request, url_for
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
 from sqlmodel import select
-from werkzeug.datastructures import MultiDict
 from werkzeug.wrappers.response import Response
 
 from asfquart import APP
@@ -44,38 +43,13 @@ from atr.db.models import (
     Task,
     TaskStatus,
 )
-from atr.routes import FlashError, app_route
+from atr.routes import FlashError, app_route, get_form
 from atr.util import get_release_storage_dir
 
 if APP is ...:
     raise RuntimeError("APP is not set")
 
 
-async def get_form(request: Request) -> MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-    from asfquart import APP
-
-    if APP is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = APP.config["blockbuster"]
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
 # Package functions
 
 
diff --git a/atr/server.py b/atr/server.py
index a9cc508..a6fdc99 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -63,11 +63,12 @@ class ApiOnlyOpenAPIProvider(OpenAPIProvider):
 
 
 def register_routes() -> tuple[str, ...]:
-    from atr.routes import candidate, docs, download, keys, package, project, 
release, root
+    from atr.routes import candidate, dev, docs, download, keys, package, 
project, release, root
 
     # Must do this otherwise ruff "fixes" this function by removing the imports
     return (
         candidate.__name__,
+        dev.__name__,
         docs.__name__,
         download.__name__,
         keys.__name__,
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
new file mode 100644
index 0000000..1b362d0
--- /dev/null
+++ b/atr/tasks/mailtest.py
@@ -0,0 +1,148 @@
+# 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 logging
+import os
+from dataclasses import dataclass
+from typing import Any
+
+# Configure detailed logging
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+# Create file handler for test.log
+file_handler = logging.FileHandler("tasks-mailtest.log")
+file_handler.setLevel(logging.DEBUG)
+
+# Create formatter with detailed information
+formatter = 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",
+)
+file_handler.setFormatter(formatter)
+logger.addHandler(file_handler)
+# Ensure parent loggers don't duplicate messages
+logger.propagate = False
+
+logger.info("Mail test module imported")
+
+
+@dataclass
+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[str, 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 "FAILED", str(e), tuple()
+
+
+def send_core(args_list: list[str]) -> tuple[str, str | None, tuple[Any, ...]]:
+    """Send a test email."""
+    import atr.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(file_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}"
+        )
+
+        # 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 "FAILED", error_msg, tuple()
+
+        event = atr.mail.ArtifactEvent(
+            artifact_name=args.artifact_name,
+            email_recipient=args.email_recipient,
+            token=args.token,
+        )
+        atr.mail.send(event)
+        logger.info(f"Email sent successfully to {args.email_recipient}")
+
+        return "COMPLETED", None, tuple()
+
+    except Exception as e:
+        logger.exception(f"Error in send_core: {e}")
+        return "FAILED", str(e), tuple()
diff --git a/atr/templates/dev-send-email.html 
b/atr/templates/dev-send-email.html
new file mode 100644
index 0000000..eaaff3f
--- /dev/null
+++ b/atr/templates/dev-send-email.html
@@ -0,0 +1,153 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Email testing ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Test email sending functionality.
+{% endblock description %}
+
+{% block stylesheets %}
+  {{ super() }}
+  <style>
+      .form-table {
+          width: 100%;
+      }
+
+      .form-table th {
+          width: 200px;
+          text-align: right;
+          padding-right: 1rem;
+          vertical-align: top;
+          font-weight: 500;
+      }
+
+      .form-table td {
+          vertical-align: top;
+      }
+
+      .form-table label {
+          border-bottom: none;
+          padding-bottom: 0;
+      }
+
+      input[type="text"],
+      input[type="email"] {
+          width: 100%;
+          max-width: 600px;
+          padding: 0.375rem;
+          border: 1px solid #ced4da;
+          border-radius: 0.25rem;
+      }
+
+      .help-text {
+          color: #666;
+          font-size: 0.9em;
+          display: block;
+          margin-top: 0.25rem;
+      }
+
+      .error-message {
+          color: #dc3545;
+          margin-top: 0.25rem;
+      }
+
+      .success-message {
+          color: #28a745;
+          margin-top: 0.25rem;
+      }
+
+      button {
+          margin-top: 1rem;
+      }
+  </style>
+{% endblock stylesheets %}
+
+{% block content %}
+  <h1>Test email sending</h1>
+  <p class="intro">
+    Welcome, <strong>{{ asf_id }}</strong>! Use this form to test the email 
sending functionality.
+  </p>
+
+  {% if error %}
+    <div class="error-message">
+      <p>
+        <strong>Error:</strong> {{ error }}
+      </p>
+    </div>
+  {% endif %}
+
+  {% if success %}
+    <div class="success-message">
+      <p>
+        <strong>Success!</strong>
+        {% if message %}
+          {{ message }}
+        {% else %}
+          Email was sent successfully.
+        {% endif %}
+      </p>
+    </div>
+  {% endif %}
+
+  <form method="post" class="striking">
+    <table class="form-table">
+      <tbody>
+        <tr>
+          <th>
+            <label for="email_recipient">Recipient email:</label>
+          </th>
+          <td>
+            <input type="email"
+                   id="email_recipient"
+                   name="email_recipient"
+                   required
+                   value="{{ email_recipient or '' }}"
+                   placeholder="[email protected]"
+                   aria-describedby="email-help" />
+            <span id="email-help" class="help-text">Enter the email address to 
send the test email to</span>
+          </td>
+        </tr>
+
+        <tr>
+          <th>
+            <label for="artifact_name">Artifact name:</label>
+          </th>
+          <td>
+            <input type="text"
+                   id="artifact_name"
+                   name="artifact_name"
+                   required
+                   value="{{ artifact_name or '' }}"
+                   placeholder="my-artifact-1.0.0"
+                   aria-describedby="artifact-help" />
+            <span id="artifact-help" class="help-text">Enter a name for the 
artifact</span>
+          </td>
+        </tr>
+
+        <tr>
+          <th>
+            <label for="token">Token (optional):</label>
+          </th>
+          <td>
+            <input type="text"
+                   id="token"
+                   name="token"
+                   value="{{ token or '' }}"
+                   placeholder="Optional token"
+                   aria-describedby="token-help" />
+            <span id="token-help" class="help-text">Optional token to include 
in the email</span>
+          </td>
+        </tr>
+
+        <tr>
+          <td></td>
+          <td>
+            <button type="submit">Send test email</button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </form>
+{% endblock content %}
diff --git a/atr/worker.py b/atr/worker.py
index f5bb158..dc8b315 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -388,6 +388,7 @@ def task_process(task_id: int, task_type: str, task_args: 
str) -> None:
     # TODO: This does not go here permanently
     # We need to move the other tasks into atr.tasks
     from atr.tasks.bulk import download as bulk_download
+    from atr.tasks.mailtest import send as mailtest_send
 
     logger.info(f"Processing task {task_id} ({task_type}) with args 
{task_args}")
     try:
@@ -404,11 +405,12 @@ def task_process(task_id: int, task_type: str, task_args: 
str) -> None:
             "verify_rat_license": task_verify_rat_license,
             "generate_cyclonedx_sbom": task_generate_cyclonedx_sbom,
             "package_bulk_download": bulk_download,
+            "mailtest_send": mailtest_send,
         }
 
         handler = task_handlers.get(task_type)
         if not handler:
-            msg = f"Unknown task type: {task_type}"
+            msg = f"Unknown task type: {task_type}, {task_handlers.keys()}"
             logger.error(msg)
             raise Exception(msg)
 
@@ -478,6 +480,8 @@ def worker_signal_handle(signum: int, frame: object) -> 
None:
 
 
 if __name__ == "__main__":
+    logger.info("Starting ATR worker...")
+    print("Starting ATR worker...")
     try:
         main()
     except Exception as e:
diff --git a/docs/plan.html b/docs/plan.html
index 2ea18d7..df91dcb 100644
--- a/docs/plan.html
+++ b/docs/plan.html
@@ -113,6 +113,8 @@
 <li>[DONE] Fix and improve the package checks summary count</li>
 <li>Ensure that all errors are caught and logged or displayed</li>
 <li>Add tests</li>
+<li>Improve the proprietary platform patch in ASFQuart and submit upstream</li>
+<li>Patch the synchronous behaviour in Jinja and submit upstream</li>
 </ul>
 </li>
 <li>
diff --git a/docs/plan.md b/docs/plan.md
index 3e67245..56959aa 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -86,6 +86,8 @@ Advanced tasks, possibly deferred
    - [DONE] Fix and improve the package checks summary count
    - Ensure that all errors are caught and logged or displayed
    - Add tests
+   - Improve the proprietary platform patch in ASFQuart and submit upstream
+   - Patch the synchronous behaviour in Jinja and submit upstream
 
 2. Ensure that performance is optimal
    - [DONE] Add page load timing metrics to a log
diff --git a/poetry.lock b/poetry.lock
index cdb0165..9807fe6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be 
changed by hand.
+# This file is automatically @generated by Poetry 2.0.1 and should not be 
changed by hand.
 
 [[package]]
 name = "aiofiles"
@@ -14,14 +14,14 @@ files = [
 
 [[package]]
 name = "aiohappyeyeballs"
-version = "2.4.6"
+version = "2.4.8"
 description = "Happy Eyeballs for asyncio"
 optional = false
 python-versions = ">=3.9"
 groups = ["main"]
 files = [
-    {file = "aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = 
"sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1"},
-    {file = "aiohappyeyeballs-2.4.6.tar.gz", hash = 
"sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0"},
+    {file = "aiohappyeyeballs-2.4.8-py3-none-any.whl", hash = 
"sha256:6cac4f5dd6e34a9644e69cf9021ef679e4394f54e58a183056d12009e42ea9e3"},
+    {file = "aiohappyeyeballs-2.4.8.tar.gz", hash = 
"sha256:19728772cb12263077982d2f55453babd8bec6a052a926cd5c0c42796da8bf62"},
 ]
 
 [[package]]
@@ -125,7 +125,7 @@ propcache = ">=0.2.0"
 yarl = ">=1.17.0,<2.0"
 
 [package.extras]
-speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns 
(>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", 
"brotlicffi ; platform_python_implementation != \"CPython\""]
+speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
 
 [[package]]
 name = "aiosignal"
@@ -163,23 +163,23 @@ docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"]
 
 [[package]]
 name = "alembic"
-version = "1.14.1"
+version = "1.15.1"
 description = "A database migration tool for SQLAlchemy."
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 groups = ["main"]
 files = [
-    {file = "alembic-1.14.1-py3-none-any.whl", hash = 
"sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5"},
-    {file = "alembic-1.14.1.tar.gz", hash = 
"sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213"},
+    {file = "alembic-1.15.1-py3-none-any.whl", hash = 
"sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe"},
+    {file = "alembic-1.15.1.tar.gz", hash = 
"sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49"},
 ]
 
 [package.dependencies]
 Mako = "*"
-SQLAlchemy = ">=1.3.0"
-typing-extensions = ">=4"
+SQLAlchemy = ">=1.4.0"
+typing-extensions = ">=4.12"
 
 [package.extras]
-tz = ["backports.zoneinfo ; python_version < \"3.9\"", "tzdata"]
+tz = ["tzdata"]
 
 [[package]]
 name = "annotated-types"
@@ -211,7 +211,7 @@ sniffio = ">=1.1"
 
 [package.extras]
 doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints 
(>=1.2.0)", "sphinx_rtd_theme"]
-test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", 
"hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", 
"truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; 
platform_python_implementation == \"CPython\" and platform_system != 
\"Windows\" and python_version < \"3.14\""]
+test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", 
"hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", 
"truststore (>=0.9.1)", "uvloop (>=0.21)"]
 trio = ["trio (>=0.26.1)"]
 
 [[package]]
@@ -284,12 +284,12 @@ files = [
 ]
 
 [package.extras]
-benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", 
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" 
and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", 
"pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == 
\"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", 
"coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; 
platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", 
"pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; 
platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", 
"pytest-xdist[psutil]"]
-dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", 
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" 
and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest 
(>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == 
\"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", 
"pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", 
"pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy 
(>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", 
"pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", 
"pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
 docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", 
"sphinxcontrib-towncrier", "towncrier (<24.7)"]
-tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", 
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" 
and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", 
"pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and 
python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" 
and python_version >= \"3.10\"", "pytest-mypy-plugins ; 
platform_python_implementation == \"CPython\" and python_version >= \"3.10\""]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest 
(>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
 
 [[package]]
 name = "blinker"
@@ -602,10 +602,10 @@ files = [
 cffi = {version = ">=1.12", markers = "platform_python_implementation != 
\"PyPy\""}
 
 [package.extras]
-docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= 
\"3.8\""]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"]
 docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", 
"sphinxcontrib-spelling (>=7.3.1)"]
-nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
-pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", 
"mypy (>=1.4)", "ruff (>=0.3.6)"]
+nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"]
+pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
 sdist = ["build (>=1.0.0)"]
 ssh = ["bcrypt (>=3.1.5)"]
 test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend 
(>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov 
(>=2.10.1)", "pytest-xdist (>=3.5.0)"]
@@ -683,6 +683,52 @@ pyyaml = ">=6"
 regex = ">=2023"
 tqdm = ">=4.62.2"
 
+[[package]]
+name = "dkimpy"
+version = "1.1.9+sbp1"
+description = "DKIM (DomainKeys Identified Mail), ARC (Authenticated Receive 
Chain), and TLSRPT (TLS Report) email signing and verification"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = []
+develop = false
+
+[package.dependencies]
+dnspython = ">=2.0.0"
+
+[package.extras]
+arc = ["authres"]
+asyncio = ["aiodns"]
+ed25519 = ["pynacl"]
+testing = ["authres", "pynacl"]
+
+[package.source]
+type = "git"
+url = "https://github.com/sbp/dkimpy.git";
+reference = "main"
+resolved_reference = "7ed6f94a94719a804bc8351782dbdb77d75b029f"
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+description = "DNS toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+    {file = "dnspython-2.7.0-py3-none-any.whl", hash = 
"sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
+    {file = "dnspython-2.7.0.tar.gz", hash = 
"sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
+]
+
+[package.extras]
+dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn 
(>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov 
(>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme 
(>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
+dnssec = ["cryptography (>=43)"]
+doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
+doq = ["aioquic (>=1.0.0)"]
+idna = ["idna (>=3.7)"]
+trio = ["trio (>=0.23)"]
+wmi = ["wmi (>=1.5.1)"]
+
 [[package]]
 name = "dunamai"
 version = "1.23.0"
@@ -748,7 +794,7 @@ files = [
 [package.extras]
 docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints 
(>=3)"]
 testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover 
(>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov 
(>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv 
(>=20.28.1)"]
-typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
+typing = ["typing-extensions (>=4.12.2)"]
 
 [[package]]
 name = "flask"
@@ -1054,7 +1100,7 @@ httpcore = "==1.*"
 idna = "*"
 
 [package.extras]
-brotli = ["brotli ; platform_python_implementation == \"CPython\"", 
"brotlicffi ; platform_python_implementation != \"CPython\""]
+brotli = ["brotli", "brotlicffi"]
 cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
@@ -1082,7 +1128,7 @@ wsproto = ">=0.14.0"
 docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"]
 h3 = ["aioquic (>=0.9.0,<1.0)"]
 trio = ["trio (>=0.22.0)"]
-uvloop = ["uvloop (>=0.18) ; platform_system != \"Windows\""]
+uvloop = ["uvloop (>=0.18)"]
 
 [[package]]
 name = "hyperframe"
@@ -1697,7 +1743,7 @@ typing-extensions = ">=4.12.2"
 
 [package.extras]
 email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == 
\"Windows\""]
+timezone = ["tzdata"]
 
 [[package]]
 name = "pydantic-core"
@@ -2368,7 +2414,7 @@ files = [
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", 
"brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -2392,7 +2438,7 @@ platformdirs = ">=3.9.1,<5"
 
 [package.extras]
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", 
"sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier 
(>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", 
"coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", 
"pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; 
platform_python_implementation == \"PyPy\" or platform_python_implementation == 
\"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", 
"pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", 
"setuptools (>=68)", "time-machine (>=2.10) ; platform_pyth [...]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", 
"coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", 
"pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", 
"pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", 
"setuptools (>=68)", "time-machine (>=2.10)"]
 
 [[package]]
 name = "watchfiles"
@@ -2623,4 +2669,4 @@ propcache = ">=0.2.0"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"4299d87c5aabfe30a4a23876a3e58bf06891aaeecebaedecdcc9d970b94fdbf8"
+content-hash = 
"a79eacf4cc9156bb1395aeda139734f51b9de6b6efc2f64587197bbb80f882a8"
diff --git a/pyproject.toml b/pyproject.toml
index 1db2632..250b63f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,7 @@ authors = [
 license = "Apache-2.0"
 readme = "README.md"
 requires-python = "~=3.13"
+# https://bugs.launchpad.net/dkimpy/+bug/2024461
 dependencies = [
   "aiofiles>=24.1.0,<25.0.0",
   "aiosqlite>=0.21.0,<0.22.0",
@@ -23,6 +24,9 @@ dependencies = [
   "python-gnupg~=0.5",
   "quart-schema~=0.21",
   "sqlmodel~=0.0",
+  "dnspython>=2.7.0,<3.0.0",
+  # "dkimpy>=1.1.8,<2.0.0",  # Using GitHub fork instead
+  "dkimpy @ git+https://github.com/sbp/dkimpy.git@main";,
 ]
 
 [dependency-groups]
@@ -53,6 +57,9 @@ package-mode = false
 [tool.poetry.dependencies]
 asfquart = { path = "./asfquart", develop = true }
 python = "~=3.13"
+# dnspython = ">=2.7.0,<3.0.0"
+# dkimpy = { version = ">=1.1.8,<2.0.0", extras = ["dnspython"] }
+# dkimpy = { git = 
"https://github.com/sbp/dkimpy.git#subdirectory=src/dkimpy";, branch = "main" }
 
 [tool.poetry.group.test.dependencies]
 pytest = ">=8.0"
@@ -70,6 +77,7 @@ types-aiofiles = ">=24.1.0.20241221,<25.0.0.0"
 
 [tool.uv.sources]
 asfquart = { path = "./asfquart", editable = true }
+# dkimpy = { git = "https://github.com/sbp/dkimpy.git";, branch = "main" }
 
 # Additional tools
 
diff --git a/uv.lock b/uv.lock
index fb8b667..c5d47e8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,11 +12,11 @@ wheels = [
 
 [[package]]
 name = "aiohappyeyeballs"
-version = "2.4.6"
+version = "2.4.8"
 source = { registry = "https://pypi.org/simple"; }
-sdist = { url = 
"https://files.pythonhosted.org/packages/08/07/508f9ebba367fc3370162e53a3cfd12f5652ad79f0e0bfdf9f9847c6f159/aiohappyeyeballs-2.4.6.tar.gz";,
 hash = 
"sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0", size 
= 21726 }
+sdist = { url = 
"https://files.pythonhosted.org/packages/de/7c/79a15272e88d2563c9d63599fa59f05778975f35b255bf8f90c8b12b4ada/aiohappyeyeballs-2.4.8.tar.gz";,
 hash = 
"sha256:19728772cb12263077982d2f55453babd8bec6a052a926cd5c0c42796da8bf62", size 
= 22337 }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/44/4c/03fb05f56551828ec67ceb3665e5dc51638042d204983a03b0a1541475b6/aiohappyeyeballs-2.4.6-py3-none-any.whl";,
 hash = 
"sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", size 
= 14543 },
+    { url = 
"https://files.pythonhosted.org/packages/52/0e/b187e2bb3eeb2644515109657c4474d65a84e7123de249bf1e8467d04a65/aiohappyeyeballs-2.4.8-py3-none-any.whl";,
 hash = 
"sha256:6cac4f5dd6e34a9644e69cf9021ef679e4394f54e58a183056d12009e42ea9e3", size 
= 15005 },
 ]
 
 [[package]]
@@ -78,16 +78,16 @@ wheels = [
 
 [[package]]
 name = "alembic"
-version = "1.14.1"
+version = "1.15.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
     { name = "mako" },
     { name = "sqlalchemy" },
     { name = "typing-extensions" },
 ]
-sdist = { url = 
"https://files.pythonhosted.org/packages/99/09/f844822e4e847a3f0bd41797f93c4674cd4d2462a3f6c459aa528cdf786e/alembic-1.14.1.tar.gz";,
 hash = 
"sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213", size 
= 1918219 }
+sdist = { url = 
"https://files.pythonhosted.org/packages/4a/ed/901044acb892caa5604bf818d2da9ab0df94ef606c6059fdf367894ebf60/alembic-1.15.1.tar.gz";,
 hash = 
"sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49", size 
= 1924789 }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/54/7e/ac0991d1745f7d755fc1cd381b3990a45b404b4d008fc75e2a983516fbfe/alembic-1.14.1-py3-none-any.whl";,
 hash = 
"sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", size 
= 233565 },
+    { url = 
"https://files.pythonhosted.org/packages/99/f7/d398fae160568472ddce0b3fde9c4581afc593019a6adc91006a66406991/alembic-1.15.1-py3-none-any.whl";,
 hash = 
"sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe", size 
= 231753 },
 ]
 
 [[package]]
@@ -259,7 +259,7 @@ name = "click"
 version = "8.1.8"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "colorama", marker = "platform_system == 'Windows'" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz";,
 hash = 
"sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size 
= 226593 }
 wheels = [
@@ -357,6 +357,23 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl";,
 hash = 
"sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size 
= 52290 },
 ]
 
+[[package]]
+name = "dkimpy"
+version = "1.1.9+sbp1"
+source = { git = 
"https://github.com/sbp/dkimpy.git?rev=main#7ed6f94a94719a804bc8351782dbdb77d75b029f";
 }
+dependencies = [
+    { name = "dnspython" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz";,
 hash = 
"sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size 
= 345197 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl";,
 hash = 
"sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size 
= 313632 },
+]
+
 [[package]]
 name = "dunamai"
 version = "1.23.0"
@@ -1119,6 +1136,8 @@ dependencies = [
     { name = "asfquart" },
     { name = "blockbuster" },
     { name = "cryptography" },
+    { name = "dkimpy" },
+    { name = "dnspython" },
     { name = "dunamai" },
     { name = "greenlet" },
     { name = "httpx" },
@@ -1151,6 +1170,8 @@ requires-dist = [
     { name = "asfquart", editable = "asfquart" },
     { name = "blockbuster", specifier = ">=1.5.23,<2.0.0" },
     { name = "cryptography", specifier = "~=44.0" },
+    { name = "dkimpy", git = "https://github.com/sbp/dkimpy.git?rev=main"; },
+    { name = "dnspython", specifier = ">=2.7.0,<3.0.0" },
     { name = "dunamai", specifier = ">=1.23.0" },
     { name = "greenlet", specifier = ">=3.1.1,<4.0.0" },
     { name = "httpx", specifier = "~=0.27" },
@@ -1180,7 +1201,7 @@ name = "tqdm"
 version = "4.67.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "colorama", marker = "platform_system == 'Windows'" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz";,
 hash = 
"sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size 
= 169737 }
 wheels = [


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

Reply via email to