diff --git a/backend/app/email_service.py b/backend/app/email_service.py
index 47d1bd5..6fcb858 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}
+
+
+
+
+
+
+
+
+
+
+
+ 📄 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.
+
+ |
+
+
+ {personal_block}
+
+
+
+ |
+
+ 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 e6f0956..50ce268 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 0000000..aa4cceb
--- /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
diff --git a/backend/tests/test_workspaces.py b/backend/tests/test_workspaces.py
index effdbe3..6a5085f 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