From ac9985321eaa92ac05938dcb7dd6cc936b7dc407 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 30 Jan 2026 10:39:30 +0800 Subject: [PATCH] feat: send email when user mentioned in comment --- api/libs/email_i18n.py | 13 ++ api/services/workflow_comment_service.py | 191 ++++++++++++++---- api/tasks/mail_workflow_comment_task.py | 62 ++++++ ...rkflow_comment_mention_template_en-US.html | 119 +++++++++++ ...rkflow_comment_mention_template_zh-CN.html | 119 +++++++++++ ...rkflow_comment_mention_template_en-US.html | 119 +++++++++++ ...rkflow_comment_mention_template_zh-CN.html | 119 +++++++++++ 7 files changed, 708 insertions(+), 34 deletions(-) create mode 100644 api/tasks/mail_workflow_comment_task.py create mode 100644 api/templates/without-brand/workflow_comment_mention_template_en-US.html create mode 100644 api/templates/without-brand/workflow_comment_mention_template_zh-CN.html create mode 100644 api/templates/workflow_comment_mention_template_en-US.html create mode 100644 api/templates/workflow_comment_mention_template_zh-CN.html diff --git a/api/libs/email_i18n.py b/api/libs/email_i18n.py index 0828cf80bf..1519f07bb1 100644 --- a/api/libs/email_i18n.py +++ b/api/libs/email_i18n.py @@ -37,6 +37,7 @@ class EmailType(StrEnum): ENTERPRISE_CUSTOM = auto() QUEUE_MONITOR_ALERT = auto() DOCUMENT_CLEAN_NOTIFY = auto() + WORKFLOW_COMMENT_MENTION = auto() EMAIL_REGISTER = auto() EMAIL_REGISTER_WHEN_ACCOUNT_EXIST = auto() RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER = auto() @@ -453,6 +454,18 @@ def create_default_email_config() -> EmailI18nConfig: branded_template_path="clean_document_job_mail_template_zh-CN.html", ), }, + EmailType.WORKFLOW_COMMENT_MENTION: { + EmailLanguage.EN_US: EmailTemplate( + subject="You were mentioned in a workflow comment", + template_path="workflow_comment_mention_template_en-US.html", + branded_template_path="without-brand/workflow_comment_mention_template_en-US.html", + ), + EmailLanguage.ZH_HANS: EmailTemplate( + subject="你在工作流评论中被提及", + template_path="workflow_comment_mention_template_zh-CN.html", + branded_template_path="without-brand/workflow_comment_mention_template_zh-CN.html", + ), + }, EmailType.TRIGGER_EVENTS_LIMIT_SANDBOX: { EmailLanguage.EN_US: EmailTemplate( subject="You’ve reached your Sandbox Trigger Events limit", diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index 40e0df7e87..ee483b706c 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -8,8 +8,9 @@ from werkzeug.exceptions import Forbidden, NotFound from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import uuid_value -from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply +from models import App, TenantAccountJoin, WorkflowComment, WorkflowCommentMention, WorkflowCommentReply from models.account import Account +from tasks.mail_workflow_comment_task import send_workflow_comment_mention_email_task logger = logging.getLogger(__name__) @@ -25,6 +26,81 @@ class WorkflowCommentService: if len(content) > 1000: raise ValueError("Comment content cannot exceed 1000 characters") + @staticmethod + def _filter_valid_mentioned_user_ids(mentioned_user_ids: Sequence[str]) -> list[str]: + """Return deduplicated UUID user IDs in the order provided.""" + unique_user_ids: list[str] = [] + seen: set[str] = set() + for user_id in mentioned_user_ids: + if not isinstance(user_id, str): + continue + if not uuid_value(user_id): + continue + if user_id in seen: + continue + seen.add(user_id) + unique_user_ids.append(user_id) + return unique_user_ids + + @staticmethod + def _format_comment_excerpt(content: str, max_length: int = 200) -> str: + """Trim comment content for email display.""" + trimmed = content.strip() + if len(trimmed) <= max_length: + return trimmed + if max_length <= 3: + return trimmed[:max_length] + return f"{trimmed[: max_length - 3].rstrip()}..." + + @staticmethod + def _build_mention_email_payloads( + session: Session, + tenant_id: str, + app_id: str, + mentioner_id: str, + mentioned_user_ids: Sequence[str], + content: str, + ) -> list[dict[str, str]]: + """Prepare email payloads for mentioned users.""" + if not mentioned_user_ids: + return [] + + candidate_user_ids = [user_id for user_id in mentioned_user_ids if user_id != mentioner_id] + if not candidate_user_ids: + return [] + + app_name = session.scalar( + select(App.name).where(App.id == app_id, App.tenant_id == tenant_id) + ) or "Dify app" + commenter_name = session.scalar(select(Account.name).where(Account.id == mentioner_id)) or "Dify user" + comment_excerpt = WorkflowCommentService._format_comment_excerpt(content) + + accounts = session.scalars( + select(Account) + .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id) + .where(TenantAccountJoin.tenant_id == tenant_id, Account.id.in_(candidate_user_ids)) + ).all() + + payloads: list[dict[str, str]] = [] + for account in accounts: + payloads.append( + { + "language": account.interface_language or "en-US", + "to": account.email, + "mentioned_name": account.name or account.email, + "commenter_name": commenter_name, + "app_name": app_name, + "comment_content": comment_excerpt, + } + ) + return payloads + + @staticmethod + def _dispatch_mention_emails(payloads: Sequence[dict[str, str]]) -> None: + """Enqueue mention notification emails.""" + for payload in payloads: + send_workflow_comment_mention_email_task.delay(**payload) + @staticmethod def get_comments(tenant_id: str, app_id: str) -> Sequence[WorkflowComment]: """Get all comments for a workflow.""" @@ -112,7 +188,7 @@ class WorkflowCommentService: position_y: float, mentioned_user_ids: list[str] | None = None, ) -> dict: - """Create a new workflow comment.""" + """Create a new workflow comment and send mention notification emails.""" WorkflowCommentService._validate_content(content) with Session(db.engine) as session: @@ -129,17 +205,26 @@ class WorkflowCommentService: session.flush() # Get the comment ID for mentions # Create mentions if specified - mentioned_user_ids = mentioned_user_ids or [] + mentioned_user_ids = WorkflowCommentService._filter_valid_mentioned_user_ids(mentioned_user_ids or []) for user_id in mentioned_user_ids: - if isinstance(user_id, str) and uuid_value(user_id): - mention = WorkflowCommentMention( - comment_id=comment.id, - reply_id=None, # This is a comment mention, not reply mention - mentioned_user_id=user_id, - ) - session.add(mention) + mention = WorkflowCommentMention( + comment_id=comment.id, + reply_id=None, # This is a comment mention, not reply mention + mentioned_user_id=user_id, + ) + session.add(mention) + + mention_email_payloads = WorkflowCommentService._build_mention_email_payloads( + session=session, + tenant_id=tenant_id, + app_id=app_id, + mentioner_id=created_by, + mentioned_user_ids=mentioned_user_ids, + content=content, + ) session.commit() + WorkflowCommentService._dispatch_mention_emails(mention_email_payloads) # Return only what we need - id and created_at return {"id": comment.id, "created_at": comment.created_at} @@ -155,7 +240,7 @@ class WorkflowCommentService: position_y: float | None = None, mentioned_user_ids: list[str] | None = None, ) -> dict: - """Update a workflow comment.""" + """Update a workflow comment and notify newly mentioned users.""" WorkflowCommentService._validate_content(content) with Session(db.engine, expire_on_commit=False) as session: @@ -188,21 +273,34 @@ class WorkflowCommentService: WorkflowCommentMention.reply_id.is_(None), # Only comment mentions, not reply mentions ) ).all() + existing_mentioned_user_ids = {mention.mentioned_user_id for mention in existing_mentions} for mention in existing_mentions: session.delete(mention) # Add new mentions - mentioned_user_ids = mentioned_user_ids or [] + mentioned_user_ids = WorkflowCommentService._filter_valid_mentioned_user_ids(mentioned_user_ids or []) + new_mentioned_user_ids = [ + user_id for user_id in mentioned_user_ids if user_id not in existing_mentioned_user_ids + ] for user_id_str in mentioned_user_ids: - if isinstance(user_id_str, str) and uuid_value(user_id_str): - mention = WorkflowCommentMention( - comment_id=comment.id, - reply_id=None, # This is a comment mention - mentioned_user_id=user_id_str, - ) - session.add(mention) + mention = WorkflowCommentMention( + comment_id=comment.id, + reply_id=None, # This is a comment mention + mentioned_user_id=user_id_str, + ) + session.add(mention) + + mention_email_payloads = WorkflowCommentService._build_mention_email_payloads( + session=session, + tenant_id=tenant_id, + app_id=app_id, + mentioner_id=user_id, + mentioned_user_ids=new_mentioned_user_ids, + content=content, + ) session.commit() + WorkflowCommentService._dispatch_mention_emails(mention_email_payloads) return {"id": comment.id, "updated_at": comment.updated_at} @@ -252,7 +350,7 @@ class WorkflowCommentService: def create_reply( comment_id: str, content: str, created_by: str, mentioned_user_ids: list[str] | None = None ) -> dict: - """Add a reply to a workflow comment.""" + """Add a reply to a workflow comment and notify mentioned users.""" WorkflowCommentService._validate_content(content) with Session(db.engine, expire_on_commit=False) as session: @@ -267,22 +365,31 @@ class WorkflowCommentService: session.flush() # Get the reply ID for mentions # Create mentions if specified - mentioned_user_ids = mentioned_user_ids or [] + mentioned_user_ids = WorkflowCommentService._filter_valid_mentioned_user_ids(mentioned_user_ids or []) for user_id in mentioned_user_ids: - if isinstance(user_id, str) and uuid_value(user_id): - # Create mention linking to specific reply - mention = WorkflowCommentMention( - comment_id=comment_id, reply_id=reply.id, mentioned_user_id=user_id - ) - session.add(mention) + # Create mention linking to specific reply + mention = WorkflowCommentMention( + comment_id=comment_id, reply_id=reply.id, mentioned_user_id=user_id + ) + session.add(mention) + + mention_email_payloads = WorkflowCommentService._build_mention_email_payloads( + session=session, + tenant_id=comment.tenant_id, + app_id=comment.app_id, + mentioner_id=created_by, + mentioned_user_ids=mentioned_user_ids, + content=content, + ) session.commit() + WorkflowCommentService._dispatch_mention_emails(mention_email_payloads) return {"id": reply.id, "created_at": reply.created_at} @staticmethod def update_reply(reply_id: str, user_id: str, content: str, mentioned_user_ids: list[str] | None = None) -> dict: - """Update a comment reply.""" + """Update a comment reply and notify newly mentioned users.""" WorkflowCommentService._validate_content(content) with Session(db.engine, expire_on_commit=False) as session: @@ -300,20 +407,36 @@ class WorkflowCommentService: existing_mentions = session.scalars( select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id) ).all() + existing_mentioned_user_ids = {mention.mentioned_user_id for mention in existing_mentions} for mention in existing_mentions: session.delete(mention) # Add mentions - mentioned_user_ids = mentioned_user_ids or [] + mentioned_user_ids = WorkflowCommentService._filter_valid_mentioned_user_ids(mentioned_user_ids or []) + new_mentioned_user_ids = [ + user_id for user_id in mentioned_user_ids if user_id not in existing_mentioned_user_ids + ] for user_id_str in mentioned_user_ids: - if isinstance(user_id_str, str) and uuid_value(user_id_str): - mention = WorkflowCommentMention( - comment_id=reply.comment_id, reply_id=reply.id, mentioned_user_id=user_id_str - ) - session.add(mention) + mention = WorkflowCommentMention( + comment_id=reply.comment_id, reply_id=reply.id, mentioned_user_id=user_id_str + ) + session.add(mention) + + mention_email_payloads: list[dict[str, str]] = [] + comment = session.get(WorkflowComment, reply.comment_id) + if comment: + mention_email_payloads = WorkflowCommentService._build_mention_email_payloads( + session=session, + tenant_id=comment.tenant_id, + app_id=comment.app_id, + mentioner_id=user_id, + mentioned_user_ids=new_mentioned_user_ids, + content=content, + ) session.commit() session.refresh(reply) # Refresh to get updated timestamp + WorkflowCommentService._dispatch_mention_emails(mention_email_payloads) return {"id": reply.id, "updated_at": reply.updated_at} diff --git a/api/tasks/mail_workflow_comment_task.py b/api/tasks/mail_workflow_comment_task.py new file mode 100644 index 0000000000..d80db7d55f --- /dev/null +++ b/api/tasks/mail_workflow_comment_task.py @@ -0,0 +1,62 @@ +import logging +import time + +import click +from celery import shared_task + +from extensions.ext_mail import mail +from libs.email_i18n import EmailType, get_email_i18n_service + +logger = logging.getLogger(__name__) + + +@shared_task(queue="mail") +def send_workflow_comment_mention_email_task( + language: str, + to: str, + mentioned_name: str, + commenter_name: str, + app_name: str, + comment_content: str, +): + """ + Send workflow comment mention email with internationalization support. + + Args: + language: Language code for email localization + to: Recipient email address + mentioned_name: Name of the mentioned user + commenter_name: Name of the comment author + app_name: Name of the app where the comment was made + comment_content: Comment content excerpt + """ + if not mail.is_inited(): + return + + logger.info(click.style(f"Start workflow comment mention mail to {to}", fg="green")) + start_at = time.perf_counter() + + try: + email_service = get_email_i18n_service() + email_service.send_email( + email_type=EmailType.WORKFLOW_COMMENT_MENTION, + language_code=language, + to=to, + template_context={ + "to": to, + "mentioned_name": mentioned_name, + "commenter_name": commenter_name, + "app_name": app_name, + "comment_content": comment_content, + }, + ) + + end_at = time.perf_counter() + logger.info( + click.style( + f"Send workflow comment mention mail to {to} succeeded: latency: {end_at - start_at}", + fg="green", + ) + ) + except Exception: + logger.exception("workflow comment mention email to %s failed", to) diff --git a/api/templates/without-brand/workflow_comment_mention_template_en-US.html b/api/templates/without-brand/workflow_comment_mention_template_en-US.html new file mode 100644 index 0000000000..dac52eb293 --- /dev/null +++ b/api/templates/without-brand/workflow_comment_mention_template_en-US.html @@ -0,0 +1,119 @@ + + + + + + + + +
+
+ Dify Logo +
+

