From f0dcf66167b953bf43cfec9286ec95d79af930e2 Mon Sep 17 00:00:00 2001 From: b-saikrishnakanth <130811169+b-saikrishnakanth@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:44:52 +0530 Subject: [PATCH] [WEB-5917] fix: generate clean plain text from HTML email template #8535 --- .../api/plane/bgtasks/analytic_plot_export.py | 4 +- .../plane/bgtasks/email_notification_task.py | 4 +- .../api/plane/bgtasks/forgot_password_task.py | 4 +- .../api/plane/bgtasks/magic_link_code_task.py | 4 +- .../bgtasks/project_add_user_email_task.py | 4 +- .../plane/bgtasks/project_invitation_task.py | 4 +- .../bgtasks/user_activation_email_task.py | 4 +- .../bgtasks/user_deactivation_email_task.py | 4 +- .../plane/bgtasks/user_email_update_task.py | 6 +-- apps/api/plane/bgtasks/webhook_task.py | 4 +- .../bgtasks/workspace_invitation_task.py | 4 +- apps/api/plane/utils/email.py | 42 +++++++++++++++++++ 12 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 apps/api/plane/utils/email.py diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py index e36314af6e..4b0983138b 100644 --- a/apps/api/plane/bgtasks/analytic_plot_export.py +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -13,7 +13,6 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags from django.db.models import Q, Case, Value, When from django.db import models from django.db.models.functions import Concat @@ -22,6 +21,7 @@ from django.db.models.functions import Concat from plane.db.models import Issue from plane.license.utils.instance_value import get_email_configuration from plane.utils.analytics_plot import build_graph_plot +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception from plane.utils.issue_filters import issue_filters from plane.utils.csv_utils import sanitize_csv_row @@ -53,7 +53,7 @@ def send_export_email(email, slug, csv_buffer, rows): """Helper function to send export email.""" subject = "Your Export is ready" html_content = render_to_string("emails/exports/analytics.html", {}) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) csv_buffer.seek(0) diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py index 19db897462..5cf1d52af9 100644 --- a/apps/api/plane/bgtasks/email_notification_task.py +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -15,12 +15,12 @@ from django.template.loader import render_to_string # Django imports from django.utils import timezone -from django.utils.html import strip_tags # Module imports from plane.db.models import EmailNotificationLog, Issue, User from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -260,7 +260,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti "entity_type": "issue", } html_content = render_to_string("emails/notifications/issue-updates.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) try: connection = get_connection( diff --git a/apps/api/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py index 4a66551ed9..9ca0548de2 100644 --- a/apps/api/plane/bgtasks/forgot_password_task.py +++ b/apps/api/plane/bgtasks/forgot_password_task.py @@ -12,10 +12,10 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -45,7 +45,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): html_content = render_to_string("emails/auth/forgot_password.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py index 01adc1bd4a..eef7adea03 100644 --- a/apps/api/plane/bgtasks/magic_link_code_task.py +++ b/apps/api/plane/bgtasks/magic_link_code_task.py @@ -12,10 +12,10 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -37,7 +37,7 @@ def magic_link(email, key, token): context = {"code": token, "email": email} html_content = render_to_string("emails/auth/magic_signin.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py index 83b034bb35..1efe6bc461 100644 --- a/apps/api/plane/bgtasks/project_add_user_email_task.py +++ b/apps/api/plane/bgtasks/project_add_user_email_task.py @@ -11,11 +11,11 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception from plane.db.models import ProjectMember from plane.db.models import User @@ -59,7 +59,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id): # Render the email template html_content = render_to_string("emails/notifications/project_addition.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Initialize the connection connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py index 6876bd9f64..7b47df4a02 100644 --- a/apps/api/plane/bgtasks/project_invitation_task.py +++ b/apps/api/plane/bgtasks/project_invitation_task.py @@ -12,11 +12,11 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.db.models import Project, ProjectMemberInvite, User from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -41,7 +41,7 @@ def project_invitation(email, project_id, token, current_site, invitor): html_content = render_to_string("emails/invitations/project_invitation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) project_member_invite.message = text_content project_member_invite.save() diff --git a/apps/api/plane/bgtasks/user_activation_email_task.py b/apps/api/plane/bgtasks/user_activation_email_task.py index 1f9b46beda..f7a2d3999a 100644 --- a/apps/api/plane/bgtasks/user_activation_email_task.py +++ b/apps/api/plane/bgtasks/user_activation_email_task.py @@ -8,7 +8,6 @@ import logging # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -16,6 +15,7 @@ from celery import shared_task # Module imports from plane.db.models import User from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -31,7 +31,7 @@ def user_activation_email(current_site, user_id): # Send email to user html_content = render_to_string("emails/user/user_activation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Configure email connection from the database ( EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/user_deactivation_email_task.py b/apps/api/plane/bgtasks/user_deactivation_email_task.py index bf27105855..81419606a7 100644 --- a/apps/api/plane/bgtasks/user_deactivation_email_task.py +++ b/apps/api/plane/bgtasks/user_deactivation_email_task.py @@ -8,7 +8,6 @@ import logging # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -16,6 +15,7 @@ from celery import shared_task # Module imports from plane.db.models import User from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -31,7 +31,7 @@ def user_deactivation_email(current_site, user_id): # Send email to user html_content = render_to_string("emails/user/user_deactivation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Configure email connection from the database ( EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/user_email_update_task.py b/apps/api/plane/bgtasks/user_email_update_task.py index ee45f5b74c..48b9c02dba 100644 --- a/apps/api/plane/bgtasks/user_email_update_task.py +++ b/apps/api/plane/bgtasks/user_email_update_task.py @@ -11,10 +11,10 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -36,7 +36,7 @@ def send_email_update_magic_code(email, token): context = {"code": token, "email": email} html_content = render_to_string("emails/auth/magic_signin.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, @@ -87,7 +87,7 @@ def send_email_update_confirmation(email): context = {"email": email} html_content = render_to_string("emails/user/email_updated.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index 5da4587cea..6543c3845b 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -20,7 +20,6 @@ from django.db.models import Prefetch from django.core.mail import EmailMultiAlternatives, get_connection from django.core.serializers.json import DjangoJSONEncoder from django.template.loader import render_to_string -from django.utils.html import strip_tags from django.core.exceptions import ObjectDoesNotExist # Module imports @@ -51,6 +50,7 @@ from plane.db.models import ( IssueAssignee, ) from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception from plane.settings.mongo import MongoConnection @@ -222,7 +222,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", } html_content = render_to_string("emails/notifications/webhook-deactivate.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Set the email connection connection = get_connection( diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py index 9e9a17c2f3..f293cc16f8 100644 --- a/apps/api/plane/bgtasks/workspace_invitation_task.py +++ b/apps/api/plane/bgtasks/workspace_invitation_task.py @@ -11,11 +11,11 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.db.models import User, Workspace, WorkspaceMemberInvite from plane.license.utils.instance_value import get_email_configuration +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -57,7 +57,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): html_content = render_to_string("emails/invitations/workspace_invitation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) workspace_member_invite.message = text_content workspace_member_invite.save() diff --git a/apps/api/plane/utils/email.py b/apps/api/plane/utils/email.py new file mode 100644 index 0000000000..f950e94515 --- /dev/null +++ b/apps/api/plane/utils/email.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +# Python imports +import re + +# Django imports +from django.utils.html import strip_tags + + +def generate_plain_text_from_html(html_content): + """ + Generate clean plain text from HTML email template. + Removes all HTML tags, CSS styles, and excessive whitespace. + + Args: + html_content (str): The HTML content to convert to plain text + + Returns: + str: Clean plain text without HTML tags, styles, or excessive whitespace + """ + # Remove style tags and their content + html_content = re.sub(r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE) + + # Strip HTML tags + text_content = strip_tags(html_content) + + # Remove excessive empty lines + text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content) + + # Ensure there's a leading and trailing whitespace + text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n" + + return text_content