diff --git a/pyproject.toml b/pyproject.toml index a7c4a72b..2c6a7f16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "flask==3.0.3", "gcld3>=3.0.13", "google-cloud-pubsub>=2.29.0", + "google-cloud-secret-manager>=2.20.0", "greenlet==3.1.0", "gunicorn>=23.0.0", "h11==0.14.0", @@ -127,6 +128,7 @@ dev = [ "docopt==0.6.2", "epc>=0.0.5", "google-cloud-pubsub>=2.29.0", + "google-cloud-secret-manager>=2.20.0", "hypothesis>=6.131.21", "hypothesis-jsonschema>=0.23.1", "idna==3.10", diff --git a/submit_ce/api/email_service.py b/submit_ce/api/email_service.py new file mode 100644 index 00000000..3af5fce1 --- /dev/null +++ b/submit_ce/api/email_service.py @@ -0,0 +1,54 @@ +"""API for service to send email.""" +from abc import ABCMeta, abstractmethod + +class EmailService(metaclass=ABCMeta): + @abstractmethod + def send_email(self, + to: list[str], + subject: str, + body: str, + reply_to: str, + cc: list[str] | None = None, + bcc: list[str] | None = None, + message_id: str="", + references: str="") -> tuple[str, str]: + """Send an email. + + Parameters + ---------- + to : list[str] + Recipient email addresses. + subject : str + Subject line of the message. + body : str + Body content of the message. + reply_to : str + Address that replies should be directed to. + cc : list[str], optional + Addresses to copy on the message. Pass ``None`` or an empty list + for no CCs. + bcc : list[str], optional + Addresses to blind-copy on the message. Pass ``None`` or an empty + list for no BCCs. + message_id : str + Unique identifier for this message (the ``Message-ID`` header), + used for threading. + references : str + ``References`` header linking this message to prior messages in + a thread. + + Returns + ------- + tuple[str, str] + ``(message_id, error)``. ``error`` is an empty string on success, + or a human-readable description if sending failed or some + recipients were refused. + """ + pass + + + + @abstractmethod + def is_available(self) -> bool: + """Determine whether the service is available.""" + pass diff --git a/submit_ce/api/submit.py b/submit_ce/api/submit.py index cda8af5d..565874ec 100644 --- a/submit_ce/api/submit.py +++ b/submit_ce/api/submit.py @@ -64,7 +64,9 @@ from typing import Tuple, List, Optional from submit_ce.api.compile_service import CompileService +from submit_ce.api.email_service import EmailService from submit_ce.domain import Submission, Event, License +from submit_ce.domain.config import SubmitConfig from submit_ce.domain.size_limits import SIZE_LIMIT_POLICY, SizeLimits from submit_ce.api.file_store import SubmissionFileStore @@ -79,6 +81,14 @@ def get_size_limits(self) -> SizeLimits: honor the configured ``MAX_*_KB`` values.""" return SIZE_LIMIT_POLICY + def get_config(self) -> SubmitConfig: + """Domain-relevant configuration values (e.g. for composing email). + + Defaults to the built-in `SubmitConfig` defaults. Implementations with + access to application config (e.g. the Flask implementation) override + this to honor the configured values.""" + return SubmitConfig() + @abstractmethod def get(self, submission_id: str) -> Submission: """ @@ -176,6 +186,22 @@ def get_compiler(self) -> CompileService: """ ... + @abstractmethod + def get_email_service(self) -> EmailService: + """ + Gets an `EmailService` implementation object. + + Used by `Events` with side effects to send email. + + Intended to allow code with access to the SubmitApi to also have + access to the email service. + + Returns + ------- + `EmailService` + """ + ... + # Ex what happens on a Command like CompileSource What about longer # commands? or things like compile source that may have a later result? In # legacy compile source is just synchronous. diff --git a/submit_ce/domain/config.py b/submit_ce/domain/config.py new file mode 100644 index 00000000..3431da88 --- /dev/null +++ b/submit_ce/domain/config.py @@ -0,0 +1,57 @@ +"""Domain-facing configuration for the submission API. + +`SubmitConfig` is a small, immutable bundle of the config values the *domain* +layer needs (for example, when an event composes an email). It deliberately +exposes only domain-relevant values, not the full application `Settings` (which +also carries implementation details like DB URIs and SMTP credentials). An +implementation builds one from its own settings and returns it via +`SubmitApi.get_config()`. +""" +from typing import Union + +from pydantic import BaseModel, ConfigDict +from pydantic_settings import BaseSettings + + +class SubmitConfig(BaseModel): + """Domain-relevant configuration values, supplied by the `SubmitApi`.""" + + model_config = ConfigDict(frozen=True) + + email_reply_to: str = "www-admin@arxiv.org" + """``Reply-To`` address for normal submission confirmation emails.""" + + email_auto_hold_reply_to: str = "mod-lib@arxiv.org" + """``Reply-To`` address for auto-hold confirmation emails; replies go to + the moderation team rather than the general admin address.""" + + url_for_user_dashboard: str = "https://arxiv.org/user/" + """Absolute URL of the user submission dashboard.""" + + @classmethod + def from_config(cls, config: Union[dict, BaseSettings]) -> "SubmitConfig": + """Build a `SubmitConfig` from a dict or a `Settings`. + Only the domain-relevant keys are read. + """ + if not isinstance(config, dict): + config = config.model_dump() + + defaults = cls() + email_reply_to = config.get("EMAIL_REPLY_TO", defaults.email_reply_to) + email_auto_hold_reply_to = config.get( + "EMAIL_AUTO_HOLD_REPLY_TO", defaults.email_auto_hold_reply_to) + + dashboard = config.get("url_for_user_dashboard") + if dashboard is None: + base_server = config.get("BASE_SERVER") + if base_server: + host = base_server.rstrip("/") + if not host.startswith(("http://", "https://")): + host = f"https://{host}" + dashboard = f"{host}/user/" + else: + dashboard = defaults.url_for_user_dashboard + + return cls(email_reply_to=email_reply_to, + email_auto_hold_reply_to=email_auto_hold_reply_to, + url_for_user_dashboard=dashboard) diff --git a/submit_ce/domain/event/__init__.py b/submit_ce/domain/event/__init__.py index 3444d5fb..fde160e1 100644 --- a/submit_ce/domain/event/__init__.py +++ b/submit_ce/domain/event/__init__.py @@ -62,6 +62,7 @@ from . import validators from .base import Event from .base import event_factory as make_event +from .email import EmailSubmitterFinalizeMsg from .file import UploadFiles, RemoveFiles, RemoveAllFiles from .flag import AddMetadataFlag, AddUserFlag, AddContentFlag, RemoveFlag, \ AddHold, RemoveHold @@ -978,7 +979,7 @@ class FinalizeSubmission(Event): ] REQUIRED_METADATA: ClassVar[str] = ['title', 'abstract', 'authors_display'] - CONSEQUENCE_TYPES = frozenset({AddHold}) + CONSEQUENCE_TYPES = frozenset({AddHold, EmailSubmitterFinalizeMsg}) def validate_pre_lock(self, submission: Submission) -> None: """Ensure that all required data/steps are complete.""" @@ -995,19 +996,27 @@ def project(self, submission: Submission) -> Submission: return submission def consequences(self, submission: Submission) -> List[Event]: - """Place an oversize submission on hold when it is finalized. + """Follow-on events when a submission is finalized. - Recording a `SOURCE_OVERSIZE` hold (while status stays `SUBMITTED`) is - what makes :attr:`Submission.is_on_hold` report true; there is no - separate hold status in this model. Skipped if a waiver already exists. + 1. Place an oversize submission on hold. Recording a `SOURCE_OVERSIZE` + hold (while status stays `SUBMITTED`) is what makes + :attr:`Submission.is_on_hold` report true; there is no separate hold + status in this model. Skipped if a waiver already exists. + 2. Send the submitter the on-submit confirmation email. """ + events: List[Event] = [] if submission.is_oversize \ and not submission.has_waiver_for(Hold.Type.SOURCE_OVERSIZE): - return [AddHold(creator=System(name=__name__), - submission_id=submission.submission_id, - hold_type=Hold.Type.SOURCE_OVERSIZE, - hold_reason="source is oversize")] - return [] + events.append(AddHold(creator=System(name=__name__), + submission_id=submission.submission_id, + hold_type=Hold.Type.SOURCE_OVERSIZE, + hold_reason="source is oversize")) + sid = submission.submission_id + events.append(EmailSubmitterFinalizeMsg( + creator=System(name=__name__), + email_to=self.creator, + submission_id=str(sid) if sid is not None else None)) + return events def _required_fields_are_complete(self, submission: Submission) -> None: """Verify that all required fields are complete.""" diff --git a/submit_ce/domain/event/email.py b/submit_ce/domain/event/email.py new file mode 100644 index 00000000..d3743405 --- /dev/null +++ b/submit_ce/domain/event/email.py @@ -0,0 +1,427 @@ +"""Events that send email as a side effect. + +TODO ``Re:`` resubmit threading +""" +from typing import Optional, TYPE_CHECKING +from urllib.parse import urlparse + +from ..agent import ServiceAgent, System, User +from ..config import SubmitConfig +from ..submission import Submission, SubmissionType +from .base import EventWithSideEffect + +if TYPE_CHECKING: + from submit_ce.api.submit import SubmitApi + +import logging +logger = logging.getLogger(__name__) + + +# Body template for the "new submission" confirmation, from the legacy +# OnSubmit `new` template (submit_email_feature_description.md, "New submission +# body"). The dashboard URL comes from SubmitConfig. +# +# TODO: parameterize the "20:00 ET" schedule text via config + +_NEW_SUBMISSION_BODY = """\ +Dear {name}, + +Thank you for submitting your work to arXiv. + +Your submission has been received and is under consideration. The temporary submission number is: +{submission_id}. + +As with all submissions, this work will go through technical and moderation checks. You will be contacted by arXiv when the work is announced or if any issues are identified. + +Our goal is to screen and announce papers as quickly as possible while ensuring that papers meet long-term archival standards. Generally, this process takes two business days, with announcements occurring at 20:00 ET, Sunday through Thursday. + +You can make changes and view the current status of the submission from your user dashboard: {dashboard_url} + +Below is a copy of the submission information. + +Regards, +arXiv Support + + +{summary} +""" + +# TODO: replace placeholder bodies below with real per-type content + + +def _render_auto_hold_body( + submission: Submission, + submission_id: str, + submit_url: str, + this_site: str, + www_admin: str, + summary: str, +) -> str: + """Render the auto_hold email body. + + Faithfully translated from ``$tmpl_new_user_auto_hold`` in + ``arxiv-lib/lib/arXiv/Submit/Email/OnSubmit.pm`` (lines 298-349). + Conditions are evaluated per flag; only ``is_oversize`` is wired now — + the remaining four (``multiple``, ``linenos``, ``text_extraction_failure``, + ``missing_pdf``) will be added when those Submission fields exist. + """ + # -- Summary block ------------------------------------------------------- + summary_lines: list[str] = [] + if submission.is_oversize: + summary_lines.append(" Oversize submission") + # TODO: append for multiple, linenos, text_extraction_failure, missing_pdf + + # -- Detail block -------------------------------------------------------- + detail_parts: list[str] = [] + if submission.is_oversize: + detail_parts.append( + f"Oversize submission: Your article is currently in \"on-hold\" status" + f" because it is over our size limits. It will not be announced without" + f" action from arXiv administrators either after you correct the" + f" over-size issue, or if you are given permission because there is a" + f" good reason for why your paper should be announced as-is (e.g. the" + f" source of your paper is efficient already, or you have large ancillary" + f" files). Please see:\n" + f"\n" + f" https://{this_site}/help/sizes\n" + f"\n" + f"for a discussion related to arXiv's file size warnings.\n" + f"\n" + f"A common problem is large and inefficient postscript files in LaTeX" + f" submissions. The simplest method to correct this issue is to convert" + f" any postscript figures into pdf and convert your submission to use" + f" pdflatex. See:\n" + f"\n" + f" https://{this_site}/help/submit_tex#pdflatex\n" + f"\n" + f"for a brief discussion regarding the considerations for using pdflatex." + f" You may also wish to consider bitmapping complex figures. For a more" + f" complete discussion see:\n" + f"\n" + f" https://{this_site}/bitmap/index" + ) + # TODO: append detail paragraphs for multiple, linenos, text_extraction_failure, + # missing_pdf when those Submission fields exist. + + conditions_summary = "\n".join(summary_lines) + conditions_detail = "\n\n".join(detail_parts) + + return ( + f"Your submission to arXiv is on hold.\n" + f"\n" + f"Your temporary submission identifier is: {submission_id}.\n" + f"You may update your submission at: {submit_url}\n" + f"\n" + f"Your article is currently in \"on-hold\" status because of the conditions" + f" listed below. Once you have corrected these conditions, please update your" + f" source files, reprocess/view your submission and submit your article again." + f" This sequence should automatically move your submission to \"submitted\"" + f" status.\n" + f"\n" + f"Summary:\n" + f"\n" + f"{conditions_summary}\n" + f"\n" + f"Additional details on each of these conditions are included below:\n" + f"\n" + f"{conditions_detail}\n" + f"\n" + f"You may resubmit your paper once you have addressed all the issues listed" + f" above. If you are not able to resolve these matters, or feel you are" + f" receiving this warning in error, please contact {www_admin}, quoting" + f" submission identifier {submission_id}, to request additional assistance.\n" + f"\n" + f"arXiv admin\n" + f"\n" + f"\n" + f"{summary}\n" + ) + +_REP_SUBMISSION_BODY = """\ +Dear {name}, + +TODO: replacement confirmation email body for arXiv replacement {submission_id} of {arxiv_id}. + +Regards, +arXiv Support + + +{summary} +""" + +_WDR_SUBMISSION_BODY = """\ +Dear {name}, + +TODO: withdrawal confirmation email body for arXiv withdrawal of {arxiv_id} (submission {submission_id}). + +Regards, +arXiv Support + + +{summary} +""" + +_CROSS_SUBMISSION_BODY = """\ +Dear {name}, + +TODO: cross-list confirmation email body for arXiv cross to {new_categories} for {arxiv_id} (submission {submission_id}). + +Regards, +arXiv Support + + +{summary} +""" + +_JREF_SUBMISSION_BODY = """\ +Dear {name}, + +TODO: journal-ref confirmation email body for arXiv journal ref for {arxiv_id} (submission {submission_id}). + +Regards, +arXiv Support + + +{summary} +""" + + +def render_submission_summary(submission: Submission) -> str: + """Render the plaintext "copy of the submission information" block. + + A ``write_to_string`` equivalent: an arXiv abs-style summary of the + submission -- title / authors / categories, then the optional + cross-reference fields, then the abstract. Empty fields are omitted. Text is + included as entered (TeX is not converted to unicode). The ``\\\\`` lines + are the conventional arXiv abs delimiters around the abstract. + """ + md = submission.metadata + + categories = [] + if submission.primary_classification: + categories.append(submission.primary_classification.category) + categories.extend(submission.secondary_categories) + + lines = [ + f"Title: {md.title or ''}", + f"Authors: {md.authors_display or ''}", + ] + if categories: + lines.append(f"Categories: {' '.join(categories)}") + for label, value in ( + ("Comments", md.comments), + ("Report-no", md.report_num), + ("MSC-class", md.msc_class), + ("ACM-class", md.acm_class), + ("Journal-ref", md.journal_ref), + ("DOI", md.doi), + ): + if value: + lines.append(f"{label}: {value}") + + abstract = (md.abstract or "").strip() + return "{}\n\\\\\n{}\n\\\\".format("\n".join(lines), abstract) + + +def _is_auto_hold(submission: Submission) -> bool: + """Return True when the email type should be overridden to ``auto_hold``. + + Currently only ``is_oversize`` is wired; the other four legacy conditions + (``multiple``, ``linenos``, ``text_extraction_failure``, ``missing_pdf``) + don't exist as Submission fields yet and will be added when those + content-check pipelines are implemented. + """ + return submission.is_oversize + + +def _build_subject_and_body( + submission: Submission, + to_name: str, + config: SubmitConfig, +) -> tuple[str, str]: + """Return ``(subject, body)`` for the finalize confirmation email. + + Checks for the auto_hold override first (``is_oversize``), then dispatches + on :attr:`.Submission.submission_type`. Non-``new`` types send a placeholder + body until full templates are implemented. + """ + sid = submission.submission_id + arxiv_id = submission.arxiv_id or "" + summary = render_submission_summary(submission) + + if _is_auto_hold(submission): + this_site = urlparse(config.url_for_user_dashboard).hostname or "arxiv.org" + submit_url = f"https://{this_site}/submit/{sid}" + subject = f"arXiv submission {sid}: On Hold" + body = _render_auto_hold_body( + submission=submission, + submission_id=sid, + submit_url=submit_url, + this_site=this_site, + www_admin=config.email_reply_to, + summary=summary, + ) + return subject, body + + sub_type = submission.submission_type or SubmissionType.NEW + + if sub_type == SubmissionType.NEW: + subject = f"arXiv submission {sid}" + body = _NEW_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + dashboard_url=config.url_for_user_dashboard, + summary=summary, + ) + elif sub_type == SubmissionType.REPLACEMENT: + subject = f"arXiv replacement {sid} for {arxiv_id}" + body = _REP_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + arxiv_id=arxiv_id, + summary=summary, + ) + elif sub_type == SubmissionType.WITHDRAWAL: + subject = f"arXiv withdrawal of {arxiv_id}" + body = _WDR_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + arxiv_id=arxiv_id, + summary=summary, + ) + elif sub_type == SubmissionType.CROSS_LIST: + categories = [] + if submission.primary_classification: + categories.append(submission.primary_classification.category) + categories.extend(submission.secondary_categories) + new_categories = " ".join(categories) + subject = f"arXiv cross to {new_categories} for {arxiv_id}" + body = _CROSS_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + arxiv_id=arxiv_id, + new_categories=new_categories, + summary=summary, + ) + elif sub_type == SubmissionType.JOURNAL_REFERENCE: + subject = f"arXiv journal ref for {arxiv_id}" + body = _JREF_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + arxiv_id=arxiv_id, + summary=summary, + ) + else: + subject = f"arXiv submission {sid}" + body = _NEW_SUBMISSION_BODY.format( + name=to_name, + submission_id=sid, + dashboard_url=config.url_for_user_dashboard, + summary=summary, + ) + + return subject, body + + +def submitter_recipient(user: User) -> tuple[str, str]: + """Resolve the ``(name, email)`` the confirmation email is sent to. + + ``user`` is the agent the email is addressed to -- in practice the + ``creator`` of the :class:`.EmailSubmitterFinalizeMsg` event, which is the + user that performed the ``FinalizeSubmission``. So the person doing the + Finalize is the one who gets the email. + + If the normal submitter performs Finalize they will get the email. + + If an admin performs Finalize they will get the email. + + If CCSD performs Finalize CCSD will get the email eventhough they put a + proxy on the submission. + """ + match user: + case System(): + return "", "" + case ServiceAgent(): + return user.email, user.email + case _: + return user.name, user.email + + +class EmailSubmitterFinalizeMsg(EventWithSideEffect): + """Send the submitter the on-submit confirmation email. + + Emitted as a consequence of :class:`.FinalizeSubmission`. Sending is + non-fatal: any failure is recorded on :attr:`error` and never raised, so it + cannot abort the submit transaction (matching legacy, which wraps the email + send in an ``eval``). + """ + + NAME = "email submitter on finalize" + NAMED = "submitter finalize email sent" + + email_to: User + """User who should get the email. Should be the creator of the `FinalizeSubmission` event.""" + + error: Optional[str] = None + """Set if the email could not be sent; the submit still succeeds.""" + + msg_id: Optional[str] = None + """Message id""" + + def validate_pre_lock(self, submission: Submission) -> None: + """No precondition; this is a consequence of a validated finalize.""" + pass + + def execute(self, api: 'SubmitApi', submission: Submission) -> None: + """Compose and send the confirmation email. Never raises.""" + try: + if isinstance(self.email_to, System): + # System actor: there is no one to email. Intentionally skip; + logger.info("Submission %s: System actor, no confirmation " + "email sent", submission.submission_id) + return + + to_name, to_email = submitter_recipient(self.email_to) + if not to_email: + # A real user with no email address; record it (not fatal). + name = getattr(self.email_to, "name", "") + self.error = (f"No email for user of type {type(self.email_to)} " + f"name '{name}'") + logger.warning("Submission %s: %s", submission.submission_id, + self.error) + return + + service = api.get_email_service() + if service is None or not service.is_available(): + self.error = "email not configured or unavailable, no confirmation sent" + logger.warning("Submission %s: %s", submission.submission_id, + self.error) + return + + config = api.get_config() + subject, body = _build_subject_and_body( + submission=submission, + to_name=to_name, + config=config, + ) + reply_to = ( + config.email_auto_hold_reply_to + if _is_auto_hold(submission) + else config.email_reply_to + ) + msg_id, problems = service.send_email( + to=[to_email], + subject=subject, + body=body, + reply_to=reply_to, + ) + self.msg_id = msg_id + self.error = problems or None + except Exception as e: # noqa: BLE001 - email send must never abort submit + self.error = f"failed to send confirmation email: {e}" + logger.warning("Submission %s: %s", submission.submission_id, + self.error) + + def project(self, submission: Submission) -> Submission: + """No state change; this event only sends mail.""" + return submission diff --git a/submit_ce/domain/event/tests/test_email_submitter_finalize.py b/submit_ce/domain/event/tests/test_email_submitter_finalize.py new file mode 100644 index 00000000..c597a4c3 --- /dev/null +++ b/submit_ce/domain/event/tests/test_email_submitter_finalize.py @@ -0,0 +1,269 @@ +"""Unit tests for `EmailSubmitterFinalizeMsg`. + +These exercise the event's `execute` side effect directly with hand-built +submissions and stub APIs, so no app, DB, or file store is needed. Sending must +be non-fatal: a failure is recorded on `event.error`, never raised. +""" +from datetime import datetime + +from pytz import UTC + +from submit_ce.domain import agent +from submit_ce.domain.meta import Classification +from submit_ce.domain.config import SubmitConfig +from submit_ce.domain.event import EmailSubmitterFinalizeMsg +from submit_ce.domain.event.email import ( + _is_auto_hold, + render_submission_summary, + submitter_recipient, +) +from submit_ce.domain.submission import Submission, SubmissionMetadata +from submit_ce.implementations.email.email_in_memory import EmailInMemory + + +def _user(): + return agent.PublicUser(name="Test User", user_id="u1", + email="submitter@example.org", endorsements=[]) + + +def _submission(): + u = _user() + return Submission( + submission_id="12345", + creator=u, owner=u, created=datetime.now(UTC), + primary_classification=Classification(category="astro-ph.GA"), + metadata=SubmissionMetadata(title="A Fine Paper")) + + +def _event(): + return EmailSubmitterFinalizeMsg( + creator=agent.System(name="test"), + email_to=_user(), + submission_id="12345", + created=datetime.now(UTC)) + + +class _Api: + """Minimal stub exposing get_email_service and get_config.""" + def __init__(self, service, config=None): + self._service = service + self._config = config or SubmitConfig( + email_reply_to="replyhere@arxiv.org", + url_for_user_dashboard="https://example.test/user/") + + def get_email_service(self): + return self._service + + def get_config(self): + return self._config + + +class _RaisingService(EmailInMemory): + def send_email(self, *args, **kwargs): + raise RuntimeError("smtp boom") + + +def test_execute_sends_confirmation_email(): + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _submission()) + + assert event.error is None + assert len(service.sent) == 1 + sent = service.last + assert sent.to == ["submitter@example.org"] + assert sent.subject == "arXiv submission 12345" + # Reply-To and dashboard URL come from api.get_config(). + assert sent.reply_to == "replyhere@arxiv.org" + # Body interpolates the submitter name, temp id, title, and dashboard URL. + assert "Dear Test User," in sent.body + assert "12345" in sent.body + assert "A Fine Paper" in sent.body + assert "https://example.test/user/" in sent.body + + +def test_execute_with_no_service_records_error_and_does_not_raise(): + event = _event() + event.execute(_Api(None), _submission()) + assert event.error is not None + assert "not configured" in event.error + + +def test_execute_with_unavailable_service_does_not_send(): + class _Unavailable(EmailInMemory): + def is_available(self): + return False + + service = _Unavailable() + event = _event() + event.execute(_Api(service), _submission()) + assert len(service.sent) == 0 + assert event.error is not None + + +def test_execute_swallows_send_failure(): + """A raising email service must not propagate; error is recorded.""" + event = _event() + event.execute(_Api(_RaisingService()), _submission()) + assert event.error is not None + assert "smtp boom" in event.error + + +def test_execute_system_recipient_skips_send_without_error(): + """A System actor has no email address: skip the send, but it's not an error.""" + service = EmailInMemory() + event = EmailSubmitterFinalizeMsg( + creator=agent.System(name="sys"), + email_to=agent.System(name="sys"), + submission_id="12345", + created=datetime.now(UTC)) + event.execute(_Api(service), _submission()) + assert len(service.sent) == 0 + assert event.error is None + + +# --- abstract block (render_submission_summary) --- + +def _full_submission(): + u = _user() + sub = Submission( + submission_id="12345", + creator=u, owner=u, created=datetime.now(UTC), + primary_classification=Classification(category="astro-ph.GA"), + secondary_classification=[Classification(category="astro-ph.CO"), + Classification(category="gr-qc")], + metadata=SubmissionMetadata( + title="A Fine Paper", + authors_display="A. Author and B. Coauthor", + abstract="We show a fine result.", + comments="12 pages, 3 figures", + report_num="REP-2026-1", + msc_class="83C99", + acm_class="F.2.2", + journal_ref="J. Fine Res. 1 (2026) 1", + doi="10.1000/xyz")) + return sub + + +def test_render_submission_summary_full(): + summary = render_submission_summary(_full_submission()) + assert "Title: A Fine Paper" in summary + assert "Authors: A. Author and B. Coauthor" in summary + # primary first, then secondaries, space-joined. + assert "Categories: astro-ph.GA astro-ph.CO gr-qc" in summary + assert "Comments: 12 pages, 3 figures" in summary + assert "Report-no: REP-2026-1" in summary + assert "MSC-class: 83C99" in summary + assert "ACM-class: F.2.2" in summary + assert "Journal-ref: J. Fine Res. 1 (2026) 1" in summary + assert "DOI: 10.1000/xyz" in summary + # abstract sits between the arXiv abs delimiters. + assert "\\\\\nWe show a fine result.\n\\\\" in summary + + +def test_render_submission_summary_omits_empty_optional_fields(): + """Only title/authors (and categories if any) are always present.""" + summary = render_submission_summary(_submission()) # minimal metadata + assert "Title: A Fine Paper" in summary + assert "Authors: " in summary + assert "Categories: astro-ph.GA" in summary + for label in ("Comments:", "Report-no:", "MSC-class:", "ACM-class:", + "Journal-ref:", "DOI:"): + assert label not in summary + + +def test_render_submission_summary_in_email_body(): + """The rendered summary is included in the sent email body.""" + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _full_submission()) + body = service.last.body + assert "Title: A Fine Paper" in body + assert "Categories: astro-ph.GA astro-ph.CO gr-qc" in body + assert "We show a fine result." in body + + +def test_project_is_noop(): + sub = _submission() + assert _event().project(sub) is sub + + +# --- recipient resolution (submitter_recipient) --- + +def test_submitter_recipient_returns_user_name_and_email(): + """The recipient is the given user's name and email (the finalizer).""" + assert submitter_recipient(_user()) == ("Test User", + "submitter@example.org") + + +def test_submitter_recipient_staff_user(): + staff = agent.StaffUser(user_id="999", email="mod@arxiv.org", + name="Mod Erator", username="moderator") + assert submitter_recipient(staff) == ("Mod Erator", "mod@arxiv.org") + + +# --- auto_hold override --- + +def _oversize_submission(): + sub = _submission() + sub.is_oversize = True + return sub + + +def test_is_auto_hold_true_for_oversize(): + assert _is_auto_hold(_oversize_submission()) is True + + +def test_is_auto_hold_false_for_normal(): + assert _is_auto_hold(_submission()) is False + + +def test_auto_hold_subject_contains_on_hold(): + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _oversize_submission()) + + assert event.error is None + sent = service.last + assert "On Hold" in sent.subject + assert sent.subject == "arXiv submission 12345: On Hold" + + +def test_auto_hold_reply_to_is_mod_lib(): + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _oversize_submission()) + + assert service.last.reply_to == SubmitConfig().email_auto_hold_reply_to + + +def test_normal_submission_reply_to_is_from_config(): + """Non-oversize submissions use the configured reply-to, not mod-lib.""" + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _submission()) + + assert service.last.reply_to == "replyhere@arxiv.org" + + +def test_auto_hold_body_content(): + """Auto-hold body mirrors the legacy $tmpl_new_user_auto_hold template.""" + service = EmailInMemory() + event = _event() + event.execute(_Api(service), _oversize_submission()) + + body = service.last.body + # Top-level framing (no "Dear" salutation, matching legacy) + assert body.startswith("Your submission to arXiv is on hold.") + assert "12345" in body + # Submit URL derived from dashboard URL + assert "https://example.test/submit/12345" in body + # Oversize summary and detail + assert "Oversize submission" in body + assert "example.test/help/sizes" in body + assert "example.test/help/submit_tex#pdflatex" in body + assert "example.test/bitmap/index" in body + # www_admin contact address from config + assert "replyhere@arxiv.org" in body + # Abstract block at the bottom + assert "A Fine Paper" in body diff --git a/submit_ce/domain/event/tests/test_finalize_oversize_hold.py b/submit_ce/domain/event/tests/test_finalize_oversize_hold.py index 34382685..94b2dc00 100644 --- a/submit_ce/domain/event/tests/test_finalize_oversize_hold.py +++ b/submit_ce/domain/event/tests/test_finalize_oversize_hold.py @@ -10,7 +10,8 @@ from submit_ce.domain import agent from submit_ce.domain.meta import Classification -from submit_ce.domain.event import FinalizeSubmission, AddHold +from submit_ce.domain.event import FinalizeSubmission, AddHold, \ + EmailSubmitterFinalizeMsg from submit_ce.domain.submission import Submission, Hold, Waiver @@ -32,24 +33,36 @@ def _finalize(): return FinalizeSubmission(creator=_user(), created=datetime.now(UTC)) +def _holds(events): + return [e for e in events if isinstance(e, AddHold)] + + def test_oversize_finalize_yields_addhold(): events = _finalize().consequences(_submission(is_oversize=True)) - assert len(events) == 1 - hold = events[0] - assert isinstance(hold, AddHold) - assert hold.hold_type == Hold.Type.SOURCE_OVERSIZE + holds = _holds(events) + assert len(holds) == 1 + assert holds[0].hold_type == Hold.Type.SOURCE_OVERSIZE -def test_not_oversize_finalize_yields_nothing(): - assert _finalize().consequences(_submission(is_oversize=False)) == [] +def test_not_oversize_finalize_yields_no_hold(): + assert _holds(_finalize().consequences(_submission(is_oversize=False))) == [] -def test_oversize_with_waiver_yields_nothing(): +def test_oversize_with_waiver_yields_no_hold(): waiver = Waiver(event_id="w1", created=datetime.now(UTC), creator=_user(), waiver_type=Hold.Type.SOURCE_OVERSIZE, waiver_reason="ok") sub = _submission(is_oversize=True, waivers={"w1": waiver}) - assert _finalize().consequences(sub) == [] + assert _holds(_finalize().consequences(sub)) == [] + + +def test_finalize_always_emails_submitter(): + """Every finalize emits exactly one submitter confirmation email.""" + for is_oversize in (True, False): + events = _finalize().consequences(_submission(is_oversize=is_oversize)) + emails = [e for e in events if isinstance(e, EmailSubmitterFinalizeMsg)] + assert len(emails) == 1 def test_declared_consequence_type(): - assert FinalizeSubmission.CONSEQUENCE_TYPES == frozenset({AddHold}) + assert FinalizeSubmission.CONSEQUENCE_TYPES == frozenset( + {AddHold, EmailSubmitterFinalizeMsg}) diff --git a/submit_ce/implementations/__init__.py b/submit_ce/implementations/__init__.py index 02b727b5..412cbc9e 100644 --- a/submit_ce/implementations/__init__.py +++ b/submit_ce/implementations/__init__.py @@ -6,6 +6,7 @@ from submit_ce.api import SubmitApi, SubmissionFileStore from submit_ce.api.compile_service import CompileService +from submit_ce.api.email_service import EmailService from submit_ce.domain.uploads import SubmitFile from submit_ce.domain.uploads import FileStatus, UploadStatus, UploadLifecycleStates from submit_ce.domain import Event, Submission, License, User, Client, Workspace @@ -14,7 +15,7 @@ from submit_ce.implementations.schedule import next_announcement_time, next_freeze_time -class NullCompilerService(CompileService): +class NullCompilerService(CompileService): # pragma: no cover def start_directives(self, submission: Submission, user: User, client: Client, api: 'SubmitApi', source_package_id: Optional[str] = None) -> Result: @@ -41,7 +42,24 @@ def is_available(self) -> bool: return False -class NullFileStore(SubmissionFileStore): +class NullEmailService(EmailService): # pragma: no cover + + def send_email(self, + to: list[str], + subject: str, + body: str, + reply_to: str, + cc: list[str] | None = None, + bcc: list[str] | None = None, + message_id: str = "", + references: str = "") -> tuple[str, str]: + return ("", "") + + def is_available(self) -> bool: + return False + + +class NullFileStore(SubmissionFileStore): # pragma: no cover def get_workspace(self, submission_id: str) -> Optional[Workspace]: return Workspace( @@ -229,12 +247,15 @@ def is_available(self) -> bool: return False -class NullImplementation(SubmitApi): +class NullImplementation(SubmitApi): # pragma: no cover """Submission that does as little as possible.""" def get_compiler(self) -> CompileService: return NullCompilerService() + def get_email_service(self) -> EmailService: + return NullEmailService() + def get(self, submission_id: str) -> Submission: Submission(submission_id) diff --git a/submit_ce/implementations/email/__init__.py b/submit_ce/implementations/email/__init__.py new file mode 100644 index 00000000..c3fd2f03 --- /dev/null +++ b/submit_ce/implementations/email/__init__.py @@ -0,0 +1,169 @@ +"""Email service implementations. + +`HalonEmailService` is the production `EmailService`. It dispatches mail +through arXiv's Halon SMTP server over an authenticated SMTP-over-SSL +connection, using the standard library ``smtplib``/``email`` modules. +""" +import logging +import smtplib +from email.message import EmailMessage +from email.utils import format_datetime, localtime, make_msgid + +from typing_extensions import override + +from submit_ce.api.email_service import EmailService + +logger = logging.getLogger(__name__) + + +class HalonEmailService(EmailService): + """`EmailService` that sends mail via arXiv's Halon SMTP server. + + Connects to the Halon server over SMTP-over-SSL, authenticates, and + sends a single text-body message. + + Parameters + ---------- + host : str + Hostname of the Halon SMTP server. + user : str + Username for SMTP authentication. + password : str + Password for SMTP authentication. + from_address : str + Default ``From`` address for outgoing mail. + port : int + Port for the SMTP connection. Defaults to 465. + use_starttls : bool + If ``True``, connect with plain SMTP then upgrade via STARTTLS. + If ``False`` (default), connect with SMTP_SSL. + """ + + def __init__(self, + host: str, + user: str, + password: str, + from_address: str, + port: int = 465, + use_starttls: bool = False, + timeout: float = 30.0) -> None: + self.host = host + self.user = user + self.password = password + self.from_address = from_address + self.port = port + self.use_starttls = use_starttls + self.timeout = timeout + + def __repr__(self) -> str: + # Never include the password in a repr. + return (f"{self.__class__.__name__}(" + f"host={self.host!r}," + f"port={self.port!r}," + f"user={self.user!r}," + f"from_address={self.from_address!r}" + ")") + + @override + def send_email(self, + to: list[str], + subject: str, + body: str, + reply_to: str, + cc: list[str] | None = None, + bcc: list[str] | None = None, + message_id: str = "", + references: str = "") -> tuple[str,str]: + """Build and send a plain-text email through the Halon server. + + Parameters match `EmailService.send_email`; see that method for + details. ``Bcc`` recipients are included in the SMTP envelope but + not in the message headers. + """ + cc = cc or [] + bcc = bcc or [] + + msg = EmailMessage() + msg["Date"] = format_datetime(localtime()) + msg["Message-ID"] = message_id or make_msgid() + msg["From"] = self.from_address + msg["To"] = ", ".join(to) + msg["Subject"] = subject + if cc: + msg["Cc"] = ", ".join(cc) + if reply_to: + msg["Reply-To"] = reply_to + if references: + msg["References"] = references + + # cte='8bit' and 8bitmime mail option avoids needless down-coding of the body. + # Recomended in halon docs. + msg.set_content(body, cte="8bit") + mail_options=("8bitmime",) + + # Envelope recipients: every address that should receive the mail, + # including Bcc (which is intentionally absent from the headers). + recipients = list(to) + list(cc) + list(bcc) + + try: + if self.use_starttls: + with smtplib.SMTP(host=self.host, port=self.port, timeout=self.timeout) as sess: + sess.starttls() + sess.login(self.user, self.password) + refused = sess.send_message(msg, + from_addr=self.from_address, + to_addrs=recipients, + mail_options=mail_options) + else: + with smtplib.SMTP_SSL(host=self.host, port=self.port, timeout=self.timeout) as sess: + sess.login(self.user, self.password) + refused = sess.send_message(msg, + from_addr=self.from_address, + to_addrs=recipients, + mail_options=mail_options) + except TimeoutError: + return (msg["Message-ID"], + f"Email timed out after {self.timeout}s" + f" connecting to {self.host}:{self.port}") + except smtplib.SMTPAuthenticationError as e: + return (msg["Message-ID"], + f"SMTP authentication failed for user {self.user!r}:" + f" {e.smtp_error.decode(errors='replace')}") + except smtplib.SMTPConnectError as e: + return (msg["Message-ID"], + f"Could not connect to SMTP server {self.host}:{self.port}:" + f" {e.smtp_error.decode(errors='replace')}") + except smtplib.SMTPRecipientsRefused as e: + parts = ", ".join( + f"{addr} ({code}, {resp.decode(errors='replace')})" + for addr, (code, resp) in e.recipients.items() + ) + return (msg["Message-ID"], f"All recipients refused: {parts}") + except smtplib.SMTPSenderRefused as e: + return (msg["Message-ID"], + f"Sender {self.from_address!r} refused:" + f" {e.smtp_error.decode(errors='replace')}") + + if refused: + parts = ", ".join( + f"{addr} ({code}, {resp.decode(errors='replace')})" + for addr, (code, resp) in refused.items() + ) + logger.warning("send_email %s: some recipients refused: %s", + msg["Message-ID"], parts) + return (msg["Message-ID"], + f"Email sent but some recipients refused: {parts}") + + logger.info("Sent email %s to %d recipient(s)", + msg["Message-ID"], len(recipients)) + return (msg["Message-ID"], "") + + @override + def is_available(self) -> bool: + """Return ``True`` if the service has the config needed to send. + + This checks configuration only; it does not open a connection to + the SMTP server. + """ + return bool(self.host and self.user and self.password + and self.from_address) diff --git a/submit_ce/implementations/email/email_in_memory.py b/submit_ce/implementations/email/email_in_memory.py new file mode 100644 index 00000000..b661d708 --- /dev/null +++ b/submit_ce/implementations/email/email_in_memory.py @@ -0,0 +1,94 @@ +"""In-memory `EmailInMemory` service for tests. + +A drop-in `EmailService` for unit and integration tests that retains sent +messages in process memory instead of dispatching them through a real mail +transport. Tests can send email exactly as production code would, then inspect +`sent` (or use the convenience accessors) to assert on what was sent. +""" +from dataclasses import dataclass, field + +from submit_ce.api.email_service import EmailService + + +@dataclass(frozen=True) +class SentEmail: # pragma: no cover + """A single captured email. + + Mirrors the parameters of `EmailService.send_email`. + """ + + to: list[str] + subject: str + body: str + reply_to: str + cc: list[str] = field(default_factory=list) + bcc: list[str] = field(default_factory=list) + message_id: str = "" + references: str = "" + + +class EmailInMemory(EmailService): # pragma: no cover + """An `EmailService` that captures sent email in memory for tests. + + Each call to `send_email` appends a `SentEmail` to `sent`, in order. The + captured messages can then be examined to make assertions during a test. + + Examples + -------- + >>> service = EmailInMemory() + >>> service.send_email(["a@example.com"], "Hi", "Body", "noreply@arxiv.org") + >>> len(service.sent) + 1 + >>> service.last.subject + 'Hi' + >>> service.clear() + >>> service.sent + [] + """ + + def __init__(self) -> None: + self.sent: list[SentEmail] = [] + + def send_email(self, + to: list[str], + subject: str, + body: str, + reply_to: str, + cc: list[str] | None = None, + bcc: list[str] | None = None, + message_id: str = "", + references: str = "") -> tuple[str, str]: + """Capture an email in memory. + + Parameters match `EmailService.send_email`; see that method for + details. The message is appended to `sent` rather than dispatched. + """ + self.sent.append(SentEmail( + to=list(to), + subject=subject, + body=body, + reply_to=reply_to, + cc=list(cc or []), + bcc=list(bcc or []), + message_id=message_id, + references=references, + )) + return (message_id, "") + + @property + def last(self) -> SentEmail: + """Return the most recently sent email. + + Raises + ------ + IndexError + If no email has been sent. + """ + return self.sent[-1] + + def clear(self) -> None: + """Discard all captured emails.""" + self.sent.clear() + + def is_available(self) -> bool: + return True diff --git a/submit_ce/implementations/email/smtp_creds.py b/submit_ce/implementations/email/smtp_creds.py new file mode 100644 index 00000000..a1e52603 --- /dev/null +++ b/submit_ce/implementations/email/smtp_creds.py @@ -0,0 +1,77 @@ +"""SMTP credential resolution via GCP Secret Manager. + +The secret's value must be an SMTP URI: + + smtps://user:password@host:port (SSL — default for Halon) + smtp+starttls://user:password@host:port (STARTTLS) + smtp://user:password@host:port (plain, no encryption) + +The short name form (e.g. ``"HALON_CREDS"``) requires ``GOOGLE_CLOUD_PROJECT`` +to be set; a full ``projects/.../secrets/.../versions/...`` resource name is +passed straight through. + +``smtp_creds_from_secret`` is cached for the process lifetime — a restart is +needed to pick up secret rotation. +""" +import os +from dataclasses import dataclass +from functools import cache +from urllib.parse import unquote, urlparse + +import logging +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SmtpCreds: + host: str + port: int | None + user: str + password: str + use_ssl: bool + use_starttls: bool + + @classmethod + def parse(cls, url: str) -> "SmtpCreds": + """Parse an SMTP URI into an ``SmtpCreds``.""" + parsed = urlparse(url) + if not parsed.hostname: + raise RuntimeError("SMTP URI secret value must include a hostname") + return cls( + host=parsed.hostname, + port=parsed.port, + user=unquote(parsed.username) if parsed.username else "", + password=unquote(parsed.password) if parsed.password else "", + use_ssl=parsed.scheme == "smtps", + use_starttls=parsed.scheme == "smtp+starttls", + ) + + +def _resolve_secret_resource(name: str) -> str: + """Accept either a full Secret Manager resource name or a short name.""" + if name.startswith("projects/"): + return name + project = os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCP_PROJECT") + if not project: + raise RuntimeError( + "GOOGLE_CLOUD_PROJECT must be set to resolve short secret name " + f"{name!r}, or pass a full projects/.../secrets/.../versions/... resource" + ) + return f"projects/{project}/secrets/{name}/versions/latest" + + +@cache +def smtp_creds_from_secret(secret_name: str) -> SmtpCreds: + """Fetch and parse SMTP credentials from GCP Secret Manager. + + Cached for the process lifetime; a restart is required after secret + rotation. The ``google-cloud-secret-manager`` package is imported here + so it is only required when HALON mode is actually used. + """ + from google.cloud import secretmanager # noqa: PLC0415 + + resource = _resolve_secret_resource(secret_name) + logger.info("Fetching SMTP credentials from secret %r", resource) + client = secretmanager.SecretManagerServiceClient() + response = client.access_secret_version(request={"name": resource}) + return SmtpCreds.parse(response.payload.data.decode("utf-8")) diff --git a/submit_ce/implementations/email/tests/__init__.py b/submit_ce/implementations/email/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/submit_ce/implementations/email/tests/test_halon_email.py b/submit_ce/implementations/email/tests/test_halon_email.py new file mode 100644 index 00000000..a3035feb --- /dev/null +++ b/submit_ce/implementations/email/tests/test_halon_email.py @@ -0,0 +1,224 @@ +"""Tests for `HalonEmailService` and `SmtpCreds`, mocking the SMTP transport.""" +import smtplib +from unittest import mock + +import pytest + +from submit_ce.implementations.email import HalonEmailService +from submit_ce.implementations.email.smtp_creds import SmtpCreds + + +def make_service() -> HalonEmailService: + return HalonEmailService( + host="mail.example.org", + user="arxiv", + password="secret", + from_address="noreply@arxiv.org", + port=465, + ) + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_logs_in_and_sends(smtp_ssl): + sess = smtp_ssl.return_value.__enter__.return_value + service = make_service() + + service.send_email( + to=["a@example.com", "b@example.com"], + subject="Hello", + body="A body\n", + reply_to="reply@arxiv.org", + cc=["c@example.com"], + bcc=["d@example.com"], + message_id="", + references="", + ) + + smtp_ssl.assert_called_once_with(host="mail.example.org", port=465, timeout=30.0) + sess.login.assert_called_once_with("arxiv", "secret") + + assert sess.send_message.call_count == 1 + _, kwargs = sess.send_message.call_args + msg = sess.send_message.call_args.args[0] + + # Envelope includes To + Cc + Bcc recipients. + assert kwargs["from_addr"] == "noreply@arxiv.org" + assert kwargs["to_addrs"] == [ + "a@example.com", "b@example.com", "c@example.com", "d@example.com", + ] + assert kwargs["mail_options"] == ("8bitmime",) + + # Headers. + assert msg["From"] == "noreply@arxiv.org" + assert msg["To"] == "a@example.com, b@example.com" + assert msg["Cc"] == "c@example.com" + assert msg["Subject"] == "Hello" + assert msg["Reply-To"] == "reply@arxiv.org" + assert msg["Message-ID"] == "" + assert msg["References"] == "" + # Bcc is an envelope recipient only, never a header. + assert msg["Bcc"] is None + assert msg.get_content() == "A body\n" + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_generates_message_id_when_absent(smtp_ssl): + service = make_service() + service.send_email(["a@example.com"], "Hi", "Body", "reply@arxiv.org") + + msg = smtp_ssl.return_value.__enter__.return_value.send_message.call_args.args[0] + assert msg["Message-ID"] # auto-generated, non-empty + assert msg["Cc"] is None + assert msg["References"] is None + + +def test_is_available_requires_full_config(): + assert make_service().is_available() is True + assert HalonEmailService("h", "u", "", "f@x.org").is_available() is False + assert HalonEmailService("", "u", "p", "f@x.org").is_available() is False + + +def test_repr_hides_password(): + assert "secret" not in repr(make_service()) + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP") +def test_send_email_starttls_path(smtp): + sess = smtp.return_value.__enter__.return_value + service = HalonEmailService( + host="smtp.example.org", + user="u", + password="p", + from_address="noreply@arxiv.org", + port=587, + use_starttls=True, + ) + service.send_email(["a@example.com"], "Hi", "Body", "reply@arxiv.org") + + smtp.assert_called_once_with(host="smtp.example.org", port=587, timeout=30.0) + sess.starttls.assert_called_once() + sess.login.assert_called_once_with("u", "p") + sess.send_message.assert_called_once() + + +# --- send_email return value --- + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_returns_message_id_and_empty_error_on_success(smtp_ssl): + sess = smtp_ssl.return_value.__enter__.return_value + sess.send_message.return_value = {} + msg_id, err = make_service().send_email( + ["a@example.com"], "Hi", "Body", "reply@arxiv.org", + message_id="", + ) + assert msg_id == "" + assert err == "" + + +# --- send_email error paths --- + +def _send(service=None, **kwargs): + svc = service or make_service() + return svc.send_email(["a@example.com"], "Hi", "Body", "reply@arxiv.org", **kwargs) + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_timeout(smtp_ssl): + smtp_ssl.side_effect = TimeoutError + msg_id, err = _send() + assert "timed out" in err + assert "30.0" in err + assert "mail.example.org" in err + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_auth_failure(smtp_ssl): + smtp_ssl.return_value.__enter__.return_value.login.side_effect = ( + smtplib.SMTPAuthenticationError(535, b"5.7.8 Bad credentials") + ) + msg_id, err = _send() + assert "authentication failed" in err + assert "arxiv" in err # user name included + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_connect_error(smtp_ssl): + smtp_ssl.return_value.__enter__.return_value.login.side_effect = ( + smtplib.SMTPConnectError(421, b"Service unavailable") + ) + msg_id, err = _send() + assert "connect" in err.lower() + assert "mail.example.org" in err + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_all_recipients_refused(smtp_ssl): + smtp_ssl.return_value.__enter__.return_value.send_message.side_effect = ( + smtplib.SMTPRecipientsRefused({"a@example.com": (550, b"User unknown")}) + ) + msg_id, err = _send() + assert "All recipients refused" in err + assert "a@example.com" in err + assert "550" in err + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_sender_refused(smtp_ssl): + smtp_ssl.return_value.__enter__.return_value.login.side_effect = ( + smtplib.SMTPSenderRefused(550, b"Sender denied", "noreply@arxiv.org") + ) + msg_id, err = _send() + assert "refused" in err.lower() + assert "noreply@arxiv.org" in err + + +@mock.patch("submit_ce.implementations.email.smtplib.SMTP_SSL") +def test_send_email_partial_recipients_refused(smtp_ssl): + sess = smtp_ssl.return_value.__enter__.return_value + sess.send_message.return_value = { + "bob@example.com": (550, b"User unknown"), + "sam@example.com": (452, b"Mailbox full"), + } + msg_id, err = _send() + assert "some recipients refused" in err + assert "bob@example.com" in err + assert "sam@example.com" in err + assert "550" in err + assert "452" in err + + +# --- SmtpCreds.parse --- + +def test_parse_smtps_uri(): + creds = SmtpCreds.parse("smtps://arxiv:s3cr3t@mailh.arxiv.org:465") + assert creds.host == "mailh.arxiv.org" + assert creds.port == 465 + assert creds.user == "arxiv" + assert creds.password == "s3cr3t" + assert creds.use_ssl is True + assert creds.use_starttls is False + + +def test_parse_starttls_uri(): + creds = SmtpCreds.parse("smtp+starttls://u:p@smtp.example.org:587") + assert creds.use_ssl is False + assert creds.use_starttls is True + assert creds.port == 587 + + +def test_parse_uri_no_port(): + creds = SmtpCreds.parse("smtps://u:p@mail.example.org") + assert creds.port is None + + +def test_parse_uri_percent_encoded_password(): + creds = SmtpCreds.parse("smtps://user:p%40ssw%21rd@mail.example.org") + assert creds.password == "p@ssw!rd" + + +def test_parse_uri_missing_hostname_raises(): + import pytest + with pytest.raises(RuntimeError, match="hostname"): + SmtpCreds.parse("smtps://") + + diff --git a/submit_ce/implementations/legacy_implementation/__init__.py b/submit_ce/implementations/legacy_implementation/__init__.py index 8b8b534b..f9bd63ae 100644 --- a/submit_ce/implementations/legacy_implementation/__init__.py +++ b/submit_ce/implementations/legacy_implementation/__init__.py @@ -10,8 +10,10 @@ from sqlalchemy.orm import Session as SqlalchemySession, Session from submit_ce.api import SubmitApi +from submit_ce.api.email_service import EmailService from submit_ce.api.file_store import SubmissionFileStore from submit_ce.domain.agent import Client, User +from submit_ce.domain.config import SubmitConfig from submit_ce.domain.meta import License from ...api.compile_service import CompileService @@ -46,9 +48,28 @@ def check_user_authorized( class LegacySubmitImplementation(SubmitApi): - """ - Implementation of `SubmitApi` that interoperates with legacy submission by writing to SFS and DB. - + """Implementation of `SubmitApi` that interoperates with legacy submission. + + Persists submissions and their events to the classic arXiv database and + the submission file store (SFS), bridging the event-sourced domain model + onto the legacy ``arXiv_submissions`` tables. + + Parameters + ---------- + store : SubmissionFileStore + File store used to persist and retrieve submission source packages, + previews and related artifacts. + compiler : CompileService + Service used to compile submission sources (e.g. to PDF). + email_service : EmailService + Service used to send notification email. + config : SubmitConfig + Configuration thta is relevant to the SubmitAPI. + get_session : Callable[[], SqlalchemySession], optional + Factory returning a SQLAlchemy session for database access. + + Notes + ----- TODO success response objects (similar to modapi? {msg: success, updated_fields:[]}) TODO failure to validate response objects (which field caused the problem?) TODO Failure response object (general failure message) @@ -58,21 +79,22 @@ class LegacySubmitImplementation(SubmitApi): def __init__(self, store: SubmissionFileStore, compiler: CompileService, - get_session:Callable[[],SqlalchemySession] = None, - serialize_file_operations:bool = False): + email_service: EmailService, + config: SubmitConfig, + get_session:Callable[[],SqlalchemySession]): self.get_session = get_session - self.serialize_file_operations = serialize_file_operations self.compiler = compiler self.store = store + self.email_service = email_service + self.config = config or SubmitConfig() def __repr__(self) -> str: return (f"{self.__class__.__name__}(" f"store={self.store.__repr__()}," f"compiler={self.compiler.__repr__()}," - f"serialize_file_operations={self.serialize_file_operations}" + f"email_service={self.email_service.__repr__()}," ")") - @override def get(self, submission_id: str) -> Submission: return self._load(self.get_session(), submission_id)[0] @@ -264,6 +286,14 @@ def get_file_store(self) -> SubmissionFileStore: def get_compiler(self) -> CompileService: return self.compiler + @override + def get_email_service(self) -> EmailService: + return self.email_service + + @override + def get_config(self) -> SubmitConfig: + return self.config + @override def next_announcement_time(self, reference: Optional[datetime] = None) -> datetime: return next_announcement_time(reference) diff --git a/submit_ce/implementations/legacy_implementation/flask_impl.py b/submit_ce/implementations/legacy_implementation/flask_impl.py index d3c6383d..98217f6f 100644 --- a/submit_ce/implementations/legacy_implementation/flask_impl.py +++ b/submit_ce/implementations/legacy_implementation/flask_impl.py @@ -15,9 +15,16 @@ class FlaskSubmitImplementation(LegacySubmitImplementation): def __init__(self, store, - compiler, + compiler=None, + email_service=None, + config=None, ): - store = store - compiler = compiler or CompileApiService() - super().__init__(store=store, compiler=compiler) - self.get_session = flask_get_session + from submit_ce.domain.config import SubmitConfig + from submit_ce.implementations import NullEmailService + super().__init__( + store=store, + compiler=compiler or CompileApiService(), + email_service=email_service or NullEmailService(), + config=config or SubmitConfig(), + get_session=flask_get_session, + ) diff --git a/submit_ce/implementations/pubsub/__init__.py b/submit_ce/implementations/pubsub/__init__.py index 6080c75d..878f5599 100644 --- a/submit_ce/implementations/pubsub/__init__.py +++ b/submit_ce/implementations/pubsub/__init__.py @@ -9,6 +9,8 @@ from submit_ce.api import SubmitApi, SubmissionFileStore from submit_ce.domain import Event, Submission from submit_ce.api.compile_service import CompileService +from submit_ce.api.email_service import EmailService +from submit_ce.domain.config import SubmitConfig from submit_ce.domain.event.base import EventList from submit_ce.domain.meta import License @@ -49,6 +51,12 @@ def get_file_store(self) -> SubmissionFileStore: def get_compiler(self) -> CompileService: return self.inner_api.get_compiler() + def get_email_service(self) -> EmailService: + return self.inner_api.get_email_service() + + def get_config(self) -> SubmitConfig: + return self.inner_api.get_config() + def categories_for_user(self, user_id: str) -> list[str]: return self.inner_api.categories_for_user(user_id) diff --git a/submit_ce/ui/backend.py b/submit_ce/ui/backend.py index 611a311d..a804df18 100644 --- a/submit_ce/ui/backend.py +++ b/submit_ce/ui/backend.py @@ -10,7 +10,11 @@ from submit_ce.api import SubmitApi from submit_ce.domain import User, Submission, Event from submit_ce.domain.exceptions import NoSuchSubmission +from submit_ce.domain.config import SubmitConfig from submit_ce.implementations.compile.compile_api_service import CompileApiService +from submit_ce.implementations.email import HalonEmailService +from submit_ce.implementations.email.email_in_memory import EmailInMemory +from submit_ce.implementations.email.smtp_creds import smtp_creds_from_secret from submit_ce.implementations.file_store.gs_file_store import GsFileStore from submit_ce.implementations.legacy_implementation.flask_impl import FlaskSubmitImplementation from submit_ce.implementations import NullFileStore @@ -30,10 +34,37 @@ def config_backend_api(settings: Settings) -> SubmitApi: store = NullFileStore() else: raise NotImplementedError("settings.store may not be set correctly.") - + + email_service = email_service_from_settings(settings) + return FlaskSubmitImplementation( store=store, - compiler=CompileApiService()) + compiler=CompileApiService(), + email_service=email_service, + config=SubmitConfig.from_config(settings)) + + +def email_service_from_settings(settings: Settings): + """Build the configured `EmailService` from application settings. + + ``EMAIL_MODE`` selects the implementation: ``HALON`` builds a + `HalonEmailService` that sends real mail; ``TESTING`` builds an + `EmailInMemory` that captures messages instead of sending them. + """ + if settings.EMAIL_MODE == "TESTING": + logger.info("EMAIL_MODE=TESTING: using in-memory email service") + return EmailInMemory() + + creds = smtp_creds_from_secret(settings.EMAIL_SMTP_SECRET) + return HalonEmailService( + host=creds.host, + port=creds.port or 465, + user=creds.user, + password=creds.password, + from_address=settings.EMAIL_FROM, + use_starttls=creds.use_starttls, + timeout=settings.EMAIL_TIMEOUT, + ) def get_submission(submission_id: str) -> Tuple[Submission, List[Event]]: diff --git a/submit_ce/ui/config.py b/submit_ce/ui/config.py index 6e9e8662..9cd5f28b 100644 --- a/submit_ce/ui/config.py +++ b/submit_ce/ui/config.py @@ -112,6 +112,38 @@ def __init__(self, **kwargs): COMPILE_API_CONVERT_TIMEOUT: int = 840 + EMAIL_MODE: Literal["TESTING", "HALON"] = "TESTING" + """Which `EmailService` the app uses. ``HALON`` sends real mail via the + Halon SMTP server (requires ``EMAIL_SMTP_SECRET`` to resolve); + ``TESTING`` uses an in-memory service that captures messages instead of + sending them. Defaults to ``TESTING`` so the app starts safely without + GCP credentials; set ``EMAIL_MODE=HALON`` in production.""" + + EMAIL_SMTP_SECRET: str = "HALON_CREDS" + """Name of the GCP Secret Manager secret whose value is an SMTP URI + (e.g. ``smtps://user:pass@mailh.arxiv.org:465``). + A short name (e.g. ``"HALON_CREDS"``) is resolved against + ``GOOGLE_CLOUD_PROJECT``; a full ``projects/…/secrets/…/versions/…`` + resource name is passed straight through. Only used when + ``EMAIL_MODE=HALON``.""" + + EMAIL_TIMEOUT: float = 30.0 + """Timeout in seconds for SMTP connection and I/O. Applied to both the + initial connection and all blocking socket operations (login, send). + Only used when ``EMAIL_MODE=HALON``.""" + + EMAIL_FROM: str = "e-prints@arxiv.org" + """Default ``From`` address for outgoing mail. Matches legacy submission mail, + which sends from ``e-prints@arxiv.org``.""" + + EMAIL_REPLY_TO: str = "www-admin@arxiv.org" + """``Reply-To`` address for email. Matches legacy, which used the + configurable ``$WWW_ADMIN_ADDRESS``.""" + + EMAIL_AUTO_HOLD_REPLY_TO: str = "mod-lib@arxiv.org" + """``Reply-To`` address for auto-hold confirmation emails. Legacy uses + ``mod-lib@arxiv.org`` so replies go to the moderation team.""" + COMPILE_API_IMPERSONATE_SA: str = "" """Service account email to impersonate when minting ID tokens for the tex2pdf-api Cloud Run service. Leave empty in production (the attached diff --git a/submit_ce/ui/controllers/cross.py b/submit_ce/ui/controllers/cross.py index 9f759cb4..0358e37d 100644 --- a/submit_ce/ui/controllers/cross.py +++ b/submit_ce/ui/controllers/cross.py @@ -31,7 +31,7 @@ Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 -class HiddenListField(HiddenField): +class HiddenListField(HiddenField): # pragma: no cover def process_formdata(self, valuelist): self.data = list(str(x) for x in valuelist if x) @@ -45,7 +45,7 @@ def _value(self): return ",".join(self.data) if self.data else "" -class CrossListForm(csrf.CSRFForm): +class CrossListForm(csrf.CSRFForm): # pragma: no cover """Submit a cross-list request.""" CATEGORIES = [ @@ -117,7 +117,7 @@ def formset(cls, selected: List[str]) -> Dict[str, 'CrossListForm']: def request_cross(method: str, params: MultiDict, session: Session, - submission_id: str, **kwargs) -> Response: + submission_id: str, **kwargs) -> Response: # pragma: no cover """Request cross-list classification for an announced e-print.""" submitter, client = user_and_client_from_session(session) logger.debug(f'method: {method}, submission: {submission_id}. {params}') diff --git a/submit_ce/ui/controllers/debug/debug_events.py b/submit_ce/ui/controllers/debug/debug_events.py index 1862ee5f..0c330e38 100644 --- a/submit_ce/ui/controllers/debug/debug_events.py +++ b/submit_ce/ui/controllers/debug/debug_events.py @@ -18,8 +18,8 @@ def debug_events(method: str, params: MultiDict, session: Session, - submission_id: str, token: str, **kwargs) -> Response: - + submission_id: str, token: str, **kwargs) -> Response: # pragma: no cover + submission, _ = get_submission(submission_id) workspace = current_app.api.get_file_store().get_workspace( diff --git a/submit_ce/ui/controllers/delete.py b/submit_ce/ui/controllers/delete.py index 6600a1ca..22cb0abc 100644 --- a/submit_ce/ui/controllers/delete.py +++ b/submit_ce/ui/controllers/delete.py @@ -16,14 +16,14 @@ from submit_ce.ui.controllers.util import Response, validate_command -class DeleteForm(csrf.CSRFForm): +class DeleteForm(csrf.CSRFForm): # pragma: no cover """Form for deleting a submission or a revision.""" confirmed = BooleanField('Confirmed', validators=[validators.DataRequired()]) -class CancelRequestForm(csrf.CSRFForm): +class CancelRequestForm(csrf.CSRFForm): # pragma: no cover """Form for cancelling a request.""" confirmed = BooleanField('Confirmed', @@ -31,7 +31,7 @@ class CancelRequestForm(csrf.CSRFForm): def delete(method: str, params: MultiDict, session: Session, - submission_id: str, **kwargs) -> Response: + submission_id: str, **kwargs) -> Response: # pragma: no cover """ Delete a submission, replacement, or other request. @@ -74,7 +74,7 @@ def delete(method: str, params: MultiDict, session: Session, def cancel_request(method: str, params: MultiDict, session: Session, submission_id: str, request_id: str, - **kwargs) -> Response: + **kwargs) -> Response: # pragma: no cover submission, submission_events = get_submission(submission_id) # if request_type == WithdrawalRequest.NAME.lower(): diff --git a/submit_ce/ui/tests/test_backend_email_service.py b/submit_ce/ui/tests/test_backend_email_service.py new file mode 100644 index 00000000..047b91d6 --- /dev/null +++ b/submit_ce/ui/tests/test_backend_email_service.py @@ -0,0 +1,83 @@ +"""Unit tests for backend.email_service_from_settings (EMAIL_MODE switch).""" +from types import SimpleNamespace +from unittest import mock + +from submit_ce.ui.backend import email_service_from_settings +from submit_ce.implementations.email import HalonEmailService +from submit_ce.implementations.email.email_in_memory import EmailInMemory +from submit_ce.implementations.email.smtp_creds import SmtpCreds + + +def _halon_settings(mode="HALON"): + return SimpleNamespace( + EMAIL_MODE=mode, + EMAIL_SMTP_SECRET="MY_SECRET", + EMAIL_FROM="from@arxiv.org", + EMAIL_TIMEOUT=30.0, + ) + + +def _ssl_creds(): + return SmtpCreds( + host="mail.example.org", + port=465, + user="u", + password="p", + use_ssl=True, + use_starttls=False, + ) + + +def test_testing_mode_returns_in_memory(): + service = email_service_from_settings(SimpleNamespace(EMAIL_MODE="TESTING")) + assert isinstance(service, EmailInMemory) + + +def test_halon_mode_fetches_secret_and_builds_service(): + with mock.patch( + "submit_ce.ui.backend.smtp_creds_from_secret", return_value=_ssl_creds() + ) as mock_fetch: + service = email_service_from_settings(_halon_settings()) + + mock_fetch.assert_called_once_with("MY_SECRET") + assert isinstance(service, HalonEmailService) + assert service.host == "mail.example.org" + assert service.port == 465 + assert service.user == "u" + assert service.from_address == "from@arxiv.org" + assert service.use_starttls is False + + +def test_halon_mode_starttls_creds(): + starttls_creds = SmtpCreds( + host="smtp.example.org", + port=587, + user="u", + password="p", + use_ssl=False, + use_starttls=True, + ) + with mock.patch( + "submit_ce.ui.backend.smtp_creds_from_secret", return_value=starttls_creds + ): + service = email_service_from_settings(_halon_settings()) + + assert service.use_starttls is True + assert service.port == 587 + + +def test_halon_mode_falls_back_to_port_465_when_uri_has_no_port(): + no_port_creds = SmtpCreds( + host="mail.example.org", + port=None, + user="u", + password="p", + use_ssl=True, + use_starttls=False, + ) + with mock.patch( + "submit_ce.ui.backend.smtp_creds_from_secret", return_value=no_port_creds + ): + service = email_service_from_settings(_halon_settings()) + + assert service.port == 465 diff --git a/submit_ce/ui/tests/test_finalize_emails_submitter.py b/submit_ce/ui/tests/test_finalize_emails_submitter.py new file mode 100644 index 00000000..ba5f74d3 --- /dev/null +++ b/submit_ce/ui/tests/test_finalize_emails_submitter.py @@ -0,0 +1,73 @@ +"""End-to-end: finalizing a submission emails the submitter. + +Exercises the Event.consequences() mechanism through the real save loop: +FinalizeSubmission declares EmailSubmitterFinalizeMsg as a consequence and emits +it on finalize; the save loop executes the side effect (sending the email) in +the same transaction. We swap in an EmailInMemory service to capture the send. +""" +from flask import current_app + +from submit_ce.domain.agent import InternalClient +from submit_ce.domain.event import FinalizeSubmission, EmailSubmitterFinalizeMsg +from submit_ce.domain.submission import Submission +from submit_ce.implementations.email.email_in_memory import EmailInMemory + + +def test_finalize_sends_submitter_email(app, authorized_user, sub_metadata): + """Finalizing captures exactly one confirmation email to the submitter.""" + with app.app_context(): + user = authorized_user + ua = InternalClient(name="test_client_finalize_email") + sid = sub_metadata.submission_id + + service = EmailInMemory() + original = current_app.api.email_service + current_app.api.email_service = service + try: + submission, _ = current_app.api.save( + FinalizeSubmission(creator=user, client=ua), submission_id=sid) + finally: + current_app.api.email_service = original + + assert submission.status == Submission.SUBMITTED + assert len(service.sent) == 1 + sent = service.last + assert sent.to == [submission.contact_email] + assert sent.subject == f"arXiv submission {sid}" + # Reply-To and dashboard URL are resolved from SubmitConfig, which the + # backend builds from settings (BASE_SERVER substituted into the URL). + assert sent.reply_to == "www-admin@arxiv.org" + assert "/user/" in sent.body + assert current_app.api.get_config().url_for_user_dashboard in sent.body + + # The consequence is persisted as a real event in the history. + _, history = current_app.api.get_with_history(str(sid)) + assert any(isinstance(e, EmailSubmitterFinalizeMsg) for e in history) + + +def test_finalize_email_failure_is_non_fatal(app, authorized_user, sub_metadata): + """A failing email send must not abort the finalize transaction.""" + class _RaisingService(EmailInMemory): + def send_email(self, *args, **kwargs): + raise RuntimeError("smtp boom") + + with app.app_context(): + user = authorized_user + ua = InternalClient(name="test_client_finalize_email") + sid = sub_metadata.submission_id + + original = current_app.api.email_service + current_app.api.email_service = _RaisingService() + try: + submission, _ = current_app.api.save( + FinalizeSubmission(creator=user, client=ua), submission_id=sid) + finally: + current_app.api.email_service = original + + # Submit still succeeded despite the email failure. + assert submission.status == Submission.SUBMITTED + _, history = current_app.api.get_with_history(str(sid)) + emails = [e for e in history + if isinstance(e, EmailSubmitterFinalizeMsg)] + assert len(emails) == 1 + assert emails[0].error is not None diff --git a/uv.lock b/uv.lock index 649f9729..b392cc68 100644 --- a/uv.lock +++ b/uv.lock @@ -634,6 +634,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/cb/b783f4e910f0ec4010d279bafce0cd1ed8a10bac41970eb5c6a6416008ab/google_cloud_pubsub-2.35.0-py3-none-any.whl", hash = "sha256:c32e4eb29e532ec784b5abb5d674807715ec07895b7c022b9404871dec09970d", size = 320973, upload-time = "2026-02-05T22:29:13.096Z" }, ] +[[package]] +name = "google-cloud-secret-manager" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/7c/5c88cdde9664f6c75fb68aa11e0af4309a92bef38dd38df0456ffb0f469b/google_cloud_secret_manager-2.29.0.tar.gz", hash = "sha256:ee64133af8fdb3780affb65ec6ccf10ab15a0113d8edeba388665f4be87ce1be", size = 278437, upload-time = "2026-06-03T16:13:43.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c2/fc3275bc42a522757cb5141d7dae51f048b93d2f5fe4574fcee5392cef03/google_cloud_secret_manager-2.29.0-py3-none-any.whl", hash = "sha256:21bac2d0adb0bb3c13c346d7223832f197c2266534528a1bf1402774e06395a3", size = 225042, upload-time = "2026-06-03T16:12:20.162Z" }, +] + [[package]] name = "google-cloud-storage" version = "3.9.0" @@ -2019,6 +2036,7 @@ dependencies = [ { name = "flask" }, { name = "gcld3" }, { name = "google-cloud-pubsub" }, + { name = "google-cloud-secret-manager" }, { name = "greenlet" }, { name = "gunicorn" }, { name = "h11" }, @@ -2109,6 +2127,7 @@ dev = [ { name = "docopt" }, { name = "epc" }, { name = "google-cloud-pubsub" }, + { name = "google-cloud-secret-manager" }, { name = "hypothesis" }, { name = "hypothesis-jsonschema" }, { name = "idna" }, @@ -2178,6 +2197,7 @@ requires-dist = [ { name = "flask", specifier = "==3.0.3" }, { name = "gcld3", specifier = ">=3.0.13" }, { name = "google-cloud-pubsub", specifier = ">=2.29.0" }, + { name = "google-cloud-secret-manager", specifier = ">=2.20.0" }, { name = "greenlet", specifier = "==3.1.0" }, { name = "gunicorn", specifier = ">=23.0.0" }, { name = "h11", specifier = "==0.14.0" }, @@ -2268,6 +2288,7 @@ dev = [ { name = "docopt", specifier = "==0.6.2" }, { name = "epc", specifier = ">=0.0.5" }, { name = "google-cloud-pubsub", specifier = ">=2.29.0" }, + { name = "google-cloud-secret-manager", specifier = ">=2.20.0" }, { name = "hypothesis", specifier = ">=6.131.21" }, { name = "hypothesis-jsonschema", specifier = ">=0.23.1" }, { name = "idna", specifier = "==3.10" },