Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
9dc8806
refactor
giancarloromeo Apr 20, 2026
5cd52a4
add bcc
giancarloromeo Apr 20, 2026
2af97de
refactor
giancarloromeo Apr 20, 2026
b69ae10
Merge branch 'master' into refactor/use-notification-service-for-emai…
giancarloromeo Apr 20, 2026
2a8c65c
add attachment
giancarloromeo Apr 21, 2026
4058f16
add context
giancarloromeo Apr 22, 2026
cc210b8
add user_name
giancarloromeo Apr 22, 2026
60430cd
fix
giancarloromeo Apr 22, 2026
7e30b20
Merge branch 'master' into refactor/use-notification-service-for-emai…
giancarloromeo Apr 22, 2026
fd4f6b9
revert
giancarloromeo Apr 22, 2026
ec6f0c7
Merge branch 'refactor/use-notification-service-for-emails-in-payment…
giancarloromeo Apr 22, 2026
b8a956d
update payment gateway
giancarloromeo Apr 22, 2026
415f525
fix
giancarloromeo Apr 22, 2026
0a356fa
fix bytes issue
giancarloromeo Apr 22, 2026
1e8b641
add test
giancarloromeo Apr 22, 2026
a5d07a1
Merge branch 'refactor/use-notification-service-for-emails-in-payment…
giancarloromeo Apr 22, 2026
6fe5f18
fix
giancarloromeo Apr 22, 2026
316f38d
fix
giancarloromeo Apr 22, 2026
bc9e75b
single engine instance
giancarloromeo Apr 22, 2026
620692d
nullable invoice url
giancarloromeo Apr 22, 2026
b9d83f2
add test
giancarloromeo Apr 22, 2026
5eaa575
fix
giancarloromeo Apr 22, 2026
4ff7d68
pylint
giancarloromeo Apr 22, 2026
613f2ed
fix
giancarloromeo Apr 22, 2026
804340d
fix
giancarloromeo Apr 22, 2026
c9166d9
revert
giancarloromeo Apr 22, 2026
51e851f
add test
giancarloromeo Apr 22, 2026
3613b9a
fix type
giancarloromeo Apr 22, 2026
daf5771
fix
giancarloromeo Apr 22, 2026
7f72c74
fix
giancarloromeo Apr 22, 2026
f9862c6
fix
giancarloromeo Apr 22, 2026
c58694d
fix
giancarloromeo Apr 22, 2026
9b2169d
fix
giancarloromeo Apr 22, 2026
56cc902
cleanup
giancarloromeo Apr 22, 2026
88a2c90
update
giancarloromeo Apr 22, 2026
f8be479
fix
giancarloromeo Apr 22, 2026
fcf9c5c
fix
giancarloromeo Apr 22, 2026
8ad379c
fix
giancarloromeo Apr 22, 2026
bd92d9c
remove PAYMENTS_EMAIL
giancarloromeo Apr 22, 2026
4af15da
Merge branch 'refactor/use-notification-service-for-emails-in-payment…
giancarloromeo Apr 22, 2026
f6df75d
revert
giancarloromeo Apr 22, 2026
0efe10f
remove dep
giancarloromeo Apr 22, 2026
84a96da
remove dep
giancarloromeo Apr 22, 2026
1a6739d
fix
giancarloromeo Apr 22, 2026
9eeea51
fix
giancarloromeo Apr 22, 2026
369599b
fix
giancarloromeo Apr 22, 2026
dc79d22
fix
giancarloromeo Apr 23, 2026
5c79619
update template
giancarloromeo Apr 23, 2026
a0e89b2
Merge branch 'refactor/use-notification-service-for-emails-in-payment…
giancarloromeo Apr 23, 2026
647c06b
fix
giancarloromeo Apr 23, 2026
2a14982
fix
giancarloromeo Apr 23, 2026
9340f5b
fix
giancarloromeo Apr 23, 2026
7c85847
fix
giancarloromeo Apr 23, 2026
63eb845
Merge branch 'master' into refactor/use-notification-service-for-emai…
giancarloromeo Apr 23, 2026
37be72c
fix
giancarloromeo Apr 23, 2026
7565171
fix tests
giancarloromeo Apr 23, 2026
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
1 change: 0 additions & 1 deletion .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@ PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT=100.0
PAYMENTS_AUTORECHARGE_ENABLED=1
PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS=100
PAYMENTS_BCC_EMAIL=none
PAYMENTS_EMAIL={}
PAYMENTS_FAKE_COMPLETION_DELAY_SEC=10
PAYMENTS_FAKE_COMPLETION=0
PAYMENTS_GATEWAY_API_SECRET=adminadmin
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from ._email import EmailContact, EmailContent, EmailMessage
from ._email import EmailAttachment, EmailContact, EmailContent, EmailMessage

