From d514e523be10fdd6312029eae0e8282031231c87 Mon Sep 17 00:00:00 2001 From: Nancy <9d.24.nancy.sangani@gmail.com> Date: Mon, 22 Jun 2026 13:41:28 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(email):=20add=20HTML=20workspace=20inv?= =?UTF-8?q?ite=20email=20=E2=80=94=20closes=20#442?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/email_service.py | 153 +++++++++++++++++++ backend/app/routes/workspaces.py | 23 +-- backend/tests/test_workspace_invite_email.py | 120 +++++++++++++++ 3 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_workspace_invite_email.py diff --git a/backend/app/email_service.py b/backend/app/email_service.py index 47d1bd5f..6fcb858a 100644 --- a/backend/app/email_service.py +++ b/backend/app/email_service.py @@ -40,3 +40,156 @@ def send_email(to: str, subject: str, body: str, html: str | None = None) -> Non subject, body, ) + + +def send_workspace_invite_email( + to: str, + workspace_name: str, + invite_link: str, + expires_in_hours: int, + personal_message: str | None = None, +) -> None: + """Send a workspace invitation email with an HTML body. + + Builds a branded HTML email containing the workspace name, an optional + personal message from the inviter, a prominent call-to-action button with + the acceptance link, and an expiry notice. Falls back to plain-text if HTML + is not supported by the client. Delegates delivery to :func:`send_email`. + + Args: + to: Recipient email address. + workspace_name: Name of the workspace the recipient is invited to. + invite_link: Fully-qualified URL the recipient must visit to accept. + expires_in_hours: How many hours until the invite token expires. + personal_message: Optional personal note from the inviting admin. + """ + subject = f"You're invited to join workspace '{workspace_name}'" + + # ── Plain-text fallback ─────────────────────────────────────────────── + plain_lines = [ + "Hello,", + "", + f"You have been invited to join the workspace '{workspace_name}'.", + ] + if personal_message: + plain_lines += ["", personal_message] + plain_lines += [ + "", + "Accept your invitation by visiting the link below:", + invite_link, + "", + f"This invitation expires in {expires_in_hours} hours.", + "", + "If you did not expect this email, you can safely ignore it.", + ] + plain_body = "\n".join(plain_lines) + + # ── HTML body ───────────────────────────────────────────────────────── + personal_block = "" + if personal_message: + personal_block = f""" + + +

+ {personal_message} +

+ + """ + + html_body = f""" + + + + + {subject} + + + + + + +
+ + + + + + + + + + + + + {personal_block} + + + + + + + + + + + + + + + + +
+

+ 📄 PDF Assistant +

+

+ Workspace Invitation +

+
+

+ You've been invited! +

+

+ You have been invited to join the workspace + '{workspace_name}' + on PDF Assistant. Accept below to start collaborating. +

+
+ + Accept Invitation → + +

+ Or copy this link into your browser:
+ {invite_link} +

+
+

+ ⏳ This invitation expires in {expires_in_hours} hours. +

+
+

+ If you did not expect this invitation, you can safely ignore this email.
+ This email was sent by PDF Assistant · No reply +

