From fdd6a5d8476d0b6533f70b6bd8299566ba359261 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Fri, 7 Mar 2025 07:57:47 -0600 Subject: [PATCH] feat(backend): one click unsub for emails (#9564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per chat with toran, we want one click unsubscribe to work in things like gmail to reduce spam. This implements the one click unsubscribe feature, but it is difficult to test due to not being able to control when it is shown. ### Changes 🏗️ - Adds one click unsub - Casually fix google reauth by checking the correct variables (approved to be in pr by Toran) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Send an email - [x] Open headers on email and pull the link out - [x] `curl -X POST ` and ensure unsubscribed from all messages. Note that we can't control when the box is shown on various providers so we just verify it works on our side for now by checking the headers #### Configuration changes Infra pr coming separately, but we did add the secret default to the .env.example and the docker compose --------- Co-authored-by: Zamil Majdy --- autogpt_platform/backend/.env.example | 1 + .../backend/backend/blocks/google/gmail.py | 5 +- .../backend/backend/blocks/google/sheets.py | 5 +- autogpt_platform/backend/backend/data/user.py | 61 +++++++++++++++++++ .../backend/backend/notifications/email.py | 30 ++++++++- .../backend/notifications/notifications.py | 14 ++++- .../backend/server/v2/postmark/postmark.py | 24 ++++++-- .../backend/backend/util/settings.py | 5 ++ autogpt_platform/docker-compose.platform.yml | 1 + 9 files changed, 132 insertions(+), 14 deletions(-) diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index 1630f79998..9fe12b3050 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -9,6 +9,7 @@ BACKEND_CORS_ALLOW_ORIGINS=["http://localhost:3000"] # generate using `from cryptography.fernet import Fernet;Fernet.generate_key().decode()` ENCRYPTION_KEY='dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw=' +UNSUBSCRIBE_SECRET_KEY = 'HlP8ivStJjmbf6NKi78m_3FnOogut0t5ckzjsIqeaio=' REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/autogpt_platform/backend/backend/blocks/google/gmail.py b/autogpt_platform/backend/backend/blocks/google/gmail.py index d0168e4a82..780cc1b16f 100644 --- a/autogpt_platform/backend/backend/blocks/google/gmail.py +++ b/autogpt_platform/backend/backend/blocks/google/gmail.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.settings import Settings from ._auth import ( GOOGLE_OAUTH_IS_CONFIGURED, @@ -150,8 +151,8 @@ class GmailReadBlock(Block): else None ), token_uri="https://oauth2.googleapis.com/token", - client_id=kwargs.get("client_id"), - client_secret=kwargs.get("client_secret"), + client_id=Settings().secrets.google_client_id, + client_secret=Settings().secrets.google_client_secret, scopes=credentials.scopes, ) return build("gmail", "v1", credentials=creds) diff --git a/autogpt_platform/backend/backend/blocks/google/sheets.py b/autogpt_platform/backend/backend/blocks/google/sheets.py index e7878ff4b6..31ba36eebf 100644 --- a/autogpt_platform/backend/backend/blocks/google/sheets.py +++ b/autogpt_platform/backend/backend/blocks/google/sheets.py @@ -3,6 +3,7 @@ from googleapiclient.discovery import build from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.settings import Settings from ._auth import ( GOOGLE_OAUTH_IS_CONFIGURED, @@ -86,8 +87,8 @@ class GoogleSheetsReadBlock(Block): else None ), token_uri="https://oauth2.googleapis.com/token", - client_id=kwargs.get("client_id"), - client_secret=kwargs.get("client_secret"), + client_id=Settings().secrets.google_client_id, + client_secret=Settings().secrets.google_client_secret, scopes=credentials.scopes, ) return build("sheets", "v4", credentials=creds) diff --git a/autogpt_platform/backend/backend/data/user.py b/autogpt_platform/backend/backend/data/user.py index 72c5aebd36..aa9d67d7d3 100644 --- a/autogpt_platform/backend/backend/data/user.py +++ b/autogpt_platform/backend/backend/data/user.py @@ -1,6 +1,10 @@ +import base64 +import hashlib +import hmac import logging from datetime import datetime, timedelta from typing import Optional, cast +from urllib.parse import quote_plus from autogpt_libs.auth.models import DEFAULT_USER_ID from fastapi import HTTPException @@ -14,6 +18,7 @@ from backend.data.model import UserIntegrations, UserMetadata, UserMetadataRaw from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO from backend.server.v2.store.exceptions import DatabaseError from backend.util.encryption import JSONCryptor +from backend.util.settings import Settings logger = logging.getLogger(__name__) @@ -334,3 +339,59 @@ async def get_user_email_verification(user_id: str) -> bool: raise DatabaseError( f"Failed to get email verification status for user {user_id}: {e}" ) from e + + +def generate_unsubscribe_link(user_id: str) -> str: + """Generate a link to unsubscribe from all notifications""" + # Create an HMAC using a secret key + secret_key = Settings().secrets.unsubscribe_secret_key + signature = hmac.new( + secret_key.encode("utf-8"), user_id.encode("utf-8"), hashlib.sha256 + ).digest() + + # Create a token that combines the user_id and signature + token = base64.urlsafe_b64encode( + f"{user_id}:{signature.hex()}".encode("utf-8") + ).decode("utf-8") + logger.info(f"Generating unsubscribe link for user {user_id}") + + base_url = Settings().config.platform_base_url + return f"{base_url}/api/email/unsubscribe?token={quote_plus(token)}" + + +async def unsubscribe_user_by_token(token: str) -> None: + """Unsubscribe a user from all notifications using the token""" + try: + # Decode the token + decoded = base64.urlsafe_b64decode(token).decode("utf-8") + user_id, received_signature_hex = decoded.split(":", 1) + + # Verify the signature + secret_key = Settings().secrets.unsubscribe_secret_key + expected_signature = hmac.new( + secret_key.encode("utf-8"), user_id.encode("utf-8"), hashlib.sha256 + ).digest() + + if not hmac.compare_digest(expected_signature.hex(), received_signature_hex): + raise ValueError("Invalid token signature") + + user = await get_user_by_id(user_id) + await update_user_notification_preference( + user.id, + NotificationPreferenceDTO( + email=user.email, + daily_limit=0, + preferences={ + NotificationType.AGENT_RUN: False, + NotificationType.ZERO_BALANCE: False, + NotificationType.LOW_BALANCE: False, + NotificationType.BLOCK_EXECUTION_FAILED: False, + NotificationType.CONTINUOUS_AGENT_ERROR: False, + NotificationType.DAILY_SUMMARY: False, + NotificationType.WEEKLY_SUMMARY: False, + NotificationType.MONTHLY_SUMMARY: False, + }, + ), + ) + except Exception as e: + raise DatabaseError(f"Failed to unsubscribe user by token {token}: {e}") from e diff --git a/autogpt_platform/backend/backend/notifications/email.py b/autogpt_platform/backend/backend/notifications/email.py index 043cc9220e..eb64b95d8b 100644 --- a/autogpt_platform/backend/backend/notifications/email.py +++ b/autogpt_platform/backend/backend/notifications/email.py @@ -49,6 +49,7 @@ class EmailSender: notification: NotificationType, user_email: str, data: NotificationEventModel[T_co] | list[NotificationEventModel[T_co]], + user_unsub_link: str | None = None, ): """Send an email to a user using a template pulled from the notification type""" if not self.postmark: @@ -56,20 +57,28 @@ class EmailSender: return template = self._get_template(notification) + base_url = ( + settings.config.frontend_base_url or settings.config.platform_base_url + ) 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://platform.agpt.co/profile/settings", + unsubscribe_link=f"{base_url}/profile/settings", ) except Exception as e: logger.error(f"Error formatting full message: {e}") raise e - self._send_email(user_email, subject, full_message) + self._send_email( + user_email=user_email, + user_unsubscribe_link=user_unsub_link, + subject=subject, + body=full_message, + ) def _get_template(self, notification: NotificationType): # convert the notification type to a notification type override @@ -90,7 +99,13 @@ class EmailSender: base_template=base_template, ) - def _send_email(self, user_email: str, subject: str, body: str): + def _send_email( + self, + user_email: str, + subject: str, + body: str, + user_unsubscribe_link: str | None = None, + ): if not self.postmark: logger.warning("Email tried to send without postmark configured") return @@ -100,4 +115,13 @@ class EmailSender: To=user_email, Subject=subject, HtmlBody=body, + # Headers default to None internally so this is fine + Headers=( + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": f"<{user_unsubscribe_link}>", + } + if user_unsubscribe_link + else None + ), ) diff --git a/autogpt_platform/backend/backend/notifications/notifications.py b/autogpt_platform/backend/backend/notifications/notifications.py index 8e769ebfc4..eb8a7d7d77 100644 --- a/autogpt_platform/backend/backend/notifications/notifications.py +++ b/autogpt_platform/backend/backend/notifications/notifications.py @@ -16,6 +16,7 @@ from backend.data.notifications import ( ) from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig from backend.data.user import ( + generate_unsubscribe_link, get_user_email_by_id, get_user_email_verification, get_user_notification_preference, @@ -175,7 +176,7 @@ class NotificationManager(AppService): self.email_sender.send_templated(event.type, recipient_email, model) return True except Exception as e: - logger.exception(f"Error processing notification: {e}") + logger.exception(f"Error processing notification for admin queue: {e}") return False def _process_immediate(self, message: str) -> bool: @@ -202,10 +203,17 @@ class NotificationManager(AppService): ) return True - self.email_sender.send_templated(event.type, recipient_email, model) + unsub_link = generate_unsubscribe_link(event.user_id) + + self.email_sender.send_templated( + notification=event.type, + user_email=recipient_email, + data=model, + user_unsub_link=unsub_link, + ) return True except Exception as e: - logger.exception(f"Error processing notification: {e}") + logger.exception(f"Error processing notification for immediate queue: {e}") return False def _run_queue( diff --git a/autogpt_platform/backend/backend/server/v2/postmark/postmark.py b/autogpt_platform/backend/backend/server/v2/postmark/postmark.py index e59f73a036..c6348c68a3 100644 --- a/autogpt_platform/backend/backend/server/v2/postmark/postmark.py +++ b/autogpt_platform/backend/backend/server/v2/postmark/postmark.py @@ -2,9 +2,14 @@ import logging from typing import Annotated from autogpt_libs.auth.middleware import APIKeyValidator -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, Query +from fastapi.responses import JSONResponse -from backend.data.user import get_user_by_email, set_user_email_verification +from backend.data.user import ( + get_user_by_email, + set_user_email_verification, + unsubscribe_user_by_token, +) from backend.server.v2.postmark.models import ( PostmarkBounceEnum, PostmarkBounceWebhook, @@ -23,13 +28,24 @@ postmark_validator = APIKeyValidator( settings.secrets.postmark_webhook_token, ) -router = APIRouter(dependencies=[Depends(postmark_validator.get_dependency())]) +router = APIRouter() logger = logging.getLogger(__name__) -@router.post("/") +@router.post("/unsubscribe") +async def unsubscribe_via_one_click(token: Annotated[str, Query()]): + logger.info(f"Received unsubscribe request from One Click Unsubscribe: {token}") + try: + await unsubscribe_user_by_token(token) + except Exception as e: + logger.error(f"Failed to unsubscribe user by token {token}: {e}") + raise e + return JSONResponse(status_code=200, content={"status": "ok"}) + + +@router.post("/", dependencies=[Depends(postmark_validator.get_dependency())]) async def postmark_webhook_handler( webhook: Annotated[ PostmarkWebhook, diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index ca88d127e8..d1f5d72843 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -321,6 +321,11 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): description="The token to use for the Postmark webhook", ) + unsubscribe_secret_key: str = Field( + default="", + description="The secret key to use for the unsubscribe user by token", + ) + # OAuth server credentials for integrations # --8<-- [start:OAuthServerCredentialsExample] github_client_id: str = Field(default="", description="GitHub OAuth client ID") diff --git a/autogpt_platform/docker-compose.platform.yml b/autogpt_platform/docker-compose.platform.yml index 948ade616e..3ffb148c35 100644 --- a/autogpt_platform/docker-compose.platform.yml +++ b/autogpt_platform/docker-compose.platform.yml @@ -92,6 +92,7 @@ services: - FRONTEND_BASE_URL=http://localhost:3000 - BACKEND_CORS_ALLOW_ORIGINS=["http://localhost:3000"] - ENCRYPTION_KEY=dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw= # DO NOT USE IN PRODUCTION!! + - UNSUBSCRIBE_SECRET_KEY=HlP8ivStJjmbf6NKi78m_3FnOogut0t5ckzjsIqeaio= # DO NOT USE IN PRODUCTION!! ports: - "8006:8006" - "8007:8007"