Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions submit_ce/api/email_service.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions submit_ce/api/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
"""
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions submit_ce/domain/config.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 19 additions & 10 deletions submit_ce/domain/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down
Loading
Loading