diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 202a5293..cb75f468 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -1,7 +1,7 @@ """Authentication Backends for the Meet core app.""" from django.conf import settings -from django.core.exceptions import SuspiciousOperation +from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.utils.translation import gettext_lazy as _ import requests @@ -10,6 +10,11 @@ ) from core.models import User +from core.services.marketing_service import ( + ContactCreationError, + ContactData, + MarketingService, +) class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): @@ -86,6 +91,10 @@ def get_or_create_user(self, access_token, id_token, payload): password="!", # noqa: S106 **claims, ) + + if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL: + self.signup_to_marketing_email(email) + elif not user: return None @@ -96,6 +105,25 @@ def get_or_create_user(self, access_token, id_token, payload): return user + @staticmethod + def signup_to_marketing_email(email): + """Pragmatic approach to newsletter signup during authentication flow. + + Details: + 1. Uses a very short timeout (1s) to prevent blocking the auth process + 2. Silently fails if the marketing service is down/slow to prioritize user experience + 3. Trade-off: May miss some signups but ensures auth flow remains fast + + Note: For a more robust solution, consider using Async task processing (Celery/Django-Q) + """ + try: + contact_data = ContactData( + email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]} + ) + MarketingService().create_contact(contact_data, timeout=1) + except (ContactCreationError, ImproperlyConfigured): + pass + def get_existing_user(self, sub, email): """Fetch existing user by sub or email.""" try: diff --git a/src/backend/core/services/__init__.py b/src/backend/core/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/services/marketing_service.py b/src/backend/core/services/marketing_service.py new file mode 100644 index 00000000..a67414f7 --- /dev/null +++ b/src/backend/core/services/marketing_service.py @@ -0,0 +1,105 @@ +"""Marketing service in charge of pushing data for marketing automation.""" + +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import brevo_python + +logger = logging.getLogger(__name__) + + +class ContactCreationError(Exception): + """Raised when the contact creation fails.""" + + +@dataclass +class ContactData: + """Data structure for contact information.""" + + email: str + attributes: Optional[Dict[str, str]] = None + list_ids: Optional[List[int]] = None + update_enabled: bool = True + + +class MarketingService: + """Service for managing marketing automation. + + Currently, implements Brevo as our marketing automation platform for: + - Contact list management + - User segmentation + - Marketing campaign automation + - Email communications + + Note: This is an initial implementation focused on Brevo integration. + Future iterations may abstract common marketing operations to support + additional platforms or migration needs. Consider refactoring core + functionality into platform-agnostic interfaces as requirements evolve. + """ + + def __init__(self): + """Initialize the marketing service.""" + + if not settings.BREVO_API_KEY: + raise ImproperlyConfigured("Brevo API key is required") + + configuration = brevo_python.Configuration() + configuration.api_key["api-key"] = settings.BREVO_API_KEY + + self._api_client = brevo_python.ApiClient(configuration) + + def create_contact(self, contact_data: ContactData, timeout=None) -> dict: + """Create or update a contact in Brevo. + + Contacts are automatically added to both a shared list for "La Suite" + and Visio list. Both lists' ids are configured via settings.BREVO_API_CONTACT_LIST_IDS. + Additional list assignments can be specified through contact_data.list_ids. + + Contact attributes are essential for segmentation within lists and power marketing + automation workflows. + + Each product should define its own specific attributes to avoid conflicts. As our Brevo + integration is new, we expect to iterate on which attributes to track based on marketing + automation needs. Attributes must be pre-configured in Brevo's interface. + + Note: Take care when defining new attributes or modifying existing ones, as these + changes can impact marketing workflows and segmentation across products. + """ + + if not settings.BREVO_API_CONTACT_LIST_IDS: + raise ImproperlyConfigured( + "Default Brevo List IDs must be configured in settings." + ) + + contact_api = brevo_python.ContactsApi(self._api_client) + + attributes = { + **settings.BREVO_API_CONTACT_ATTRIBUTES, + **(contact_data.attributes or {}), + } + + list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS + + contact = brevo_python.CreateContact( + email=contact_data.email, + attributes=attributes, + list_ids=list_ids, + update_enabled=contact_data.update_enabled, + ) + + kwargs = {} + + if timeout is not None: + kwargs["_request_timeout"] = timeout + + try: + response = contact_api.create_contact(contact, **kwargs) + except brevo_python.rest.ApiException as err: + logger.exception("Failed to create contact in Brevo") + raise ContactCreationError("Failed to create contact in Brevo") from err + + return response diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index dfaadea5..3160722d 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -457,6 +457,25 @@ class Base(Configuration): None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None ) + # Marketing and communication settings + SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue( + True, + environ_name="SIGNUP_NEW_USERS_TO_NEWSLETTER", + environ_prefix=None, + help_text=( + "When enabled, new users are automatically added to Brevo mailing list " + "for product updates, marketing communications, and customized emails. " + ), + ) + + BREVO_API_KEY = values.Value( + None, environ_name="BREVO_API_KEY", environ_prefix=None + ) + BREVO_API_CONTACT_LIST_IDS = values.ListValue( + [], environ_name="BREVO_API_CONTACT_LIST_IDS", environ_prefix=None + ) + BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True}) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 545b43f1..8c56a6d5 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -26,6 +26,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "boto3==1.35.76", + "brevo-python==1.1.2", "Brotli==1.1.0", "celery[redis]==5.4.0", "django-configurations==2.5.1",