+
+
+ +""" + + send_email(to, subject, plain_body, html=html_body) + logger.info( + "Workspace invite email dispatched to %s for workspace '%s'", + to, + workspace_name, + ) diff --git a/backend/app/routes/workspaces.py b/backend/app/routes/workspaces.py index e6f09566..50ce268a 100644 --- a/backend/app/routes/workspaces.py +++ b/backend/app/routes/workspaces.py @@ -13,7 +13,7 @@ from app.auth import create_invite_token, get_admin_user, get_current_user from app.config import get_settings from app.database import get_db -from app.email_service import send_email +from app.email_service import send_workspace_invite_email from app.exceptions import ( ConflictException, ForbiddenException, @@ -107,20 +107,13 @@ def invite_workspace( db.refresh(invitation) join_link = f"{settings.APP_URL.rstrip('/')}/invite?token={quote(token, safe='')}" - subject = f"Invitation to join workspace '{payload.workspace_name}'" - body_lines = [ - "Hello,", - "", - f"You have been invited to join the workspace '{payload.workspace_name}'.", - "Click the link below to accept the invitation:", - join_link, - ] - if payload.message: - body_lines.insert(3, payload.message) - body_lines.insert(4, "") - body = "\n".join(body_lines) - - send_email(payload.email, subject, body) + send_workspace_invite_email( + to=payload.email, + workspace_name=payload.workspace_name, + invite_link=join_link, + expires_in_hours=settings.INVITE_TOKEN_EXPIRY_HOURS, + personal_message=payload.message or None, + ) return WorkspaceInviteResponse( email=payload.email, diff --git a/backend/tests/test_workspace_invite_email.py b/backend/tests/test_workspace_invite_email.py new file mode 100644 index 00000000..aa4cceb2 --- /dev/null +++ b/backend/tests/test_workspace_invite_email.py @@ -0,0 +1,120 @@ +""" +Tests for send_workspace_invite_email — issue #442. +""" +from unittest.mock import call, patch + +import pytest + +from app.email_service import send_workspace_invite_email + + +WORKSPACE = "Research Team" +INVITE_LINK = "http://localhost:3000/invite?token=abc123" +EXPIRES = 72 +RECIPIENT = "newuser@example.com" + + +class TestSendWorkspaceInviteEmail: + def test_delegates_to_send_email(self): + """send_workspace_invite_email must call send_email exactly once.""" + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + mock_send.assert_called_once() + + def test_subject_contains_workspace_name(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + _to, subject, _body = mock_send.call_args.args + assert WORKSPACE in subject + + def test_html_body_contains_invite_link(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + html = mock_send.call_args.kwargs.get("html") or "" + assert INVITE_LINK in html + + def test_html_body_contains_workspace_name(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + html = mock_send.call_args.kwargs.get("html") or "" + assert WORKSPACE in html + + def test_html_body_contains_expiry(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + html = mock_send.call_args.kwargs.get("html") or "" + assert str(EXPIRES) in html + + def test_personal_message_included_in_html(self): + message = "Looking forward to working with you!" + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + personal_message=message, + ) + html = mock_send.call_args.kwargs.get("html") or "" + assert message in html + + def test_no_personal_message_omits_block(self): + """Without a personal_message the optional block must not appear.""" + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + personal_message=None, + ) + html = mock_send.call_args.kwargs.get("html") or "" + # The placeholder block rendered for None should be empty / absent + assert "border-left:4px solid #4f46e5" not in html + + def test_plain_text_body_contains_invite_link(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + _to, _subject, plain_body = mock_send.call_args.args + assert INVITE_LINK in plain_body + + def test_recipient_passed_to_send_email(self): + with patch("app.email_service.send_email") as mock_send: + send_workspace_invite_email( + to=RECIPIENT, + workspace_name=WORKSPACE, + invite_link=INVITE_LINK, + expires_in_hours=EXPIRES, + ) + to_arg = mock_send.call_args.args[0] + assert to_arg == RECIPIENT From 93e4d243246d4ce9cc973853d1bb6257566d953b Mon Sep 17 00:00:00 2001 From: Nancy <9d.24.nancy.sangani@gmail.com> Date: Mon, 22 Jun 2026 13:54:44 +0530 Subject: [PATCH 2/3] =?UTF-8?q?feat(email):=20add=20HTML=20workspace=20inv?= =?UTF-8?q?ite=20email=20=E2=80=94=20closes=20#442?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_workspaces.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_workspaces.py b/backend/tests/test_workspaces.py index effdbe3b..35861438 100644 --- a/backend/tests/test_workspaces.py +++ b/backend/tests/test_workspaces.py @@ -27,12 +27,12 @@ def test_workspace_invite_creates_invitation_and_sends_email(client, db_session, sent = {} - def fake_send_email(to, subject, body, html=None): + def fake_send_workspace_invite_email(to, workspace_name, invite_link, expires_in_hours, personal_message=None): sent["to"] = to - sent["subject"] = subject - sent["body"] = body + sent["workspace_name"] = workspace_name + sent["invite_link"] = invite_link - monkeypatch.setattr("app.routes.workspaces.send_email", fake_send_email) + monkeypatch.setattr("app.routes.workspaces.send_workspace_invite_email", fake_send_workspace_invite_email) token = create_access_token(admin.id) response = client.post( @@ -49,7 +49,8 @@ def fake_send_email(to, subject, body, html=None): assert payload["invite_link"].startswith("http") assert "token=" in payload["invite_link"] assert sent["to"] == "invitee@example.com" - assert "Invitation to join workspace" in sent["subject"] + assert sent["workspace_name"] == payload["workspace_name"] + assert token in sent["invite_link"] invitation = db_session.query(WorkspaceInvitation).filter_by(email="invitee@example.com").first() assert invitation is not None From 6864b1e85a5c1fdf9e3045758275a15a98de6268 Mon Sep 17 00:00:00 2001 From: Nancy <9d.24.nancy.sangani@gmail.com> Date: Mon, 22 Jun 2026 14:00:05 +0530 Subject: [PATCH 3/3] =?UTF-8?q?feat(email):=20add=20HTML=20workspace=20inv?= =?UTF-8?q?ite=20email=20=E2=80=94=20closes=20#442?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_workspaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_workspaces.py b/backend/tests/test_workspaces.py index 35861438..6a5085f0 100644 --- a/backend/tests/test_workspaces.py +++ b/backend/tests/test_workspaces.py @@ -50,7 +50,7 @@ def fake_send_workspace_invite_email(to, workspace_name, invite_link, expires_in assert "token=" in payload["invite_link"] assert sent["to"] == "invitee@example.com" assert sent["workspace_name"] == payload["workspace_name"] - assert token in sent["invite_link"] + assert "token=" in sent["invite_link"] invitation = db_session.query(WorkspaceInvitation).filter_by(email="invitee@example.com").first() assert invitation is not None