Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
153 changes: 153 additions & 0 deletions backend/app/email_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<tr>
<td style="padding:0 32px 20px;">
<p style="margin:0;padding:16px;background:#f0f4ff;border-left:4px solid #4f46e5;
border-radius:4px;font-size:14px;color:#374151;line-height:1.6;">
{personal_message}
</p>
</td>
</tr>"""

html_body = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{subject}</title>
</head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0"
style="background:#ffffff;border-radius:12px;
box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;max-width:600px;">

<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 100%);
padding:36px 32px;text-align:center;">
<h1 style="margin:0;font-size:24px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">
&#128196; PDF Assistant
</h1>
<p style="margin:8px 0 0;font-size:13px;color:#c7d2fe;">
Workspace Invitation
</p>
</td>
</tr>

<!-- Body -->
<tr>
<td style="padding:36px 32px 20px;">
<h2 style="margin:0 0 12px;font-size:20px;font-weight:600;color:#111827;">
You've been invited!
</h2>
<p style="margin:0;font-size:15px;color:#6b7280;line-height:1.6;">
You have been invited to join the workspace
<strong style="color:#111827;">'{workspace_name}'</strong>
on PDF Assistant. Accept below to start collaborating.
</p>
</td>
</tr>

{personal_block}

<!-- CTA Button -->
<tr>
<td style="padding:8px 32px 32px;text-align:center;">
<a href="{invite_link}"
style="display:inline-block;padding:14px 36px;
background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 100%);
color:#ffffff;font-size:15px;font-weight:600;
text-decoration:none;border-radius:8px;
box-shadow:0 4px 12px rgba(79,70,229,0.4);">
Accept Invitation &#8594;
</a>
<p style="margin:16px 0 0;font-size:12px;color:#9ca3af;">
Or copy this link into your browser:<br/>
<span style="color:#4f46e5;word-break:break-all;">{invite_link}</span>
</p>
</td>
</tr>

<!-- Expiry notice -->
<tr>
<td style="padding:0 32px 24px;">
<p style="margin:0;padding:12px 16px;background:#fef3c7;border-radius:6px;
font-size:13px;color:#92400e;text-align:center;">
&#9203; This invitation expires in <strong>{expires_in_hours} hours</strong>.
</p>
</td>
</tr>

<!-- Footer -->
<tr>
<td style="background:#f9fafb;padding:20px 32px;border-top:1px solid #e5e7eb;
text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6;">
If you did not expect this invitation, you can safely ignore this email.<br/>
This email was sent by PDF Assistant &middot; No reply
</p>
</td>
</tr>

</table>
</td>
</tr>
</table>
</body>
</html>"""

send_email(to, subject, plain_body, html=html_body)
logger.info(
"Workspace invite email dispatched to %s for workspace '%s'",
to,
workspace_name,
)
23 changes: 8 additions & 15 deletions backend/app/routes/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions backend/tests/test_workspace_invite_email.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions backend/tests/test_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading