diff --git a/README.md b/README.md index f7ad96167d..d9aabb1900 100644 --- a/README.md +++ b/README.md @@ -286,18 +286,14 @@ Local analytics are enabled by default. S3 analytics can be enabled via the foll #### Email -### Email sending - To use the features that require sending emails, for example to reset the password for logged-out users, you will need to have a working SMTP server and set some environment variables: -```sh -export SIMPLIFIED_MAIL_SERVER=example.smtp.com -export SIMPLIFIED_MAIL_PORT=465 -export SIMPLIFIED_MAIL_USERNAME=username -export SIMPLIFIED_MAIL_PASSWORD=password -export SIMPLIFIED_MAIL_SENDER=sender@example.com -``` +- `PALACE_MAIL_SERVER` - The SMTP server to use. Required if you want to send emails. +- `PALACE_MAIL_PORT` - The port of the SMTP server. Default: 25. (optional) +- `PALACE_MAIL_USERNAME` - The username to use when connecting to the SMTP server. (optional) +- `PALACE_MAIL_PASSWORD` - The password to use when connecting to the SMTP server. (optional) +- `PALACE_MAIL_SENDER` - The email address to use as the sender of the emails. (optional) ## Running the Application diff --git a/api/admin/controller/base.py b/api/admin/controller/base.py index f4e5056bac..2cdc08cbf4 100644 --- a/api/admin/controller/base.py +++ b/api/admin/controller/base.py @@ -22,11 +22,12 @@ class AdminController: def __init__(self, manager): self.manager = manager self._db = self.manager._db + self.send_email = self.manager.services.email.send_email @property def admin_auth_providers(self): if Admin.with_password(self._db).count() != 0: - return [PasswordAdminAuthenticationProvider()] + return [PasswordAdminAuthenticationProvider(self.send_email)] return [] diff --git a/api/admin/password_admin_authentication_provider.py b/api/admin/password_admin_authentication_provider.py index 5e0d033cac..91106f9d48 100644 --- a/api/admin/password_admin_authentication_provider.py +++ b/api/admin/password_admin_authentication_provider.py @@ -14,11 +14,12 @@ ) from core.model import Admin, Key from core.model.key import KeyType -from core.util.email import EmailManager +from core.service.email.email import SendEmailCallable +from core.util.log import LoggerMixin from core.util.problem_detail import ProblemDetail -class PasswordAdminAuthenticationProvider(AdminAuthenticationProvider): +class PasswordAdminAuthenticationProvider(AdminAuthenticationProvider, LoggerMixin): NAME = "Password Auth" SIGN_IN_TEMPLATE = sign_in_template.format( @@ -33,6 +34,9 @@ class PasswordAdminAuthenticationProvider(AdminAuthenticationProvider): label=label_style, input=input_style, button=button_style ) + def __init__(self, send_email: SendEmailCallable): + self.send_email = send_email + @staticmethod def get_secret_key(db: Session) -> str: key = Key.get_key(db, KeyType.ADMIN_SECRET_KEY, raise_exception=True).value @@ -98,6 +102,13 @@ def generate_reset_password_token(self, admin: Admin, _db: Session) -> str: def send_reset_password_email(self, admin: Admin, reset_password_url: str) -> None: subject = f"{AdminClientConfig.APP_NAME} - Reset password email" + if admin.email is None: + # This should never happen, but if it does, we should log it. + self.log.error( + "Admin has no email address, cannot send reset password email." + ) + return + receivers = [admin.email] mail_text = render_template_string( @@ -111,7 +122,9 @@ def send_reset_password_email(self, admin: Admin, reset_password_url: str) -> No reset_password_url=reset_password_url, ) - EmailManager.send_email(subject, receivers, text=mail_text, html=mail_html) + self.send_email( + subject=subject, receivers=receivers, text=mail_text, html=mail_html + ) def validate_token_and_extract_admin( self, reset_password_token: str, admin_id: int, _db: Session diff --git a/core/jobs/playtime_entries.py b/core/jobs/playtime_entries.py index c63d7bde96..b13510b340 100644 --- a/core/jobs/playtime_entries.py +++ b/core/jobs/playtime_entries.py @@ -19,7 +19,6 @@ from core.model.identifier import Identifier, RecursiveEquivalencyCache from core.model.time_tracking import PlaytimeEntry, PlaytimeSummary from core.util.datetime_helpers import previous_months, utc_now -from core.util.email import EmailManager from scripts import Script if TYPE_CHECKING: @@ -207,8 +206,8 @@ def do_run(self): Configuration.REPORTING_EMAIL_ENVIRONMENT_VARIABLE ) if recipient: - EmailManager.send_email( - email_subject, + self.services.email.send_email( + subject=email_subject, receivers=[recipient], text="", attachments={attachment_name: temp.read()}, diff --git a/core/service/container.py b/core/service/container.py index 19510322a4..55f7e4a37c 100644 --- a/core/service/container.py +++ b/core/service/container.py @@ -4,6 +4,8 @@ from core.service.analytics.configuration import AnalyticsConfiguration from core.service.analytics.container import AnalyticsContainer +from core.service.email.configuration import EmailConfiguration +from core.service.email.container import Email from core.service.logging.configuration import LoggingConfiguration from core.service.logging.container import Logging from core.service.search.configuration import SearchConfiguration @@ -37,6 +39,11 @@ class Services(DeclarativeContainer): config=config.search, ) + email = Container( + Email, + config=config.email, + ) + def wire_container(container: Services) -> None: container.wire( @@ -67,6 +74,7 @@ def create_container() -> Services: "logging": LoggingConfiguration().dict(), "analytics": AnalyticsConfiguration().dict(), "search": SearchConfiguration().dict(), + "email": EmailConfiguration().dict(), } ) wire_container(container) diff --git a/core/service/email/configuration.py b/core/service/email/configuration.py new file mode 100644 index 0000000000..1456456943 --- /dev/null +++ b/core/service/email/configuration.py @@ -0,0 +1,14 @@ +from pydantic import EmailStr, PositiveInt + +from core.service.configuration import ServiceConfiguration + + +class EmailConfiguration(ServiceConfiguration): + class Config: + env_prefix = "PALACE_MAIL_" + + server: str | None = None + port: PositiveInt = 25 + username: str | None = None + password: str | None = None + sender: EmailStr | None = None diff --git a/core/service/email/container.py b/core/service/email/container.py new file mode 100644 index 0000000000..4f98861b38 --- /dev/null +++ b/core/service/email/container.py @@ -0,0 +1,24 @@ +from dependency_injector import providers +from dependency_injector.containers import DeclarativeContainer +from dependency_injector.providers import Provider +from redmail import EmailSender + +from core.service.email.email import SendEmailCallable, emailer_factory, send_email + + +class Email(DeclarativeContainer): + config = providers.Configuration() + + emailer: Provider[EmailSender] = providers.Singleton( + emailer_factory, + host=config.server, + port=config.port, + username=config.username, + password=config.password, + ) + + send_email: SendEmailCallable = providers.Callable( + send_email, + emailer=emailer, + sender=config.sender, + ) diff --git a/core/service/email/email.py b/core/service/email/email.py new file mode 100644 index 0000000000..b7ad1e131b --- /dev/null +++ b/core/service/email/email.py @@ -0,0 +1,59 @@ +import os +from email.message import EmailMessage +from typing import Any, Protocol + +from redmail import EmailSender + +from core.config import CannotLoadConfiguration + + +def emailer_factory( + host: str | None, port: int, username: str | None, password: str | None +) -> EmailSender: + if host is None: + raise CannotLoadConfiguration( + "Mail server must be provided. Please set PALACE_MAIL_SERVER." + ) + + return EmailSender( + host=host, + port=port, + # Username and password are ignored here because the emailer library has them + # as required, but defaults them to None. So their types are not correct. + # PR here to fix the type hint upstream: https://github.com/Miksus/red-mail/pull/90 + username=username, # type: ignore[arg-type] + password=password, # type: ignore[arg-type] + ) + + +def send_email( + *, + emailer: EmailSender, + sender: str, + subject: str, + receivers: list[str] | str, + html: str | None = None, + text: str | None = None, + attachments: dict[str, str | os.PathLike[Any] | bytes] | None = None, +) -> EmailMessage: + return emailer.send( + subject=subject, + sender=sender, + receivers=receivers, + text=text, + html=html, + attachments=attachments, + ) + + +class SendEmailCallable(Protocol): + def __call__( + self, + *, + subject: str, + receivers: list[str] | str, + html: str | None = None, + text: str | None = None, + attachments: dict[str, str | os.PathLike[Any] | bytes] | None = None, + ) -> EmailMessage: + ... diff --git a/core/util/email.py b/core/util/email.py deleted file mode 100644 index d364dd628a..0000000000 --- a/core/util/email.py +++ /dev/null @@ -1,37 +0,0 @@ -import os - -from redmail import EmailSender - - -class EmailManager: - MAIL_SERVER = os.environ.get("SIMPLIFIED_MAIL_SERVER") - MAIL_PORT = int(os.environ.get("SIMPLIFIED_MAIL_PORT", "25")) - MAIL_USERNAME = os.environ.get("SIMPLIFIED_MAIL_USERNAME") - MAIL_PASSWORD = os.environ.get("SIMPLIFIED_MAIL_PASSWORD") - MAIL_SENDER = os.environ.get("SIMPLIFIED_MAIL_SENDER") - - @classmethod - def send_email( - cls, - subject, - receivers, - sender=MAIL_SENDER, - text=None, - html=None, - attachments=None, - ): - email_sender = EmailSender( - host=cls.MAIL_SERVER, - port=cls.MAIL_PORT, - username=cls.MAIL_USERNAME, - password=cls.MAIL_PASSWORD, - ) - - email_sender.send( - subject=subject, - sender=sender, - receivers=receivers, - text=text, - html=html, - attachments=attachments, - ) diff --git a/tests/api/admin/controller/test_reset_password.py b/tests/api/admin/controller/test_reset_password.py index f765914809..5b99c4a9a5 100644 --- a/tests/api/admin/controller/test_reset_password.py +++ b/tests/api/admin/controller/test_reset_password.py @@ -1,5 +1,4 @@ import re -from unittest import mock import flask from werkzeug import Response as WerkzeugResponse @@ -12,6 +11,7 @@ ) from core.util.problem_detail import ProblemDetail from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.services import ServicesEmailFixture class TestResetPasswordController: @@ -65,7 +65,11 @@ def test_forgot_password_get(self, admin_ctrl_fixture: AdminControllerFixture): assert "admin/web" in location - def test_forgot_password_post(self, admin_ctrl_fixture: AdminControllerFixture): + def test_forgot_password_post( + self, + admin_ctrl_fixture: AdminControllerFixture, + services_email_fixture: ServicesEmailFixture, + ): reset_password_ctrl = admin_ctrl_fixture.manager.admin_reset_password_controller # If there is no admin sent in the request we should get error response @@ -97,35 +101,33 @@ def test_forgot_password_post(self, admin_ctrl_fixture: AdminControllerFixture): ) # When the real admin is used the email is sent and we get success message in the response - with mock.patch( - "api.admin.password_admin_authentication_provider.EmailManager" - ) as mock_email_manager: - with admin_ctrl_fixture.ctrl.app.test_request_context( - "/admin/forgot_password", method="POST" - ): - admin_email = admin_ctrl_fixture.admin.email - assert isinstance(admin_email, str) - - flask.request.form = ImmutableMultiDict([("email", admin_email)]) - - response = reset_password_ctrl.forgot_password() - assert isinstance(response, WerkzeugResponse) + with admin_ctrl_fixture.ctrl.app.test_request_context( + "/admin/forgot_password", method="POST" + ): + admin_email = admin_ctrl_fixture.admin.email + assert isinstance(admin_email, str) - assert response.status_code == 200 - assert "Email successfully sent" in response.get_data(as_text=True) + flask.request.form = ImmutableMultiDict([("email", admin_email)]) - # Check the email is sent - assert mock_email_manager.send_email.call_count == 1 + response = reset_password_ctrl.forgot_password() + assert isinstance(response, WerkzeugResponse) - call_args, call_kwargs = mock_email_manager.send_email.call_args_list[0] + assert response.status_code == 200 + assert "Email successfully sent" in response.get_data(as_text=True) - # Check that the email is sent to the right admin - _, receivers = call_args + # Check the email is sent + assert services_email_fixture.mock_emailer.send.call_count == 1 - assert len(receivers) == 1 - assert receivers[0] == admin_email + # Check that the email is sent to the right admin + assert services_email_fixture.mock_emailer.send.call_args.kwargs[ + "receivers" + ] == [admin_email] - def test_reset_password_get(self, admin_ctrl_fixture: AdminControllerFixture): + def test_reset_password_get( + self, + admin_ctrl_fixture: AdminControllerFixture, + services_email_fixture: ServicesEmailFixture, + ): reset_password_ctrl = admin_ctrl_fixture.manager.admin_reset_password_controller token = "token" @@ -199,28 +201,24 @@ def test_reset_password_get(self, admin_ctrl_fixture: AdminControllerFixture): # Finally, if we use good token we get back view with the form for the new password # Let's get valid token first - with mock.patch( - "api.admin.password_admin_authentication_provider.EmailManager" - ) as mock_email_manager: - with admin_ctrl_fixture.ctrl.app.test_request_context( - "/admin/forgot_password", method="POST" - ): - flask.request.form = ImmutableMultiDict([("email", admin_email)]) + with admin_ctrl_fixture.ctrl.app.test_request_context( + "/admin/forgot_password", method="POST" + ): + flask.request.form = ImmutableMultiDict([("email", admin_email)]) - forgot_password_response = reset_password_ctrl.forgot_password() - assert isinstance(forgot_password_response, WerkzeugResponse) + forgot_password_response = reset_password_ctrl.forgot_password() + assert isinstance(forgot_password_response, WerkzeugResponse) - assert forgot_password_response.status_code == 200 + assert forgot_password_response.status_code == 200 - call_args, call_kwargs = mock_email_manager.send_email.call_args_list[0] - mail_text = call_kwargs["text"] + mail_text = services_email_fixture.mock_emailer.send.call_args.kwargs[ + "text" + ] - ( - token, - admin_id, - ) = self._extract_reset_pass_token_and_admin_id_from_mail_text( - mail_text - ) + ( + token, + admin_id, + ) = self._extract_reset_pass_token_and_admin_id_from_mail_text(mail_text) with admin_ctrl_fixture.ctrl.app.test_request_context("/admin/reset_password"): assert isinstance(admin_id, int) @@ -243,33 +241,33 @@ def _extract_reset_pass_token_and_admin_id_from_mail_text(self, mail_text): return token, admin_id - def test_reset_password_post(self, admin_ctrl_fixture: AdminControllerFixture): + def test_reset_password_post( + self, + admin_ctrl_fixture: AdminControllerFixture, + services_email_fixture: ServicesEmailFixture, + ): reset_password_ctrl = admin_ctrl_fixture.manager.admin_reset_password_controller admin_email = admin_ctrl_fixture.admin.email assert isinstance(admin_email, str) # Let's get valid token first - with mock.patch( - "api.admin.password_admin_authentication_provider.EmailManager" - ) as mock_email_manager: - with admin_ctrl_fixture.ctrl.app.test_request_context( - "/admin/forgot_password", method="POST" - ): - flask.request.form = ImmutableMultiDict([("email", admin_email)]) - - response = reset_password_ctrl.forgot_password() - assert response.status_code == 200 - - call_args, call_kwargs = mock_email_manager.send_email.call_args_list[0] - mail_text = call_kwargs["text"] - - ( - token, - admin_id, - ) = self._extract_reset_pass_token_and_admin_id_from_mail_text( - mail_text - ) + with admin_ctrl_fixture.ctrl.app.test_request_context( + "/admin/forgot_password", method="POST" + ): + flask.request.form = ImmutableMultiDict([("email", admin_email)]) + + response = reset_password_ctrl.forgot_password() + assert response.status_code == 200 + + mail_text = services_email_fixture.mock_emailer.send.call_args.kwargs[ + "text" + ] + + ( + token, + admin_id, + ) = self._extract_reset_pass_token_and_admin_id_from_mail_text(mail_text) # If we use bad token we get an error response with "Try again" button with admin_ctrl_fixture.ctrl.app.test_request_context( diff --git a/tests/api/admin/test_password_admin_authentication_provider.py b/tests/api/admin/test_password_admin_authentication_provider.py index 5eb24ad9c5..f65db98e61 100644 --- a/tests/api/admin/test_password_admin_authentication_provider.py +++ b/tests/api/admin/test_password_admin_authentication_provider.py @@ -1,14 +1,37 @@ +from unittest.mock import MagicMock, create_autospec + +import pytest +from _pytest.logging import LogCaptureFixture + from api.admin.password_admin_authentication_provider import ( PasswordAdminAuthenticationProvider, ) from api.admin.problem_details import * from core.model import Admin, create +from core.service.email.email import send_email from tests.fixtures.database import DatabaseTransactionFixture +class PasswordAdminAuthenticationProviderFixture: + def __init__(self): + self.mock_send_email = create_autospec(send_email) + self.password_auth = PasswordAdminAuthenticationProvider( + send_email=self.mock_send_email + ) + + +@pytest.fixture +def password_auth_provider() -> PasswordAdminAuthenticationProviderFixture: + return PasswordAdminAuthenticationProviderFixture() + + class TestPasswordAdminAuthenticationProvider: - def test_sign_in(self, db: DatabaseTransactionFixture): - password_auth = PasswordAdminAuthenticationProvider() + def test_sign_in( + self, + db: DatabaseTransactionFixture, + password_auth_provider: PasswordAdminAuthenticationProviderFixture, + ): + password_auth = password_auth_provider.password_auth # There are two admins with passwords. admin1, ignore = create(db.session, Admin, email="admin1@example.org") @@ -77,8 +100,12 @@ def test_sign_in(self, db: DatabaseTransactionFixture): ) assert redirect == "/admin/web" - def test_sign_in_case_insensitive(self, db: DatabaseTransactionFixture): - password_auth = PasswordAdminAuthenticationProvider() + def test_sign_in_case_insensitive( + self, + db: DatabaseTransactionFixture, + password_auth_provider: PasswordAdminAuthenticationProviderFixture, + ): + password_auth = password_auth_provider.password_auth # There are two admins with passwords. admin1, ignore = create(db.session, Admin, email="admin1@example.org") @@ -102,3 +129,20 @@ def test_sign_in_case_insensitive(self, db: DatabaseTransactionFixture): assert "ADMIN2@example.org" == admin_details.get("email") assert PasswordAdminAuthenticationProvider.NAME == admin_details.get("type") assert "foo" == redirect + + def test_send_reset_password_email( + self, + password_auth_provider: PasswordAdminAuthenticationProviderFixture, + caplog: LogCaptureFixture, + ): + password_auth = password_auth_provider.password_auth + mock_admin = MagicMock() + mock_admin.email = None + assert ( + password_auth.send_reset_password_email(mock_admin, "reset_password_url") + is None + ) + assert ( + "Admin has no email address, cannot send reset password email" + in caplog.text + ) diff --git a/tests/core/jobs/test_playtime_entries.py b/tests/core/jobs/test_playtime_entries.py index fdc182ec14..0950a8e5d7 100644 --- a/tests/core/jobs/test_playtime_entries.py +++ b/tests/core/jobs/test_playtime_entries.py @@ -22,6 +22,7 @@ from core.model.time_tracking import PlaytimeEntry, PlaytimeSummary from core.util.datetime_helpers import datetime_utc, previous_months, utc_now from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.services import ServicesEmailFixture def create_playtime_entries( @@ -220,7 +221,11 @@ def playtime(session, identifier, collection, library, timestamp, total_seconds) class TestPlaytimeEntriesEmailReportsScript: - def test_do_run(self, db: DatabaseTransactionFixture): + def test_do_run( + self, + db: DatabaseTransactionFixture, + services_email_fixture: ServicesEmailFixture, + ): identifier = db.identifier() collection = db.default_collection() library = db.default_library() @@ -270,10 +275,8 @@ def test_do_run(self, db: DatabaseTransactionFixture): playtime(db.session, identifier, collection2, library2, date1m(3), 300) reporting_name = "test cm" - with ( patch("core.jobs.playtime_entries.csv.writer") as writer, - patch("core.jobs.playtime_entries.EmailManager") as email, patch( "core.jobs.playtime_entries.os.environ", new={ @@ -363,11 +366,13 @@ def test_do_run(self, db: DatabaseTransactionFixture): ), # Identifier with edition ] - assert email.send_email.call_count == 1 - assert email.send_email.call_args == call( - f"{reporting_name}: Playtime Summaries {cutoff} - {until}", + assert services_email_fixture.mock_emailer.send.call_count == 1 + assert services_email_fixture.mock_emailer.send.call_args == call( + subject=f"{reporting_name}: Playtime Summaries {cutoff} - {until}", + sender=services_email_fixture.sender_email, receivers=["reporting@test.email"], text="", + html=None, attachments={ f"playtime-summary-{reporting_name.replace(' ', '_')}-{cutoff}-{until}.csv": "" }, # Mock objects do not write data diff --git a/tests/core/service/email/__init__.py b/tests/core/service/email/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/core/service/email/test_container.py b/tests/core/service/email/test_container.py new file mode 100644 index 0000000000..a4598448dc --- /dev/null +++ b/tests/core/service/email/test_container.py @@ -0,0 +1,56 @@ +from unittest.mock import create_autospec + +import pytest +from redmail import EmailSender + +from core.config import CannotLoadConfiguration +from core.service.email.container import Email + + +@pytest.fixture +def container() -> Email: + email = Email() + email.config.from_dict( + { + "server": "test_server.com", + "port": 587, + "username": "username", + "password": "password", + "sender": "test@test.com", + } + ) + return email + + +def test_emailer(container: Email): + emailer = container.emailer() + assert isinstance(emailer, EmailSender) + assert emailer.host == "test_server.com" + assert emailer.port == 587 + assert emailer.username == "username" + assert emailer.password == "password" + + +def test_emailer_error(container: Email): + # If the server is None, we get an exception when trying to create the emailer + container.config.set("server", None) + + with pytest.raises(CannotLoadConfiguration) as exc_info: + container.emailer() + assert "Mail server must be provided. Please set PALACE_MAIL_SERVER." in str( + exc_info.value + ) + + +def test_send_email(container: Email): + mock_emailer = create_autospec(EmailSender) + container.emailer.override(mock_emailer) + container.send_email(subject="subject", receivers=["x@y.com", "a@b.com"]) + mock_emailer.send.assert_called_once_with( + subject="subject", + sender="test@test.com", + receivers=["x@y.com", "a@b.com"], + text=None, + html=None, + attachments=None, + ) diff --git a/tests/fixtures/services.py b/tests/fixtures/services.py index e09a63f892..dc075b304b 100644 --- a/tests/fixtures/services.py +++ b/tests/fixtures/services.py @@ -15,6 +15,8 @@ from core.search.service import SearchServiceOpensearch1 from core.service.analytics.container import AnalyticsContainer from core.service.container import Services, wire_container +from core.service.email.configuration import EmailConfiguration +from core.service.email.container import Email from core.service.logging.container import Logging from core.service.logging.log import setup_logging from core.service.search.container import Search @@ -109,6 +111,23 @@ def services_analytics_fixture() -> ServicesAnalyticsFixture: return ServicesAnalyticsFixture(analytics_container, analytics_mock) +@dataclass +class ServicesEmailFixture: + email_container: Email + mock_emailer: MagicMock + sender_email: str + + +@pytest.fixture +def services_email_fixture() -> ServicesEmailFixture: + email_container = Email() + sender_email = "test@email.com" + email_container.config.from_dict(EmailConfiguration(sender=sender_email).dict()) + mock_emailer = MagicMock() + email_container.emailer.override(mock_emailer) + return ServicesEmailFixture(email_container, mock_emailer, sender_email) + + class ServicesFixture: """ Provide a real services container, with all services mocked out. @@ -120,17 +139,20 @@ def __init__( storage: ServicesStorageFixture, search: ServicesSearchFixture, analytics: ServicesAnalyticsFixture, + email: ServicesEmailFixture, ) -> None: self.logging_fixture = logging self.storage_fixture = storage self.search_fixture = search self.analytics_fixture = analytics + self.email_fixture = email self.services = Services() self.services.logging.override(logging.logging_container) self.services.storage.override(storage.storage_container) self.services.search.override(search.search_container) self.services.analytics.override(analytics.analytics_container) + self.services.email.override(email.email_container) # setup basic configuration from default settings self.services.config.from_dict({"sitewide": SitewideConfiguration().dict()}) @@ -166,12 +188,14 @@ def services_fixture( services_storage_fixture: ServicesStorageFixture, services_search_fixture: ServicesSearchFixture, services_analytics_fixture: ServicesAnalyticsFixture, + services_email_fixture: ServicesEmailFixture, ) -> Generator[ServicesFixture, None, None]: fixture = ServicesFixture( logging=services_logging_fixture, storage=services_storage_fixture, search=services_search_fixture, analytics=services_analytics_fixture, + email=services_email_fixture, ) with mock_services_container(fixture.services): yield fixture