__all__: tuple[str, ...] = (
"EmailAttachment",
"EmailContact",
"EmailContent",
"EmailMessage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ._email import (
Addressing,
EmailAddressing,
EmailAttachment,
EmailContact,
EmailContent,
EmailMessage,
Expand All @@ -28,6 +29,7 @@
__all__: tuple[str, ...] = (
"Addressing",
"EmailAddressing",
"EmailAttachment",
"EmailContact",
"EmailContent",
"EmailMessage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ class EmailContact(BaseModel):
email: EmailStr


class EmailAttachment(BaseModel):
content: bytes
filename: str

Comment thread
giancarloromeo marked this conversation as resolved.

class EmailContent(BaseModel):
subject: Annotated[str, Field(min_length=1, max_length=998)]
body_html: str | None = None
Expand All @@ -19,8 +24,11 @@ class EmailContent(BaseModel):
class EmailAddressing(BaseModel):
from_: Annotated[EmailContact, Field(alias="from")]
to: list[EmailContact]
bcc: EmailContact | None = None
reply_to: EmailContact | None = None

attachments: list[EmailAttachment] | None = None

model_config = ConfigDict(
frozen=True,
validate_by_alias=True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<p>We are delighted to confirm the successful processing of your payment of <strong>{{ payment.price_dollars }}</strong> <strong><em>USD</em></strong> for the purchase of <strong>{{ payment.osparc_credits }}</strong> <strong><em>credits</em></strong>.
The credits have been added to your {{ product.display_name }} account, and you are all set to utilize them.</p>
<p>For more details you can view or download your <a href="{{ payment.invoice_url }}">receipt</a>.</p>
<p>Please don't hesitate to contact us at {{ product.support_email }} if you need further help.</p>
<p>Please don't hesitate to contact us at <a href="mailto:{{ product.support_email }}">{{ product.support_email }}</a> if you need further help.</p>
Comment thread
giancarloromeo marked this conversation as resolved.
<p>Best Regards,</p>
<p>The <i>{{ product.display_name }}</i> Team</p>

Expand Down
1 change: 0 additions & 1 deletion services/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,6 @@ services:
PAYMENTS_AUTORECHARGE_ENABLED: ${PAYMENTS_AUTORECHARGE_ENABLED}
PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS: ${PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS}
PAYMENTS_BCC_EMAIL: ${PAYMENTS_BCC_EMAIL}
PAYMENTS_EMAIL: ${PAYMENTS_EMAIL}
PAYMENTS_GATEWAY_API_SECRET: ${PAYMENTS_GATEWAY_API_SECRET}
PAYMENTS_GATEWAY_URL: ${PAYMENTS_GATEWAY_URL}
PAYMENTS_LOGLEVEL: ${PAYMENTS_LOGLEVEL}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from models_library.celery import TaskKey
from models_library.notifications.celery import EmailContact, EmailContent, EmailMessage
from notifications_library._email import (
add_attachments,
compose_email,
create_email_session,
)
Expand All @@ -33,22 +34,29 @@ async def send_email_message(
msg = EmailMessage(
from_=EmailContact(**message.from_.model_dump()),
to=EmailContact(**message.to.model_dump()),
bcc=EmailContact(**message.bcc.model_dump()) if message.bcc else None,
reply_to=EmailContact(**message.reply_to.model_dump()) if message.reply_to else None,
content=EmailContent(**message.content.model_dump()),
attachments=message.attachments,
)

with log_context(_logger, logging.INFO, "Send email to %s", msg.to.email):
settings = SMTPSettings.create_from_envs()

async with create_email_session(settings=settings) as smtp:
await smtp.send_message(
compose_email(
from_=_to_address(msg.from_),
to=_to_address(msg.to),
subject=msg.content.subject,
content_text=msg.content.body_text,
content_html=msg.content.body_html,
reply_to=_to_address(msg.reply_to) if msg.reply_to else None,
extra_headers=settings.SMTP_EXTRA_HEADERS,
)
email_msg = compose_email(
from_=_to_address(msg.from_),
to=_to_address(msg.to),
subject=msg.content.subject,
content_text=msg.content.body_text,
content_html=msg.content.body_html,
reply_to=_to_address(msg.reply_to) if msg.reply_to else None,
bcc=[_to_address(msg.bcc)] if msg.bcc else None,
extra_headers=settings.SMTP_EXTRA_HEADERS,
)
if msg.attachments:
add_attachments(
email_msg,
[(a.content, a.filename) for a in msg.attachments],
)
await smtp.send_message(email_msg)
Comment thread
giancarloromeo marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ._credit_reimbursement import CreditReimbursementTemplateContext
from ._empty import EmptyTemplateContext
from ._new_2fa_code import New2faCodeTemplateContext
from ._paid import PaidTemplateContext
from ._registered import RegisteredTemplateContext
from ._reset_password import ResetPasswordTemplateContext
from ._unregister import UnregisterTemplateContext
Expand All @@ -17,6 +18,7 @@
"CreditReimbursementTemplateContext",
"EmptyTemplateContext",
"New2faCodeTemplateContext",
"PaidTemplateContext",
"RegisteredTemplateContext",
"ResetPasswordTemplateContext",
"UnregisterTemplateContext",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Context model for the 'paid' email template."""

from models_library.notifications import Channel
from pydantic import BaseModel, HttpUrl

from ..template import BaseTemplateContext, register_template_context


class User(BaseModel):
first_name: str | None = None
last_name: str | None = None
user_name: str | None = None
email: str | None = None


class Payment(BaseModel):
price_dollars: str
osparc_credits: str
invoice_url: HttpUrl

Comment thread
giancarloromeo marked this conversation as resolved.

@register_template_context(channel=Channel.email, template_name="paid")
class PaidTemplateContext(BaseTemplateContext):
user: User
payment: Payment
Comment thread
giancarloromeo marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,31 @@ class EmailChannelHandler(ChannelHandler):
def prepare_messages(message: EmailMessage) -> list[dict[str, Any]]:
content_dict = message.content.model_dump()
from_dict = message.addressing.from_.model_dump()
bcc_dict = message.addressing.bcc.model_dump() if message.addressing.bcc else None
reply_to_dict = message.addressing.reply_to.model_dump() if message.addressing.reply_to else None

recipients = _interleave_recipients_by_domain(message.addressing.to)

attachments_list = (
[a.model_dump() for a in message.addressing.attachments] if message.addressing.attachments else None
)

payload_base: dict[str, Any] = {
"channel": message.channel,
"from": from_dict,
"content": content_dict,
}
if bcc_dict:
payload_base["bcc"] = bcc_dict
if reply_to_dict:
payload_base["reply_to"] = reply_to_dict

if attachments_list:
payload_base["attachments"] = attachments_list

Comment thread
giancarloromeo marked this conversation as resolved.
return [
CeleryEmailMessage.model_validate({**payload_base, "to": recipient.model_dump()}).model_dump(by_alias=True)
CeleryEmailMessage.model_validate({**payload_base, "to": recipient.model_dump()}).model_dump(
by_alias=True, exclude_none=True
)
for recipient in recipients
Comment thread
giancarloromeo marked this conversation as resolved.
]
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from email.message import EmailMessage as StdEmailMessage
from unittest.mock import AsyncMock

import pytest
from faker import Faker
from models_library.celery import OwnerMetadata, TaskExecutionMetadata, TaskState, TaskStatus
from models_library.notifications.celery import EmailContact, EmailContent, EmailMessage
from models_library.notifications.celery import (
EmailAttachment,
EmailContact,
EmailContent,
EmailMessage,
)
from servicelib.celery.task_manager import TaskManager
from simcore_service_notifications.api.celery.tasks import (
send_email_message,
Expand Down Expand Up @@ -67,3 +73,55 @@ async def test_send_mail(
# if mocked, check email was sent
if smtp_mock_or_none:
smtp_mock_or_none.send_message.assert_called_once()


@pytest.mark.usefixtures(
"mock_celery_worker",
)
async def test_send_mail_with_bcc_and_attachment(
task_manager: TaskManager,
smtp_mock_or_none: AsyncMock | None,
faker: Faker,
):
owner_metadata = OwnerMetadata(owner="test_service")

bcc_contact = EmailContact(name=faker.name(), email=faker.email())
attachment_content = faker.binary(length=128)
attachment_filename = "invoice.pdf"

task_uuid = await task_manager.submit_task(
TaskExecutionMetadata(name=send_email_message.__name__),
owner_metadata=owner_metadata,
message=EmailMessage(
from_=EmailContact(email=faker.email()),
to=EmailContact(email=faker.email()),
bcc=bcc_contact,
content=EmailContent(
subject="Test with BCC and attachment",
body_text="Plain text body",
body_html="<p>HTML body</p>",
),
attachments=[
EmailAttachment(content=attachment_content, filename=attachment_filename),
],
).model_dump(),
)

async for attempt in AsyncRetrying(**_TENACITY_RETRY_PARAMS):
with attempt:
status = await task_manager.get_status(owner_metadata, task_uuid)
assert isinstance(status, TaskStatus) # nosec
assert status.task_state == TaskState.SUCCESS

if smtp_mock_or_none:
smtp_mock_or_none.send_message.assert_called_once()
sent_msg: StdEmailMessage = smtp_mock_or_none.send_message.call_args[0][0]

# bcc is reflected
assert bcc_contact.email in sent_msg["Bcc"]

# attachment is present
attachments = list(sent_msg.iter_attachments())
assert len(attachments) == 1
assert attachments[0].get_filename() == attachment_filename
assert attachments[0].get_content() == attachment_content
67 changes: 67 additions & 0 deletions services/notifications/tests/unit/test_api_rpc_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,70 @@ async def test_preview_new_2fa_code_template_invalid_context(

with pytest.raises(NotificationsTemplateContextValidationError):
await preview_template(rpc_client, ref=ref, context=context)


async def test_preview_paid_template_renders_without_errors(
fake_product_data: dict[str, Any],
rpc_client: RabbitMQRPCClient,
):
ref = TemplateRef(channel=Channel.email, template_name="paid")
context = {
"user": {
"first_name": "Jane",
"last_name": "Doe",
"user_name": "jane_doe",
"email": "jane@example.com",
},
"payment": {
"price_dollars": "25.00",
"osparc_credits": "250.00",
"invoice_url": "https://example.com/invoice/1",
},
"product": fake_product_data,
}

response = await preview_template(rpc_client, ref=ref, context=context)
assert isinstance(response, PreviewTemplateResponse)
assert response.ref.template_name == "paid"
assert isinstance(response.message_content, dict)
assert "25.00" in response.message_content["subject"]
assert "250.00" in response.message_content["subject"]
assert "Jane" in response.message_content["body_text"]


async def test_preview_paid_template_renders_with_optional_fields_missing(
fake_product_data: dict[str, Any],
rpc_client: RabbitMQRPCClient,
):
ref = TemplateRef(channel=Channel.email, template_name="paid")
context = {
"user": {
"user_name": "jdoe",
},
"payment": {
"price_dollars": "10.00",
"osparc_credits": "100.00",
"invoice_url": "https://example.com/invoice/1",
},
"product": fake_product_data,
}

response = await preview_template(rpc_client, ref=ref, context=context)
assert isinstance(response, PreviewTemplateResponse)
assert response.ref.template_name == "paid"
assert "jdoe" in response.message_content["body_text"]


async def test_preview_paid_template_invalid_context(
fake_product_data: dict[str, Any],
rpc_client: RabbitMQRPCClient,
):
ref = TemplateRef(channel=Channel.email, template_name="paid")
# Missing required 'user' and 'payment' fields
context = {
"invalid_key": "invalid_value",
"product": fake_product_data,
}

with pytest.raises(NotificationsTemplateContextValidationError):
await preview_template(rpc_client, ref=ref, context=context)
Loading
Loading