+
+
|
+
diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index ceb3570343..11383b9589 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -25,6 +25,10 @@ BEHAVE_AS=local PYRO_HOST=localhost SENTRY_DSN= +# Email For Postmark so we can send emails +POSTMARK_SERVER_API_TOKEN= +POSTMARK_SENDER_EMAIL=invalid@invalid.com + ## User auth with Supabase is required for any of the 3rd party integrations with auth to work. ENABLE_AUTH=true SUPABASE_URL=http://localhost:8000 diff --git a/autogpt_platform/backend/backend/data/notifications.py b/autogpt_platform/backend/backend/data/notifications.py index a4549de632..1dbfb8bc4d 100644 --- a/autogpt_platform/backend/backend/data/notifications.py +++ b/autogpt_platform/backend/backend/data/notifications.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from enum import Enum -from typing import Annotated, Generic, Optional, TypeVar, Union +from typing import Annotated, Any, Generic, Optional, TypeVar, Union from prisma import Json from prisma.enums import NotificationType @@ -35,10 +35,10 @@ class BaseNotificationData(BaseModel): class AgentRunData(BaseNotificationData): agent_name: str credits_used: float - # remaining_balance: float execution_time: float - graph_id: str node_count: int = Field(..., description="Number of nodes executed") + graph_id: str + outputs: dict[str, Any] = Field(..., description="Outputs of the agent") class ZeroBalanceData(BaseNotificationData): @@ -203,6 +203,19 @@ class NotificationTypeOverride: NotificationType.MONTHLY_SUMMARY: "monthly_summary.html", }[self.notification_type] + @property + def subject(self) -> str: + return { + NotificationType.AGENT_RUN: "Agent Run Report", + NotificationType.ZERO_BALANCE: "You're out of credits!", + NotificationType.LOW_BALANCE: "Low Balance Warning!", + NotificationType.BLOCK_EXECUTION_FAILED: "Uh oh! Block Execution Failed", + NotificationType.CONTINUOUS_AGENT_ERROR: "Shoot! Continuous Agent Error", + NotificationType.DAILY_SUMMARY: "Here's your daily summary!", + NotificationType.WEEKLY_SUMMARY: "Look at all the cool stuff you did last week!", + NotificationType.MONTHLY_SUMMARY: "We did a lot this month!", + }[self.notification_type] + class NotificationPreference(BaseModel): user_id: str diff --git a/autogpt_platform/backend/backend/data/user.py b/autogpt_platform/backend/backend/data/user.py index f7545b6e5a..f15816d650 100644 --- a/autogpt_platform/backend/backend/data/user.py +++ b/autogpt_platform/backend/backend/data/user.py @@ -49,6 +49,14 @@ async def get_user_by_id(user_id: str) -> User: return User.model_validate(user) +async def get_user_email_by_id(user_id: str) -> str: + try: + user = await prisma.user.find_unique_or_raise(where={"id": user_id}) + return user.email + except Exception as e: + raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e + + async def create_default_user() -> Optional[User]: user = await prisma.user.find_unique(where={"id": DEFAULT_USER_ID}) if not user: diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index 75730b3726..02e1535f5e 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -28,6 +28,7 @@ from backend.data.user import ( get_active_user_ids_in_timerange, get_active_users_ids, get_user_by_id, + get_user_email_by_id, get_user_integrations, get_user_metadata, get_user_notification_preference, @@ -105,6 +106,7 @@ class DatabaseManager(AppService): get_active_user_ids_in_timerange ) get_user_by_id = exposed_run_and_wait(get_user_by_id) + get_user_email_by_id = exposed_run_and_wait(get_user_email_by_id) get_user_notification_preference = exposed_run_and_wait( get_user_notification_preference ) diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 513e384b08..65c84ee1bc 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -206,13 +206,14 @@ def execute_node( # This is fine because for now, there is no block that is charged by time. cost = db_client.spend_credits(data, input_size + output_size, 0) + outputs: dict[str, Any] = {} for output_name, output_data in node_block.execute( input_data, **extra_exec_kwargs ): output_size += len(json.dumps(output_data)) log_metadata.info("Node produced output", **{output_name: output_data}) db_client.upsert_execution_output(node_exec_id, output_name, output_data) - + outputs[output_name] = output_data for execution in _enqueue_next_nodes( db_client=db_client, node=node, @@ -230,6 +231,7 @@ def execute_node( user_id=user_id, type=NotificationType.AGENT_RUN, data=AgentRunData( + outputs=outputs, agent_name=node_block.name, credits_used=cost, execution_time=0, diff --git a/autogpt_platform/backend/backend/notifications/email.py b/autogpt_platform/backend/backend/notifications/email.py new file mode 100644 index 0000000000..a57f5b3a41 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/email.py @@ -0,0 +1,99 @@ +import logging +import pathlib + +from postmarker.core import PostmarkClient +from postmarker.models.emails import EmailManager +from prisma.enums import NotificationType +from pydantic import BaseModel + +from backend.data.notifications import ( + NotificationEventModel, + NotificationTypeOverride, + T_co, +) +from backend.util.settings import Settings +from backend.util.text import TextFormatter + +logger = logging.getLogger(__name__) +settings = Settings() + + +# The following is a workaround to get the type checker to recognize the EmailManager type +# This is a temporary solution and should be removed once the Postmark library is updated +# to support type annotations. +class TypedPostmarkClient(PostmarkClient): + emails: EmailManager + + +class Template(BaseModel): + subject_template: str + body_template: str + base_template: str + + +class EmailSender: + def __init__(self): + if settings.secrets.postmark_server_api_token: + self.postmark = TypedPostmarkClient( + server_token=settings.secrets.postmark_server_api_token + ) + else: + logger.warning( + "Postmark server API token not found, email sending disabled" + ) + self.formatter = TextFormatter() + + def send_templated( + self, + notification: NotificationType, + user_email: str, + data: NotificationEventModel[T_co] | list[NotificationEventModel[T_co]], + ): + """Send an email to a user using a template pulled from the notification type""" + if not self.postmark: + logger.warning("Postmark client not initialized, email not sent") + return + template = self._get_template(notification) + + try: + subject, full_message = self.formatter.format_email( + base_template=template.base_template, + subject_template=template.subject_template, + content_template=template.body_template, + data=data, + unsubscribe_link="https://autogpt.com/unsubscribe", + ) + + except Exception as e: + logger.error(f"Error formatting full message: {e}") + raise e + + self._send_email(user_email, subject, full_message) + + def _get_template(self, notification: NotificationType): + # convert the notification type to a notification type override + notification_type_override = NotificationTypeOverride(notification) + # find the template in templates/name.html (the .template returns with the .html) + template_path = f"templates/{notification_type_override.template}.jinja2" + logger.debug( + f"Template full path: {pathlib.Path(__file__).parent / template_path}" + ) + base_template_path = "templates/base.html.jinja2" + with open(pathlib.Path(__file__).parent / base_template_path, "r") as file: + base_template = file.read() + with open(pathlib.Path(__file__).parent / template_path, "r") as file: + template = file.read() + return Template( + subject_template=notification_type_override.subject, + body_template=template, + base_template=base_template, + ) + + def _send_email(self, user_email: str, subject: str, body: str): + logger.debug(f"Sending email to {user_email} with subject {subject}") + self.postmark.emails.send( + From=settings.config.postmark_sender_email, + To=user_email, + Subject=subject, + HtmlBody=body, + ) diff --git a/autogpt_platform/backend/backend/notifications/notifications.py b/autogpt_platform/backend/backend/notifications/notifications.py index d4e591ac8b..b198b4c3db 100644 --- a/autogpt_platform/backend/backend/notifications/notifications.py +++ b/autogpt_platform/backend/backend/notifications/notifications.py @@ -14,6 +14,7 @@ from backend.data.notifications import ( ) from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig from backend.executor.database import DatabaseManager +from backend.notifications.email import EmailSender from backend.util.service import AppService, expose, get_service_client from backend.util.settings import Settings @@ -100,6 +101,7 @@ class NotificationManager(AppService): self.use_db = True self.rabbitmq_config = create_notification_config() self.running = True + self.email_sender = EmailSender() @classmethod def get_port(cls) -> int: @@ -150,14 +152,27 @@ class NotificationManager(AppService): return NotificationResult(success=False, message=str(e)) async def _process_immediate(self, message: str) -> bool: - """Process a single notification immediately""" + """Process a single notification immediately, returning whether to put into the failed queue""" try: event = NotificationEventDTO.model_validate_json(message) parsed_event = NotificationEventModel[ get_data_type(event.type) ].model_validate_json(message) - # Implementation of actual notification sending would go here - # self.email_sender.send_templated(event.type, event.user_id, parsed_event) + user_email = get_db_client().get_user_email_by_id(event.user_id) + should_send = ( + get_db_client() + .get_user_notification_preference(event.user_id) + .preferences[event.type] + ) + if not user_email: + logger.error(f"User email not found for user {event.user_id}") + return False + if not should_send: + logger.debug( + f"User {event.user_id} does not want to receive {event.type} notifications" + ) + return True + self.email_sender.send_templated(event.type, user_email, parsed_event) logger.info(f"Processing notification: {parsed_event}") return True except Exception as e: diff --git a/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 new file mode 100644 index 0000000000..f4416fa9ff --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 @@ -0,0 +1,75 @@ +{# Agent Run #} +{# Template variables: +data.name: the name of the agent +data.credits_used: the number of credits used by the agent +data.node_count: the number of nodes the agent ran on +data.execution_time: the time it took to run the agent +data.graph_id: the id of the graph the agent ran on +data.outputs: the dict[str, Any] of outputs of the agent +#} +
+ Hi, +
++ We've run your agent {{ data.name }} and it took {{ data.execution_time }} seconds to complete. +
++ It ran on {{ data.node_count }} nodes and used {{ data.credits_used }} credits. +
++ Your feedback has been instrumental in shaping AutoGPT, and we couldn't have + done it without you. We look forward to continuing this journey together as we + bring AI-powered automation to the world. +
++ Thank you again for your time and support. +
\ No newline at end of file diff --git a/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 new file mode 100644 index 0000000000..3ed4035f83 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 @@ -0,0 +1,352 @@ +{# Base Template #} +{# Template variables: + data.message: the message to display in the email + data.title: the title of the email + data.unsubscribe_link: the link to unsubscribe from the email +#} + + + + + + + + + + + + + + +
+
+
|
+