diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py
index 5cf1d52af91..8d34cb2bcca 100644
--- a/apps/api/plane/bgtasks/email_notification_task.py
+++ b/apps/api/plane/bgtasks/email_notification_task.py
@@ -197,7 +197,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
{
"actor_comments": comment,
"actor_detail": {
- "avatar_url": f"{base_api}{actor.avatar_url}",
+ "avatar_url": f"{base_api}{actor.avatar_url}" if actor.avatar_url else None,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
@@ -210,7 +210,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
{
"actor_comments": mention,
"actor_detail": {
- "avatar_url": f"{base_api}{actor.avatar_url}",
+ "avatar_url": f"{base_api}{actor.avatar_url}" if actor.avatar_url else None,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
@@ -224,7 +224,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
template_data.append(
{
"actor_detail": {
- "avatar_url": f"{base_api}{actor.avatar_url}",
+ "avatar_url": f"{base_api}{actor.avatar_url}" if actor.avatar_url else None,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
diff --git a/apps/api/plane/tests/contract/bgtasks/__init__.py b/apps/api/plane/tests/contract/bgtasks/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/api/plane/tests/contract/bgtasks/test_email_notification_avatar.py b/apps/api/plane/tests/contract/bgtasks/test_email_notification_avatar.py
new file mode 100644
index 00000000000..8e4eab0f194
--- /dev/null
+++ b/apps/api/plane/tests/contract/bgtasks/test_email_notification_avatar.py
@@ -0,0 +1,157 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+"""Notification emails must not render a broken avatar for users who have none.
+
+``User.avatar_url`` returns ``None`` when neither ``avatar_asset`` nor ``avatar``
+is set, which is the default for most accounts. The email context was building
+``f"{base_api}{actor.avatar_url}"`` unconditionally, so those users got the
+*truthy* string ``"`` and the initials circle, so a truthy value
+sends it down the image branch — and ``/None`` is served by the SPA catch-all as
+HTML with a 200, so mail clients render a broken-image icon rather than falling
+back.
+
+This asserts the rendered HTML, not the context dict: the bug was only visible
+once the value reached the template's truthiness check.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from plane.bgtasks.email_notification_task import send_email_notification
+from plane.db.models import EmailNotificationLog, Issue, Project, ProjectMember, User
+
+BASE_API = "https://plane.example.com"
+
+
+@pytest.fixture
+def project(db, workspace, create_user):
+ project = Project.objects.create(name="Avatar Test", identifier="AV", workspace=workspace, created_by=create_user)
+ ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
+ return project
+
+
+@pytest.fixture
+def issue(db, project, workspace, create_user):
+ return Issue.objects.create(
+ name="Broken avatar in email", project=project, workspace=workspace, created_by=create_user
+ )
+
+
+@pytest.fixture
+def actor_without_avatar(db):
+ """The default state for a Plane account: no avatar asset, no avatar url."""
+ user = User.objects.create(email="noavatar@example.com", username="noavatar", first_name="Josh", last_name="Gwinn")
+ assert user.avatar_url is None, "fixture premise: this user has no avatar"
+ return user
+
+
+# The guard was applied at three call sites, each reached by a different field:
+# "comment" and "mention" append to the comments block, anything else falls through
+# to the activity-change block. Exercising only one would leave two guards untested.
+ACTOR_DETAIL_PATHS = [
+ pytest.param("comment", "Who is working on this?", id="comment"),
+ pytest.param("mention", "
pinging you on this
", id="mention"), + pytest.param("name", "Renamed work item", id="activity-change"), +] + + +def _render(issue, actor, receiver, field="comment", new_value="Who is working on this?"): + """Run the task with its external edges mocked, returning the HTML it built.""" + log = EmailNotificationLog.objects.create( + entity_identifier=issue.id, + entity_name="issue", + entity="issue", + receiver=receiver, + triggered_by=actor, + data={}, + ) + # Raw shape, as stack_email_notification builds it: the task runs this through + # create_payload first, which folds it into {actor_id: {field: {...}, activity_time}}. + notification_data = { + str(actor.id): [ + { + "issue_activity": { + "field": field, + "old_value": "", + "new_value": new_value, + "activity_time": "2026-08-20T10:43:11", + } + } + ] + } + + redis = MagicMock() + redis.get.return_value = BASE_API.encode() + captured = {} + + class FakeMessage: + def __init__(self, *a, **kw): + pass + + def attach_alternative(self, content, mimetype): + captured["html"] = content + + def send(self): + pass + + with ( + patch("plane.bgtasks.email_notification_task.redis_instance", return_value=redis), + patch("plane.bgtasks.email_notification_task.acquire_lock", return_value=True), + patch("plane.bgtasks.email_notification_task.release_lock", return_value=True), + patch("plane.bgtasks.email_notification_task.EmailMultiAlternatives", FakeMessage), + patch("plane.bgtasks.email_notification_task.get_connection", return_value=MagicMock()), + patch( + "plane.bgtasks.email_notification_task.get_email_configuration", + # (host, user, password, port, use_tls, use_ssl, from) + return_value=("smtp.example.com", "user", "pass", "587", "0", "0", "noreply@example.com"), + ), + ): + send_email_notification( + issue_id=str(issue.id), + notification_data=notification_data, + receiver_id=str(receiver.id), + email_notification_ids=[log.id], + ) + return captured.get("html", "") + + +@pytest.mark.contract +@pytest.mark.django_db +@pytest.mark.parametrize("field,new_value", ACTOR_DETAIL_PATHS) +def test_actor_without_avatar_does_not_get_a_broken_image(issue, actor_without_avatar, create_user, field, new_value): + """No ``