Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
47 changes: 47 additions & 0 deletions submit_ce/api/email_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""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="") -> None:
"""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.
"""
pass



@abstractmethod
def is_available(self) -> bool:
"""Determine whether the service is available."""
pass
17 changes: 17 additions & 0 deletions submit_ce/api/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
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.size_limits import SIZE_LIMIT_POLICY, SizeLimits
from submit_ce.api.file_store import SubmissionFileStore
Expand Down Expand Up @@ -176,6 +177,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
27 changes: 24 additions & 3 deletions submit_ce/implementations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 = "") -> None:
pass

def is_available(self) -> bool:
return False


class NullFileStore(SubmissionFileStore): # pragma: no cover

def get_workspace(self, submission_id: str) -> Optional[Workspace]:
return Workspace(
Expand Down Expand Up @@ -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)

Expand Down
140 changes: 140 additions & 0 deletions submit_ce/implementations/email/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""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 import Optional

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-over-SSL connection. Defaults to 465.
"""

def __init__(self,
host: str,
user: str,
password: str,
from_address: str,
port: int = 465) -> None:
self.host = host
self.user = user
self.password = password
self.from_address = from_address
self.port = port

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 = "") -> None:
"""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)

with smtplib.SMTP_SSL(host=self.host, port=self.port) as sess:
Comment thread
bdc34 marked this conversation as resolved.
Outdated
sess.login(self.user, self.password)
sess.send_message(msg,
from_addr=self.from_address,
to_addrs=recipients,
mail_options=mail_options)
logger.info("Sent email %s to %d recipient(s)",
msg["Message-ID"], len(recipients))

@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)


def email_service_from_settings(settings: Optional[object] = None) -> HalonEmailService:
"""Build a `HalonEmailService` from application settings.

Parameters
----------
settings : object, optional
Settings object exposing the ``EMAIL_*`` attributes. If ``None``,
the application's `submit_ce.ui.config.settings` is used.
"""
if settings is None:
from submit_ce.ui.config import settings as app_settings
settings = app_settings
return HalonEmailService(
host=settings.EMAIL_SMTP_HOST,
port=settings.EMAIL_SMTP_PORT,
user=settings.EMAIL_SMTP_USER,
password=settings.EMAIL_SMTP_PASSWORD,
from_address=settings.EMAIL_FROM,
)
93 changes: 93 additions & 0 deletions submit_ce/implementations/email/email_in_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""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 = "") -> None:
"""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,
))

@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
Empty file.
Loading
Loading