You were mentioned in a workflow comment

+
+

Hi {{ mentioned_name }},

+

{{ commenter_name }} mentioned you in {{ app_name }}.

+
+
+

{{ comment_content }}

+
+

Open {{ application_title }} to reply to the comment.

+
+ + + diff --git a/api/templates/without-brand/workflow_comment_mention_template_zh-CN.html b/api/templates/without-brand/workflow_comment_mention_template_zh-CN.html new file mode 100644 index 0000000000..beedd59c6f --- /dev/null +++ b/api/templates/without-brand/workflow_comment_mention_template_zh-CN.html @@ -0,0 +1,119 @@ + + + + + + + + +
+
+ Dify Logo +
+

你在工作流评论中被提及

+
+

你好,{{ mentioned_name }}:

+

{{ commenter_name }} 在 {{ app_name }} 中提及了你。

+
+
+

{{ comment_content }}

+
+

请在 {{ application_title }} 中查看并回复此评论。

+
+ + + diff --git a/api/templates/workflow_comment_mention_template_en-US.html b/api/templates/workflow_comment_mention_template_en-US.html new file mode 100644 index 0000000000..dac52eb293 --- /dev/null +++ b/api/templates/workflow_comment_mention_template_en-US.html @@ -0,0 +1,119 @@ + + + + + + + + +
+
+ Dify Logo +
+

You were mentioned in a workflow comment

+
+

Hi {{ mentioned_name }},

+

{{ commenter_name }} mentioned you in {{ app_name }}.

+
+
+

{{ comment_content }}

+
+

Open {{ application_title }} to reply to the comment.

+
+ + + diff --git a/api/templates/workflow_comment_mention_template_zh-CN.html b/api/templates/workflow_comment_mention_template_zh-CN.html new file mode 100644 index 0000000000..beedd59c6f --- /dev/null +++ b/api/templates/workflow_comment_mention_template_zh-CN.html @@ -0,0 +1,119 @@ + + + + + + + + +
+
+ Dify Logo +
+

你在工作流评论中被提及

+
+

你好,{{ mentioned_name }}:

+

{{ commenter_name }} 在 {{ app_name }} 中提及了你。

+
+
+

{{ comment_content }}

+
+

请在 {{ application_title }} 中查看并回复此评论。

+
+ + +