diff --git a/docker/config/server.env.template b/docker/config/server.env.template
index 450bac549b..33837a4019 100644
--- a/docker/config/server.env.template
+++ b/docker/config/server.env.template
@@ -9,4 +9,10 @@ FXA_CLIENT_ID=727f0251c388a993
FXA_SECRET_KEY=e43fd751ca5687d28288098e3e9b1294792ed9954008388e39b1cdaac0a1ebd6
FXA_OAUTH_ENDPOINT=https://oauth.stage.mozaws.net/v1
FXA_PROFILE_ENDPOINT=https://profile.stage.mozaws.net/v1
+
+EMAIL_CONSENT_ENABLED = False
+EMAIL_COMMUNICATIONS_MAIN_TEXT = Want to stay up to date and informed about all localization matters at Mozilla? Just hit the button below to get the latest updates, announcements about new Pontoon features, invitations to contributor events and more. We won’t spam you — promise!
+EMAIL_COMMUNICATIONS_PRIVACY_NOTICE = By enabling email communications, I agree to Mozilla handling my personal information as explained in this Privacy Notice.
+EMAIL_COMMUNICATIONS_HELP_TEXT = Get the latest updates about localization at Mozilla, announcements about new Pontoon features, invitations to contributor events and more.
By enabling email communications, I agree to Mozilla handling my personal information as explained in this Privacy Notice.
+
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
diff --git a/docs/admin/deployment.rst b/docs/admin/deployment.rst
index d164949750..04f645b2ae 100644
--- a/docs/admin/deployment.rst
+++ b/docs/admin/deployment.rst
@@ -108,7 +108,7 @@ you create:
Adds some additional django apps that can be helpful during day to day development.
``EMAIL_HOST``
- SMTP host (default: ``'smtp.sendgrid.net'``)
+ SMTP host (default: ``'smtp.sendgrid.net'``).
``EMAIL_HOST_PASSWORD``
Password for the SMTP connection.
@@ -117,13 +117,23 @@ you create:
Username for the SMTP connection (default: ``'apikey'``).
``EMAIL_PORT``
- SMTP port (default: ``587``)
+ SMTP port (default: ``587``).
``EMAIL_USE_TLS``
- Use explicit TLS for the SMTP connection (default: ``True``)
+ Use explicit TLS for the SMTP connection (default: ``True``).
``EMAIL_USE_SSL``
- Use implicit TLS for the SMTP connection (default: ``False``)
+ Use implicit TLS for the SMTP connection (default: ``False``).
+
+``EMAIL_CONSENT_ENABLED``
+ Enables Email consent page (default: ``False``).
+
+``EMAIL_COMMUNICATIONS_MAIN_TEXT``
+ Optional. Main text to use on the Email consent page, possibly explaining what type
+ of communication to expect.
+
+``EMAIL_COMMUNICATIONS_PRIVACY_NOTICE``
+ Optional. Text and link to the privacy notice, used on the Email consent page.
``EMAIL_COMMUNICATIONS_HELP_TEXT``
Optional. Help text to use under the Email communications checkbox in user settings.
diff --git a/pontoon/base/middleware.py b/pontoon/base/middleware.py
index aa73ca92cc..36e86d08fc 100644
--- a/pontoon/base/middleware.py
+++ b/pontoon/base/middleware.py
@@ -1,7 +1,10 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponseForbidden
+from django.shortcuts import redirect
+from django.urls import reverse
from django.utils.deprecation import MiddlewareMixin
+from pontoon.base.utils import is_ajax
from raygun4py.middleware.django import Provider
@@ -34,3 +37,25 @@ def process_request(self, request):
return HttpResponseForbidden("
Forbidden
")
return None
+
+
+class EmailConsentMiddleware(MiddlewareMixin):
+ def process_request(self, request):
+ if not settings.EMAIL_CONSENT_ENABLED:
+ return None
+
+ if not request.user.is_authenticated:
+ return None
+
+ if request.user.profile.email_consent_dismissed_at is not None:
+ return None
+
+ if is_ajax(request):
+ return None
+
+ email_consent_url = "pontoon.messaging.email_consent"
+ if request.path == reverse(email_consent_url):
+ return None
+
+ request.session["next_path"] = request.get_full_path()
+ return redirect(email_consent_url)
diff --git a/pontoon/base/migrations/0062_userprofile_email_consent_dismissed_at.py b/pontoon/base/migrations/0062_userprofile_email_consent_dismissed_at.py
new file mode 100644
index 0000000000..3a3fed8645
--- /dev/null
+++ b/pontoon/base/migrations/0062_userprofile_email_consent_dismissed_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.11 on 2024-05-15 16:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("base", "0061_userprofile_email_communications_enabled"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="userprofile",
+ name="email_consent_dismissed_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/pontoon/base/models/user_profile.py b/pontoon/base/models/user_profile.py
index fe12d20db7..2062bfcaee 100644
--- a/pontoon/base/models/user_profile.py
+++ b/pontoon/base/models/user_profile.py
@@ -19,6 +19,7 @@ class UserProfile(models.Model):
contact_email = models.EmailField("Contact email address", blank=True, null=True)
contact_email_verified = models.BooleanField(default=False)
email_communications_enabled = models.BooleanField(default=False)
+ email_consent_dismissed_at = models.DateTimeField(null=True, blank=True)
# Theme
class Themes(models.TextChoices):
diff --git a/pontoon/messaging/__init__.py b/pontoon/messaging/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/pontoon/messaging/static/css/email_consent.css b/pontoon/messaging/static/css/email_consent.css
new file mode 100644
index 0000000000..15624924de
--- /dev/null
+++ b/pontoon/messaging/static/css/email_consent.css
@@ -0,0 +1,75 @@
+body > header {
+ background: transparent;
+ border-color: transparent;
+ position: fixed;
+ width: 100%;
+ z-index: 10;
+}
+
+body > header.menu-opened {
+ border-color: var(--main-border-1);
+}
+
+#main section {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+
+ background-image: var(--homepage-background-image);
+ background-attachment: fixed;
+ background-size: cover;
+}
+
+#main section .container {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: start;
+ justify-content: center;
+ overflow: hidden;
+}
+
+#main .buttons {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+#main .button {
+ background: transparent;
+ color: var(--white-1);
+ display: flex;
+ font-size: 16px;
+ border-radius: 2px;
+ width: 120px;
+ height: 40px;
+ justify-content: center;
+ align-items: center;
+ font-weight: 400;
+}
+
+#main .button.enable {
+ background-color: var(--status-translated);
+ color: var(--homepage-tour-button-color);
+ width: 320px;
+}
+
+#main h1 {
+ font-size: 64px;
+ margin-bottom: 10px;
+}
+
+#main p {
+ font-size: 22px;
+ font-weight: 300;
+ line-height: 36px;
+ margin-bottom: 60px;
+ width: 900px;
+}
+
+#main p.privacy-notice {
+ font-size: 14px;
+}
+
+#main p.privacy-notice a {
+ color: var(--status-translated);
+}
diff --git a/pontoon/messaging/static/js/email_consent.js b/pontoon/messaging/static/js/email_consent.js
new file mode 100644
index 0000000000..e4c0ec28bd
--- /dev/null
+++ b/pontoon/messaging/static/js/email_consent.js
@@ -0,0 +1,26 @@
+$(function () {
+ // Toggle user profile attribute
+ $('.buttons .button').click(function (e) {
+ e.preventDefault();
+ const self = $(this);
+
+ $.ajax({
+ url: '/dismiss-email-consent/',
+ type: 'POST',
+ data: {
+ csrfmiddlewaretoken: $('body').data('csrf'),
+ value: self.is('.enable'),
+ },
+ success: function (data) {
+ window.location.href = data.next;
+ },
+ error: function (request) {
+ if (request.responseText === 'error') {
+ Pontoon.endLoader('Oops, something went wrong.', 'error');
+ } else {
+ Pontoon.endLoader(request.responseText, 'error');
+ }
+ },
+ });
+ });
+});
diff --git a/pontoon/messaging/templates/messaging/email_consent.html b/pontoon/messaging/templates/messaging/email_consent.html
new file mode 100644
index 0000000000..525f894b8b
--- /dev/null
+++ b/pontoon/messaging/templates/messaging/email_consent.html
@@ -0,0 +1,29 @@
+{% extends 'base.html' %}
+
+{% block title %}Email consent{% endblock %}
+
+{% block class %}email-consent{% endblock %}
+
+{% block middle %}
+
+
+
+
Let’s keep in touch
+
{{ settings.EMAIL_COMMUNICATIONS_MAIN_TEXT|safe }}
+
+
{{ settings.EMAIL_COMMUNICATIONS_PRIVACY_NOTICE|safe }}
+
+
+
+{% endblock %}
+
+{% block extend_css %}
+ {% stylesheet 'email_consent' %}
+{% endblock %}
+
+{% block extend_js %}
+ {% javascript 'email_consent' %}
+{% endblock %}
diff --git a/pontoon/messaging/urls.py b/pontoon/messaging/urls.py
new file mode 100644
index 0000000000..0faf2ffa5c
--- /dev/null
+++ b/pontoon/messaging/urls.py
@@ -0,0 +1,17 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ # Email consent
+ path(
+ "email-consent/",
+ views.email_consent,
+ name="pontoon.messaging.email_consent",
+ ),
+ path(
+ "dismiss-email-consent/",
+ views.dismiss_email_consent,
+ name="pontoon.messaging.dismiss_email_consent",
+ ),
+]
diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py
new file mode 100644
index 0000000000..43e2540e65
--- /dev/null
+++ b/pontoon/messaging/views.py
@@ -0,0 +1,43 @@
+import json
+
+from django.contrib.auth.decorators import login_required
+from django.db import transaction
+from django.http import JsonResponse
+from django.shortcuts import render
+from django.utils import timezone
+from django.views.decorators.http import require_POST
+
+
+def email_consent(request):
+ return render(
+ request,
+ "messaging/email_consent.html",
+ )
+
+
+@login_required(redirect_field_name="", login_url="/403")
+@require_POST
+@transaction.atomic
+def dismiss_email_consent(request):
+ value = request.POST.get("value", None)
+
+ if not value:
+ return JsonResponse(
+ {
+ "status": False,
+ "message": "Bad Request: Value not set",
+ },
+ status=400,
+ )
+
+ profile = request.user.profile
+ profile.email_communications_enabled = json.loads(value)
+ profile.email_consent_dismissed_at = timezone.now()
+ profile.save()
+
+ return JsonResponse(
+ {
+ "status": True,
+ "next": request.session.get("next_path", "/"),
+ }
+ )
diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py
index 3b0b23a1ee..59fa36f57b 100644
--- a/pontoon/settings/base.py
+++ b/pontoon/settings/base.py
@@ -212,6 +212,11 @@ def _default_from_email():
EMAIL_HOST_PASSWORD = os.environ.get(
"EMAIL_HOST_PASSWORD", os.environ.get("SENDGRID_PASSWORD", "")
)
+EMAIL_CONSENT_ENABLED = os.environ.get("EMAIL_CONSENT_ENABLED", "False") != "False"
+EMAIL_COMMUNICATIONS_MAIN_TEXT = os.environ.get("EMAIL_COMMUNICATIONS_MAIN_TEXT", "")
+EMAIL_COMMUNICATIONS_PRIVACY_NOTICE = os.environ.get(
+ "EMAIL_COMMUNICATIONS_PRIVACY_NOTICE", ""
+)
EMAIL_COMMUNICATIONS_HELP_TEXT = os.environ.get("EMAIL_COMMUNICATIONS_HELP_TEXT", "")
# Log emails to console if the SendGrid credentials are missing.
@@ -233,6 +238,7 @@ def _default_from_email():
"pontoon.insights",
"pontoon.localizations",
"pontoon.machinery",
+ "pontoon.messaging",
"pontoon.projects",
"pontoon.sync",
"pontoon.tags",
@@ -287,6 +293,7 @@ def _default_from_email():
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"csp.middleware.CSPMiddleware",
+ "pontoon.base.middleware.EmailConsentMiddleware",
)
CONTEXT_PROCESSORS = (
@@ -511,6 +518,10 @@ def _default_from_email():
"source_filenames": ("css/homepage.css",),
"output_filename": "css/homepage.min.css",
},
+ "email_consent": {
+ "source_filenames": ("css/email_consent.css",),
+ "output_filename": "css/email_consent.min.css",
+ },
}
PIPELINE_JS = {
@@ -645,6 +656,10 @@ def _default_from_email():
"source_filenames": ("js/homepage.js",),
"output_filename": "js/homepage.min.js",
},
+ "email_consent": {
+ "source_filenames": ("js/email_consent.js",),
+ "output_filename": "js/email_consent.min.js",
+ },
}
PIPELINE = {
diff --git a/pontoon/urls.py b/pontoon/urls.py
index 3811c14141..57ef1a6cee 100644
--- a/pontoon/urls.py
+++ b/pontoon/urls.py
@@ -70,6 +70,7 @@ class LocaleConverter(StringConverter):
path("", include("pontoon.api.urls")),
path("", include("pontoon.homepage.urls")),
path("", include("pontoon.uxactionlog.urls")),
+ path("", include("pontoon.messaging.urls")),
# Team page: Must be at the end
path("/", team, name="pontoon.teams.team"),
]