Repository: Couchers-org/couchers (language: python)

Issue:
Email deliverability
This is a never-ending thing.
Feature: unsubscribe toggle for all email notifications
We need to add this soon to avoid spam filtering.


Discussion / hints:
I've set up DMARC #4073


Fix patch:
diff --git a/app/backend/proto/internal/unsubscribe.proto b/app/backend/proto/internal/unsubscribe.proto
index b10b216be0..3953cdf220 100644
--- a/app/backend/proto/internal/unsubscribe.proto
+++ b/app/backend/proto/internal/unsubscribe.proto
@@ -8,6 +8,10 @@ message MuteAll {
   // essential: not security stuff, etc
 }
 
+message DoNotEmail {
+  // do not email me, also turns hosting and "wants to meet" off
+}
+
 message UnsubscribeTopicKey {
   // unsubscribe from a given topic for a given key
   // e.g. a given event or group chat
@@ -29,5 +33,6 @@ message UnsubscribePayload {
     MuteAll all = 2;
     UnsubscribeTopicKey topic_key = 3;
     UnsubscribeTopicAction topic_action = 4;
+    DoNotEmail do_not_email = 5;
   }
 }
diff --git a/app/backend/src/couchers/email/__init__.py b/app/backend/src/couchers/email/__init__.py
index 99ea29eeeb..77acb92139 100644
--- a/app/backend/src/couchers/email/__init__.py
+++ b/app/backend/src/couchers/email/__init__.py
@@ -1,3 +1,4 @@
+import logging
 from html import escape
 from pathlib import Path
 
@@ -6,8 +7,11 @@
 
 from couchers.config import config
 from couchers.jobs.enqueue import queue_job
+from couchers.notifications.unsubscribe import generate_do_not_email
 from proto.internal import jobs_pb2
 
+logger = logging.getLogger(__name__)
+
 loader = FileSystemLoader(Path(__file__).parent / ".." / ".." / ".." / "templates")
 env = Environment(loader=loader, trim_blocks=True)
 
@@ -32,7 +36,7 @@ def render_html(text):
     return "\n\n".join(f"<p>{paragraph.strip()}</p>" for paragraph in stripped_paragraphs)
 
 
-def _render_email(template_file, template_args={}):
+def _render_email(template_file, template_args, _footer_unsub_link):
     """
     Renders both a plain-text and a HTML version of an email, and embeds both in their base templates
 
@@ -71,8 +75,12 @@ def _render_email(template_file, template_args={}):
     plain_content = template.render({**template_args, "frontmatter": frontmatter}, plain=True, html=False)
     html_content = render_html(template.render({**template_args, "frontmatter": frontmatter}, plain=False, html=True))
 
-    plain = plain_base_template.render(frontmatter=frontmatter, content=plain_content)
-    html = html_base_template.render(frontmatter=frontmatter, content=html_content)
+    plain = plain_base_template.render(
+        frontmatter=frontmatter, content=plain_content, _footer_unsub_link=_footer_unsub_link
+    )
+    html = html_base_template.render(
+        frontmatter=frontmatter, content=html_content, _footer_unsub_link=_footer_unsub_link
+    )
 
     return frontmatter, plain, html
 
@@ -92,8 +100,8 @@ def queue_email(sender_name, sender_email, recipient, subject, plain, html):
     )
 
 
-def enqueue_email_from_template(recipient, template_file, template_args={}):
-    frontmatter, plain, html = _render_email(template_file, template_args)
+def enqueue_email_from_template(recipient, template_file, template_args={}, _footer_unsub_link=None):
+    frontmatter, plain, html = _render_email(template_file, template_args, _footer_unsub_link=_footer_unsub_link)
     queue_email(
         config["NOTIFICATION_EMAIL_SENDER"],
         config["NOTIFICATION_EMAIL_ADDRESS"],
@@ -102,3 +110,16 @@ def enqueue_email_from_template(recipient, template_file, template_args={}):
         plain,
         html,
     )
+
+
+def enqueue_email_from_template_to_user(user, template_file, template_args={}, is_critical_email=False):
+    if user.do_not_email and not is_critical_email:
+        logger.info(f"Not emailing {user} based on template {template_file} due to emails turned off")
+        return
+    enqueue_email_from_template(
+        user.email,
+        template_file,
+        template_args=template_args,
+        # don't include the link on security emails
+        _footer_unsub_link=generate_do_not_email(user.id) if not is_critical_email else None,
+    )
diff --git a/app/backend/src/couchers/errors.py b/app/backend/src/couchers/errors.py
index e9dd5bfc24..d385799164 100644
--- a/app/backend/src/couchers/errors.py
+++ b/app/backend/src/couchers/errors.py
@@ -34,6 +34,9 @@
 DATE_FROM_BEFORE_TODAY = "From date must be today or later."
 DATE_TO_AFTER_ONE_YEAR = "You cannot request to stay with someone for longer than one year."
 DISCUSSION_NOT_FOUND = "Discussion not found."
+DO_NOT_EMAIL_CANNOT_ENABLE_NEW_NOTIFICATIONS = "You cannot enable this feature while you have emails turned off."
+DO_NOT_EMAIL_CANNOT_HOST = "You cannot enable hosting while you have emails turned off in your settings."
+DO_NOT_EMAIL_CANNOT_MEET = "You cannot enable meeting up while you have emails turned off in your settings."
 DONATION_TOO_SMALL = "We can't accept donations less than $2, sorry!"
 DONATIONS_DISABLED = "Donations are currently disabled."
 EVENT_CANT_OVERLAP = "An event cannot have overlapping occurrences."
diff --git a/app/backend/src/couchers/jobs/handlers.py b/app/backend/src/couchers/jobs/handlers.py
index 8d1f918bb4..b59d14bdbb 100644
--- a/app/backend/src/couchers/jobs/handlers.py
+++ b/app/backend/src/couchers/jobs/handlers.py
@@ -191,8 +191,8 @@ def send_message_notifications(payload):
 
             total_unseen_message_count = sum(count for _, _, count in unseen_messages)
 
-            email.enqueue_email_from_template(
-                user.email,
+            email.enqueue_email_from_template_to_user(
+                user,
                 "unseen_messages",
                 template_args={
                     "user": user,
@@ -246,8 +246,8 @@ def send_request_notifications(payload):
             user.last_notified_request_message_id = max(user.last_notified_request_message_id, max_message_id)
             session.commit()
 
-            email.enqueue_email_from_template(
-                user.email,
+            email.enqueue_email_from_template_to_user(
+                user,
                 "unseen_message_guest",
                 template_args={
                     "user": user,
@@ -260,8 +260,8 @@ def send_request_notifications(payload):
             user.last_notified_request_message_id = max(user.last_notified_request_message_id, max_message_id)
             session.commit()
 
-            email.enqueue_email_from_template(
-                user.email,
+            email.enqueue_email_from_template_to_user(
+                user,
                 "unseen_message_host",
                 template_args={
                     "user": user,
diff --git a/app/backend/src/couchers/migrations/versions/b5355a1f60f6_add_do_not_email.py b/app/backend/src/couchers/migrations/versions/b5355a1f60f6_add_do_not_email.py
new file mode 100644
index 0000000000..8fd6241ff6
--- /dev/null
+++ b/app/backend/src/couchers/migrations/versions/b5355a1f60f6_add_do_not_email.py
@@ -0,0 +1,29 @@
+"""Add do not email
+
+Revision ID: b5355a1f60f6
+Revises: 1c999dea180d
+Create Date: 2024-05-03 16:46:22.121003
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "b5355a1f60f6"
+down_revision = "1c999dea180d"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column("users", sa.Column("do_not_email", sa.Boolean(), server_default=sa.text("false"), nullable=False))
+    op.create_check_constraint(
+        constraint_name="do_not_email_inactive",
+        table_name="users",
+        condition="(do_not_email IS FALSE) OR ((new_notifications_enabled IS FALSE) AND (hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))",
+    )
+
+
+def downgrade():
+    raise Exception("Can't downgrade")
diff --git a/app/backend/src/couchers/models.py b/app/backend/src/couchers/models.py
index 2653683594..fd0c4166cf 100644
--- a/app/backend/src/couchers/models.py
+++ b/app/backend/src/couchers/models.py
@@ -282,6 +282,9 @@ class User(Base):
 
     has_passport_sex_gender_exception = Column(Boolean, nullable=False, server_default=text("false"))
 
+    # whether this user has all emails turned off
+    do_not_email = Column(Boolean, nullable=False, server_default=text("false"))
+
     avatar = relationship("Upload", foreign_keys="User.avatar_key")
 
     admin_note = Column(String, nullable=False, server_default=text("''"))
@@ -341,6 +344,11 @@ class User(Base):
             "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR is_deleted)",
             name="undelete_nullity",
         ),
+        # If the user disabled all emails, then they can't host or meet up
+        CheckConstraint(
+            "(do_not_email IS FALSE) OR ((new_notifications_enabled IS FALSE) AND (hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))",
+            name="do_not_email_inactive",
+        ),
     )
 
     @hybrid_property
diff --git a/app/backend/src/couchers/notifications/unsubscribe.py b/app/backend/src/couchers/notifications/unsubscribe.py
index e16890101c..5add9cf211 100644
--- a/app/backend/src/couchers/notifications/unsubscribe.py
+++ b/app/backend/src/couchers/notifications/unsubscribe.py
@@ -6,7 +6,7 @@
 from couchers.constants import DATETIME_INFINITY
 from couchers.crypto import UNSUBSCRIBE_KEY_NAME, b64encode, generate_hash_signature, get_secret, verify_hash_signature
 from couchers.db import session_scope
-from couchers.models import GroupChatSubscription, NotificationDeliveryType, User
+from couchers.models import GroupChatSubscription, HostingStatus, MeetupStatus, NotificationDeliveryType, User
 from couchers.notifications import settings
 from couchers.notifications.utils import enum_from_topic_action
 from couchers.sql import couchers_select as select
@@ -30,6 +30,15 @@ def generate_mute_all(user_id):
     )
 
 
+def generate_do_not_email(user_id):
+    return _generate_unsubscribe_link(
+        unsubscribe_pb2.UnsubscribePayload(
+            user_id=user_id,
+            do_not_email=unsubscribe_pb2.DoNotEmail(),
+        )
+    )
+
+
 def generate_unsub_topic_key(notification):
     return _generate_unsubscribe_link(
         unsubscribe_pb2.UnsubscribePayload(
@@ -68,6 +77,13 @@ def unsubscribe(request, context):
             # todo: some other system when out of preview
             user.new_notifications_enabled = False
             return "You've been unsubscribed from all non-security notifications"
+        if payload.HasField("do_not_email"):
+            logger.info(f"User {user.name} turning of emails")
+            user.do_not_email = True
+            user.new_notifications_enabled = False
+            user.hosting_status = HostingStatus.cant_host
+            user.meetup_status = MeetupStatus.does_not_want_to_meetup
+            return "You will not receive any non-security emails. You may still receive the newsletter, and need to unsubscribe separately there, sorry!"
         if payload.HasField("topic_action"):
             logger.info(f"User {user.name} unsubscribing from topic_action")
             topic = payload.topic_action.topic
diff --git a/app/backend/src/couchers/servicers/account.py b/app/backend/src/couchers/servicers/account.py
index 8b65c70103..9d4f19cb6a 100644
--- a/app/backend/src/couchers/servicers/account.py
+++ b/app/backend/src/couchers/servicers/account.py
@@ -471,6 +471,17 @@ def DeleteAccount(self, request, context):
 
         return empty_pb2.Empty()
 
+    def GetDoNotEmail(self, request, context):
+        with session_scope() as session:
+            user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
+            return account_pb2.DoNotEmailRes(do_not_email=user.do_not_email)
+
+    def SetDoNotEmail(self, request, context):
+        with session_scope() as session:
+            user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
+            user.do_not_email = request.do_not_email
+            return account_pb2.DoNotEmailRes(do_not_email=user.do_not_email)
+
 
 class Iris(iris_pb2_grpc.IrisServicer):
     def Webhook(self, request, context):
diff --git a/app/backend/src/couchers/servicers/admin.py b/app/backend/src/couchers/servicers/admin.py
index c2218e4815..dc0f0bb386 100644
--- a/app/backend/src/couchers/servicers/admin.py
+++ b/app/backend/src/couchers/servicers/admin.py
@@ -27,11 +27,13 @@ def _user_to_details(user):
     return admin_pb2.UserDetails(
         user_id=user.id,
         username=user.username,
+        name=user.name,
         email=user.email,
         gender=user.gender,
         birthdate=date_to_api(user.birthdate),
         banned=user.is_banned,
         deleted=user.is_deleted,
+        do_not_email=user.do_not_email,
         badges=[badge.badge_id for badge in user.badges],
         **get_strong_verification_fields(user),
         has_passport_sex_gender_exception=user.has_passport_sex_gender_exception,
diff --git a/app/backend/src/couchers/servicers/api.py b/app/backend/src/couchers/servicers/api.py
index 6b79487464..cf2bd15a5b 100644
--- a/app/backend/src/couchers/servicers/api.py
+++ b/app/backend/src/couchers/servicers/api.py
@@ -278,9 +278,13 @@ def UpdateProfile(self, request, context):
                     user.about_place = request.about_place.value
 
             if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
+                if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
+                    context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_HOST)
                 user.hosting_status = hostingstatus2sql[request.hosting_status]
 
             if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
+                if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
+                    context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_MEET)
                 user.meetup_status = meetupstatus2sql[request.meetup_status]
 
             if request.HasField("language_abilities"):
diff --git a/app/backend/src/couchers/servicers/notifications.py b/app/backend/src/couchers/servicers/notifications.py
index ee192fbe61..834a619093 100644
--- a/app/backend/src/couchers/servicers/notifications.py
+++ b/app/backend/src/couchers/servicers/notifications.py
@@ -4,7 +4,7 @@
 
 from couchers import errors
 from couchers.db import session_scope
-from couchers.models import Notification, NotificationDeliveryType, User
+from couchers.models import HostingStatus, MeetupStatus, Notification, NotificationDeliveryType, User
 from couchers.notifications.settings import PreferenceNotUserEditableError, get_user_setting_groups, set_preference
 from couchers.notifications.utils import enum_from_topic_action
 from couchers.sql import couchers_select as select
@@ -42,6 +42,8 @@ def GetNotificationSettings(self, request, context):
     def SetNotificationSettings(self, request, context):
         with session_scope() as session:
             user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
+            if user.do_not_email:
+                context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_ENABLE_NEW_NOTIFICATIONS)
             user.new_notifications_enabled = request.enable_new_notifications
             for preference in request.preferences:
                 topic_action = enum_from_topic_action.get((preference.topic, preference.action), None)
@@ -60,6 +62,21 @@ def SetNotificationSettings(self, request, context):
                 groups=get_user_setting_groups(user.id),
             )
 
+    def GetDoNotEmail(self, request, context):
+        with session_scope() as session:
+            user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
+            return notifications_pb2.GetDoNotEmailRes(do_not_email_enabled=user.do_not_email)
+
+    def SetDoNotEmail(self, request, context):
+        with session_scope() as session:
+            user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
+            user.do_not_email = request.enable_do_not_email
+            if request.enable_do_not_email:
+                user.new_notifications_enabled = False
+                user.hosting_status = HostingStatus.cant_host
+                user.meetup_status = MeetupStatus.does_not_want_to_meetup
+        return notifications_pb2.SetDoNotEmailRes()
+
     def ListNotifications(self, request, context):
         with session_scope() as session:
             page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
diff --git a/app/backend/src/couchers/tasks.py b/app/backend/src/couchers/tasks.py
index 0d3ababa3d..f343de0017 100644
--- a/app/backend/src/couchers/tasks.py
+++ b/app/backend/src/couchers/tasks.py
@@ -62,15 +62,17 @@ def send_login_email(session, user):
     logger.info(f"Token: {login_token=} ({login_token.created=}")
     login_link = urls.login_link(login_token=login_token.token)
     logger.info(f"Link is: {login_link}")
-    email.enqueue_email_from_template(user.email, "login", template_args={"user": user, "login_link": login_link})
+    email.enqueue_email_from_template_to_user(
+        user, "login", template_args={"user": user, "login_link": login_link}, is_critical_email=True
+    )
 
     return login_token
 
 
 def send_api_key_email(session, user, token, expiry):
     logger.info(f"Sending API key email to {user=}:")
-    email.enqueue_email_from_template(
-        user.email, "api_key", template_args={"user": user, "token": token, "expiry": expiry}
+    email.enqueue_email_from_template_to_user(
+        user, "api_key", template_args={"user": user, "token": token, "expiry": expiry}, is_critical_email=True
     )
 
 
@@ -83,8 +85,11 @@ def send_password_reset_email(session, user):
     logger.info(f"Sending password reset email to {user=}:")
     password_reset_link = urls.password_reset_link(password_reset_token=password_reset_token.token)
     logger.info(f"Link is: {password_reset_link}")
-    email.enqueue_email_from_template(
-        user.email, "password_reset", template_args={"user": user, "password_reset_link": password_reset_link}
+    email.enqueue_email_from_template_to_user(
+        user,
+        "password_reset",
+        template_args={"user": user, "password_reset_link": password_reset_link},
+        is_critical_email=True,
     )
 
     return password_reset_token
@@ -95,8 +100,8 @@ def send_new_host_request_email(host_request):
     logger.info(f"Host request sent by {host_request.surfer}")
     logger.info(f"Email for {host_request.host.username=} sent to {host_request.host.email=}")
 
-    email.enqueue_email_from_template(
-        host_request.host.email,
+    email.enqueue_email_from_template_to_user(
+        host_request.host,
         "host_request",
         template_args={
             "host_request": host_request,
@@ -109,8 +114,8 @@ def send_host_request_accepted_email_to_guest(host_request):
     logger.info(f"Sending host request accepted email to guest: {host_request.surfer=}:")
     logger.info(f"Email for {host_request.surfer.username=} sent to {host_request.surfer.email=}")
 
-    email.enqueue_email_from_template(
-        host_request.surfer.email,
+    email.enqueue_email_from_template_to_user(
+        host_request.surfer,
         "host_request_accepted_guest",
         template_args={
             "host_request": host_request,
@@ -123,8 +128,8 @@ def send_host_request_rejected_email_to_guest(host_request):
     logger.info(f"Sending host request rejected email to guest: {host_request.surfer=}:")
     logger.info(f"Email for {host_request.surfer.username=} sent to {host_request.surfer.email=}")
 
-    email.enqueue_email_from_template(
-        host_request.surfer.email,
+    email.enqueue_email_from_template_to_user(
+        host_request.surfer,
         "host_request_rejected_guest",
         template_args={
             "host_request": host_request,
@@ -137,8 +142,8 @@ def send_host_request_confirmed_email_to_host(host_request):
     logger.info(f"Sending host request confirmed email to host: {host_request.host=}:")
     logger.info(f"Email for {host_request.host.username=} sent to {host_request.host.email=}")
 
-    email.enqueue_email_from_template(
-        host_request.host.email,
+    email.enqueue_email_from_template_to_user(
+        host_request.host,
         "host_request_confirmed_host",
         template_args={
             "host_request": host_request,
@@ -151,8 +156,8 @@ def send_host_request_cancelled_email_to_host(host_request):
     logger.info(f"Sending host request cancelled email to host: {host_request.host=}:")
     logger.info(f"Email for {host_request.host.username=} sent to {host_request.host.email=}")
 
-    email.enqueue_email_from_template(
-        host_request.host.email,
+    email.enqueue_email_from_template_to_user(
+        host_request.host,
         "host_request_cancelled_host",
         template_args={
             "host_request": host_request,
@@ -168,8 +173,8 @@ def send_friend_request_email(friend_relationship):
     logger.info(f"Email for {friend_relationship.to_user.username=} sent to {friend_relationship.to_user.email=}")
     logger.info(f"Friend request sent by {friend_relationship.from_user.username=}")
 
-    email.enqueue_email_from_template(
-        friend_relationship.to_user.email,
+    email.enqueue_email_from_template_to_user(
+        friend_relationship.to_user,
         "friend_request",
         template_args={
             "friend_relationship": friend_relationship,
@@ -182,8 +187,8 @@ def send_friend_request_accepted_email(friend_relationship):
     logger.info(f"Sending friend request acceptance email to {friend_relationship.from_user=}:")
     logger.info(f"Email for {friend_relationship.from_user.username=} sent to {friend_relationship.from_user.email=}")
 
-    email.enqueue_email_from_template(
-        friend_relationship.from_user.email,
+    email.enqueue_email_from_template_to_user(
+        friend_relationship.from_user,
         "friend_request_accepted",
         template_args={
             "friend_relationship": friend_relationship,
@@ -202,8 +207,8 @@ def send_host_reference_email(reference, both_written):
 
     surfed = reference.host_request.surfer_user_id != reference.from_user_id
 
-    email.enqueue_email_from_template(
-        reference.to_user.email,
+    email.enqueue_email_from_template_to_user(
+        reference.to_user,
         "host_reference",
         template_args={
             "reference": reference,
@@ -224,8 +229,8 @@ def send_friend_reference_email(reference):
 
     logger.info(f"Sending friend reference email to {reference.to_user=} for {reference.id=}")
 
-    email.enqueue_email_from_template(
-        reference.to_user.email,
+    email.enqueue_email_from_template_to_user(
+        reference.to_user,
         "friend_reference",
         template_args={
             "reference": reference,
@@ -236,8 +241,8 @@ def send_friend_reference_email(reference):
 def send_reference_reminder_email(user, other_user, host_request, surfed, time_left_text):
     logger.info(f"Sending host reference email to {user=}, they have {time_left_text} left to write a ref")
 
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "reference_reminder",
         template_args={
             "user": user,
@@ -259,7 +264,9 @@ def send_password_changed_email(user):
     Send the user an email saying their password has been changed.
     """
     logger.info(f"Sending password changed (notification) email to {user=}")
-    email.enqueue_email_from_template(user.email, "password_changed", template_args={"user": user})
+    email.enqueue_email_from_template_to_user(
+        user, "password_changed", template_args={"user": user}, is_critical_email=True
+    )
 
 
 def send_email_changed_notification_email(user):
@@ -269,7 +276,9 @@ def send_email_changed_notification_email(user):
     logger.info(
         f"Sending email changed (notification) email to {user=} (old email: {user.email=}, new email: {user.new_email=})"
     )
-    email.enqueue_email_from_template(user.email, "email_changed_notification", template_args={"user": user})
+    email.enqueue_email_from_template_to_user(
+        user, "email_changed_notification", template_args={"user": user}, is_critical_email=True
+    )
 
 
 def send_email_changed_confirmation_to_old_email(user):
@@ -281,10 +290,11 @@ def send_email_changed_confirmation_to_old_email(user):
     )
 
     confirmation_link = urls.change_email_link(confirmation_token=user.old_email_token)
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "email_changed_confirmation_old_email",
         template_args={"user": user, "confirmation_link": confirmation_link},
+        is_critical_email=True,
     )
 
 
@@ -305,8 +315,8 @@ def send_email_changed_confirmation_to_new_email(user):
 
 
 def send_onboarding_email(user, email_number):
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         f"onboarding{email_number}",
         template_args={
             "user": user,
@@ -318,8 +328,8 @@ def send_onboarding_email(user, email_number):
 
 
 def send_donation_email(user, amount, receipt_url):
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "donation_received",
         template_args={"user": user, "amount": amount, "receipt_url": receipt_url},
     )
@@ -369,8 +379,8 @@ def maybe_send_contributor_form_email(form):
 
 def send_digest_email(user, notifications: List[Notification]):
     logger.info(f"Sending digest email to {user=}:")
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "digest",
         template_args={"user": user, "notifications": notifications},
     )
@@ -380,8 +390,8 @@ def send_notification_email(notification: Notification):
     friend_requests_link = urls.friend_requests_link()
     logger.info(f"Sending notification email to {notification.user=}:")
 
-    email.enqueue_email_from_template(
-        notification.user.email,
+    email.enqueue_email_from_template_to_user(
+        notification.user,
         "notification",
         template_args={
             "notification": notification,
@@ -426,10 +436,11 @@ def send_account_deletion_confirmation_email(user):
     logger.info(f"Email for {user.username=} sent to {user.email}.")
     token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
     deletion_link = urls.delete_account_link(account_deletion_token=token.token)
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "account_deletion_confirmation",
         template_args={"user": user, "deletion_link": deletion_link},
+        is_critical_email=True,
     )
 
     return token
@@ -439,20 +450,22 @@ def send_account_deletion_successful_email(user, undelete_days):
     logger.info(f"Sending account deletion successful email to {user=}.")
     logger.info(f"Email for {user.username=} sent to {user.email}.")
     undelete_link = urls.recover_account_link(account_undelete_token=user.undelete_token)
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "account_deletion_successful",
         template_args={"user": user, "undelete_link": undelete_link, "days": undelete_days},
+        is_critical_email=True,
     )
 
 
 def send_account_recovered_email(user):
     logger.info(f"Sending account recovered successful email to {user=}.")
     logger.info(f"Email for {user.username=} sent to {user.email}.")
-    email.enqueue_email_from_template(
-        user.email,
+    email.enqueue_email_from_template_to_user(
+        user,
         "account_recovered_successful",
         template_args={"user": user, "app_link": urls.app_link()},
+        is_critical_email=True,
     )
 
 
diff --git a/app/backend/templates/account_deletion_confirmation.md b/app/backend/templates/account_deletion_confirmation.md
index 93a0c61307..a54b19f5ad 100644
--- a/app/backend/templates/account_deletion_confirmation.md
+++ b/app/backend/templates/account_deletion_confirmation.md
@@ -13,7 +13,7 @@ To complete this process, please confirm by clicking the following link:
 
 {{ button("Delete Account", deletion_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(deletion_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(deletion_link, html)|couchers_safe }}
 
 {% else %}
 
diff --git a/app/backend/templates/account_deletion_successful.md b/app/backend/templates/account_deletion_successful.md
index 1a67cb281c..cd19b06c1c 100644
--- a/app/backend/templates/account_deletion_successful.md
+++ b/app/backend/templates/account_deletion_successful.md
@@ -15,7 +15,7 @@ If you change your mind, you have {{ days|couchers_escape }} days to retrieve yo
 
 {{ button("Recover Account", undelete_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(undelete_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(undelete_link, html)|couchers_safe }}
 
 {% else %}
 
diff --git a/app/backend/templates/account_recovered_successful.md b/app/backend/templates/account_recovered_successful.md
index 3330b7db03..d394240838 100644
--- a/app/backend/templates/account_recovered_successful.md
+++ b/app/backend/templates/account_recovered_successful.md
@@ -13,7 +13,7 @@ We are so glad you decided to stay! To log back in, click the following link:
 
 {{ button("Log in", app_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(app_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(app_link, html)|couchers_safe }}
 
 {% else %}
 
diff --git a/app/backend/templates/api_key.md b/app/backend/templates/api_key.md
index 7fdc170e1b..40d2cc469d 100644
--- a/app/backend/templates/api_key.md
+++ b/app/backend/templates/api_key.md
@@ -19,6 +19,6 @@ This token is unique to your account; do not share it with anyone, it gives comp
 
 Please remember to abide by our Terms of Service, our Community Guidelines, and other policies while using our APIs. All access is logged and analyzed, and we may terminate your access or account if you abuse the service.
 
-For any questions, please contact us at {{ support_email(html)|couchers_safe }}.
+For any questions, please contact us at {{ support_email(html)|couchers_safe }}
 
 The Couchers.org team
diff --git a/app/backend/templates/donation_received.md b/app/backend/templates/donation_received.md
index 0cef83c639..5be7930f09 100644
--- a/app/backend/templates/donation_received.md
+++ b/app/backend/templates/donation_received.md
@@ -15,12 +15,12 @@ You can download an invoice and receipt for the donation here:
 {% if html %}
 {{ button("Invoice", receipt_url)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(receipt_url, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(receipt_url, html)|couchers_safe }}
 {% else %}
-<{{ receipt_url|couchers_escape }}>
+<{{ receipt_url|couchers_safe }}>
 {% endif %}
 
-If you have any questions about your donation, please email us at {{ email_link("donations@couchers.org", html)|couchers_safe }}.
+If you have any questions about your donation, please email us at {{ email_link("donations@couchers.org", html)|couchers_safe }}
 
 Your generosity will help deliver the platform for everyone.
 
diff --git a/app/backend/templates/email_base_html.html b/app/backend/templates/email_base_html.html
index 0bc0fd54fc..88aa558010 100644
--- a/app/backend/templates/email_base_html.html
+++ b/app/backend/templates/email_base_html.html
@@ -12,5 +12,8 @@
       {% endif %}
       <hr />
       <p>You're receiving this email because you signed up for Couchers.org. If you have any issues, email us at <a href="mailto:support@couchers.org">support@couchers.org</a>.</p>
+      {% if _footer_unsub_link %}
+      <p>If you wish to stop receiving emails from Couchers, click on this link: <a href="{{ _footer_unsub_link|couchers_safe }}">unsubscribe all</a>.</p>
+      {% endif %}
   </body>
 </html>
diff --git a/app/backend/templates/email_base_plain.md b/app/backend/templates/email_base_plain.md
index ff496906d1..4e640057e9 100644
--- a/app/backend/templates/email_base_plain.md
+++ b/app/backend/templates/email_base_plain.md
@@ -7,3 +7,7 @@ Sorry, an unknown error occurred, please forward this email to <bugs@couchers.or
 ---
 
 You're receiving this email because you signed up for Couchers.org. If you have any issues, email us at <support@couchers.org>.
+
+{% if _footer_unsub_link %}
+If you wish to stop receiving emails from Couchers, click on this link: <{{ _footer_unsub_link|couchers_safe }}>
+{% endif %}
diff --git a/app/backend/templates/email_changed_confirmation_new_email.md b/app/backend/templates/email_changed_confirmation_new_email.md
index 6af2ffbd1a..a13ed641e8 100644
--- a/app/backend/templates/email_changed_confirmation_new_email.md
+++ b/app/backend/templates/email_changed_confirmation_new_email.md
@@ -13,7 +13,7 @@ To complete this change, please confirm your email by clicking the following lin
 
 {{ button("Confirm new email", confirmation_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(confirmation_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(confirmation_link, html)|couchers_safe }}
 
 {% else %}
 
diff --git a/app/backend/templates/email_changed_confirmation_old_email.md b/app/backend/templates/email_changed_confirmation_old_email.md
index c71f9f243b..d73b4d3664 100644
--- a/app/backend/templates/email_changed_confirmation_old_email.md
+++ b/app/backend/templates/email_changed_confirmation_old_email.md
@@ -15,11 +15,11 @@ Please confirm you requested this change by clicking the following link:
 
 {{ button("Confirm new email", confirmation_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(confirmation_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(confirmation_link, html)|couchers_safe }}
 
 {% else %}
 
-<{{ confirmation_link|couchers_escape }}>
+<{{ confirmation_link|couchers_safe }}>
 
 {% endif %}
 
diff --git a/app/backend/templates/friend_request.md b/app/backend/templates/friend_request.md
index 372065c53c..30976ae5e9 100644
--- a/app/backend/templates/friend_request.md
+++ b/app/backend/templates/friend_request.md
@@ -11,7 +11,7 @@ You've received a friend request from {{ friend_relationship.from_user.name|couc
 {% if html %}
 
 {% if friend_relationship.from_user.avatar %}
-<img src="{{ friend_relationship.from_user.avatar.thumbnail_url|couchers_escape }}" alt="Your New Friend's Profile Picture" >
+<img src="{{ friend_relationship.from_user.avatar.thumbnail_url|couchers_safe }}" alt="Your New Friend's Profile Picture" >
 {% endif %}
 
 {% endif %}
@@ -22,10 +22,10 @@ Check it out here:
 
 {{ button("Friend Requests", friend_requests_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(friend_requests_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(friend_requests_link, html)|couchers_safe }}
 
 {% else %}
-<{{ friend_requests_link|couchers_escape }}>
+<{{ friend_requests_link|couchers_safe }}>
 {% endif %}
 
 We hope you make a new friend!
diff --git a/app/backend/templates/friend_request_accepted.md b/app/backend/templates/friend_request_accepted.md
index 46657b82c4..1069085ee1 100644
--- a/app/backend/templates/friend_request_accepted.md
+++ b/app/backend/templates/friend_request_accepted.md
@@ -11,7 +11,7 @@ Hi {{ friend_relationship.from_user.name|couchers_escape }}!
 {% if html %}
 
 {% if friend_relationship.to_user.avatar %}
-<img src="{{ friend_relationship.to_user.avatar.thumbnail_url|couchers_escape }}" alt="Your New Friend's Profile Picture">
+<img src="{{ friend_relationship.to_user.avatar.thumbnail_url|couchers_safe }}" alt="Your New Friend's Profile Picture">
 {% endif %}
 
 {% endif %}
@@ -22,10 +22,10 @@ Check it out here:
 
 {{ button("Friend Profile", to_user_user_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(to_user_user_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(to_user_user_link, html)|couchers_safe }}
 
 {% else %}
-<{{ to_user_user_link|couchers_escape }}>
+<{{ to_user_user_link|couchers_safe }}>
 {% endif %}
 
 We hope you continue making new friends!
diff --git a/app/backend/templates/host_reference.md b/app/backend/templates/host_reference.md
index 7aefc9bfd1..c8ff1d8a40 100644
--- a/app/backend/templates/host_reference.md
+++ b/app/backend/templates/host_reference.md
@@ -22,9 +22,9 @@ You can write a reference for {{ reference.from_user.name|couchers_escape}} here
 {% if html %}
 {{ button("Write a reference", leave_reference_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(leave_reference_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(leave_reference_link, html)|couchers_safe }}
 {% else %}
-<{{ leave_reference_link|couchers_escape }}>
+<{{ leave_reference_link|couchers_safe }}>
 {% endif %}
 {% endif %}
 
diff --git a/app/backend/templates/host_request.md b/app/backend/templates/host_request.md
index c9527bce83..b99f98d7de 100644
--- a/app/backend/templates/host_request.md
+++ b/app/backend/templates/host_request.md
@@ -13,7 +13,7 @@ You've received a host request!
 {% if html %}
 
 {% if host_request.surfer.avatar %}
-<img src="{{ host_request.surfer.avatar.thumbnail_url|couchers_escape }}" alt="Your Guest's Profile Picture" >
+<img src="{{ host_request.surfer.avatar.thumbnail_url|couchers_safe }}" alt="Your Guest's Profile Picture" >
 {% endif %}
 
 {% endif %}
@@ -24,10 +24,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 Thanks for using Couchers!
diff --git a/app/backend/templates/host_request_accepted_guest.md b/app/backend/templates/host_request_accepted_guest.md
index e021ac9f22..39112f4f80 100644
--- a/app/backend/templates/host_request_accepted_guest.md
+++ b/app/backend/templates/host_request_accepted_guest.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 Thanks for using Couchers!
diff --git a/app/backend/templates/host_request_cancelled_host.md b/app/backend/templates/host_request_cancelled_host.md
index 3d898a0a2d..1554714a2e 100644
--- a/app/backend/templates/host_request_cancelled_host.md
+++ b/app/backend/templates/host_request_cancelled_host.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 Thanks for using Couchers!
diff --git a/app/backend/templates/host_request_confirmed_host.md b/app/backend/templates/host_request_confirmed_host.md
index 4acb82457a..edf506d887 100644
--- a/app/backend/templates/host_request_confirmed_host.md
+++ b/app/backend/templates/host_request_confirmed_host.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 Thanks for using Couchers!
diff --git a/app/backend/templates/host_request_rejected_guest.md b/app/backend/templates/host_request_rejected_guest.md
index 5af3161a5d..9e481494b0 100644
--- a/app/backend/templates/host_request_rejected_guest.md
+++ b/app/backend/templates/host_request_rejected_guest.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 Thanks for using Couchers!
diff --git a/app/backend/templates/login.md b/app/backend/templates/login.md
index 4f609b10e0..05e46b6f75 100644
--- a/app/backend/templates/login.md
+++ b/app/backend/templates/login.md
@@ -11,11 +11,11 @@ Here's a login link for Couchers.org:
 
 {{ button("Sign in", login_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(login_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(login_link, html)|couchers_safe }}
 
 {% else %}
 
-<{{ login_link|couchers_escape }}>
+<{{ login_link|couchers_safe }}>
 
 {% endif %}
 
diff --git a/app/backend/templates/macros.html b/app/backend/templates/macros.html
index ad58a15839..777bdc2f23 100644
--- a/app/backend/templates/macros.html
+++ b/app/backend/templates/macros.html
@@ -1,5 +1,5 @@
 {% macro button(text, link) -%}
-<a style="background-color: #00a398; border-radius: 5px; margin: 20px; padding: 10px 20px; color: #ffffff; text-decoration: none; box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12);" href="{{ link|couchers_escape }}">{{ text|couchers_escape }}</a>
+<a style="background-color: #00a398; border-radius: 5px; margin: 20px; padding: 10px 20px; color: #ffffff; text-decoration: none; box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12);" href="{{ link|couchers_safe }}">{{ text|couchers_escape }}</a>
 {%- endmacro %}
 
 {% macro link(link, html) -%}
diff --git a/app/backend/templates/password_reset.md b/app/backend/templates/password_reset.md
index 7fee279544..d189ad1eca 100644
--- a/app/backend/templates/password_reset.md
+++ b/app/backend/templates/password_reset.md
@@ -11,11 +11,11 @@ You asked for your password to be reset on Couchers.org.
 
 {{ button("Reset password", password_reset_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(password_reset_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(password_reset_link, html)|couchers_safe }}
 
 {% else %}
 
-<{{ password_reset_link|couchers_escape }}>
+<{{ password_reset_link|couchers_safe }}>
 
 {% endif %}
 
diff --git a/app/backend/templates/reference_reminder.md b/app/backend/templates/reference_reminder.md
index 73f2778d85..7cf7aed975 100644
--- a/app/backend/templates/reference_reminder.md
+++ b/app/backend/templates/reference_reminder.md
@@ -13,9 +13,9 @@ Leave a reference here:
 {% if html %}
 {{ button("Leave a reference", leave_reference_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(leave_reference_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(leave_reference_link, html)|couchers_safe }}
 {% else %}
-<{{ leave_reference_link|couchers_escape }}>
+<{{ leave_reference_link|couchers_safe }}>
 {% endif %}
 
 You can also leave a reference by going to the Messaging tab, locating the request, then clicking on the "Write Reference" button at the bottom of the conversation.
diff --git a/app/backend/templates/signup_continue.md b/app/backend/templates/signup_continue.md
index bbd5ce2df7..84a3162e6f 100644
--- a/app/backend/templates/signup_continue.md
+++ b/app/backend/templates/signup_continue.md
@@ -11,7 +11,7 @@ Please finish signing up for Couchers.org.
 
 {{ button("Continue signing up", signup_link)|couchers_safe }}
 
-Please click on the above button to continue, or click the following link: {{ link(signup_link, html)|couchers_safe }}.
+Please click on the above button to continue, or click the following link: {{ link(signup_link, html)|couchers_safe }}
 
 {% else %}
 
diff --git a/app/backend/templates/signup_verify.md b/app/backend/templates/signup_verify.md
index 2b27b89058..87c55e4f8c 100644
--- a/app/backend/templates/signup_verify.md
+++ b/app/backend/templates/signup_verify.md
@@ -11,11 +11,11 @@ Thanks for signing up for Couchers.org!
 
 {{ button("Confirm your email address", signup_link)|couchers_safe }}
 
-To finish setting up your account, please click on the above button, or click the following link: {{ link(signup_link, html)|couchers_safe }}.
+To finish setting up your account, please click on the above button, or click the following link: {{ link(signup_link, html)|couchers_safe }}
 
 {% else %}
 
-To finish setting up your account, please click on this link to confirm your email address: <{{ signup_link|couchers_escape }}>.
+To finish setting up your account, please click on this link to confirm your email address: <{{ signup_link|couchers_safe }}>.
 
 {% endif %}
 
diff --git a/app/backend/templates/unseen_message_guest.md b/app/backend/templates/unseen_message_guest.md
index cba8930265..26a4b8a09e 100644
--- a/app/backend/templates/unseen_message_guest.md
+++ b/app/backend/templates/unseen_message_guest.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 The Couchers.org team
diff --git a/app/backend/templates/unseen_message_host.md b/app/backend/templates/unseen_message_host.md
index b3694581f2..515a6fd3fa 100644
--- a/app/backend/templates/unseen_message_host.md
+++ b/app/backend/templates/unseen_message_host.md
@@ -14,10 +14,10 @@ Check it out here:
 
 {{ button("Host Requests", host_request_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(host_request_link, html)|couchers_safe }}
 
 {% else %}
-<{{ host_request_link|couchers_escape }}>
+<{{ host_request_link|couchers_safe }}>
 {% endif %}
 
 The Couchers.org team
diff --git a/app/backend/templates/unseen_messages.md b/app/backend/templates/unseen_messages.md
index 11e4acd600..d88bdc0917 100644
--- a/app/backend/templates/unseen_messages.md
+++ b/app/backend/templates/unseen_messages.md
@@ -27,10 +27,10 @@ Check it out here:
 
 {{ button("See Messages", group_chats_link)|couchers_safe }}
 
-Alternatively, click the following link: {{ link(group_chats_link, html)|couchers_safe }}.
+Alternatively, click the following link: {{ link(group_chats_link, html)|couchers_safe }}
 
 {% else %}
-<{{ group_chats_link|couchers_escape }}>
+<{{ group_chats_link|couchers_safe }}>
 {% endif %}
 
 The Couchers.org team
diff --git a/app/proto/account.proto b/app/proto/account.proto
index ea86c5d96c..c1899e934f 100644
--- a/app/proto/account.proto
+++ b/app/proto/account.proto
@@ -65,6 +65,10 @@ service Account {
   rpc DeleteAccount(DeleteAccountReq) returns (google.protobuf.Empty) {
     // Sends email with confirmation link containing token to delete account
   }
+
+  rpc SetDoNotEmail(SetDoNotEmailReq) returns (google.protobuf.Empty) {
+    // Sets the do not email flag
+  }
 }
 
 message GetAccountInfoRes {
@@ -97,6 +101,9 @@ message GetAccountInfoRes {
   bool has_strong_verification = 10;
   org.couchers.api.core.BirthdateVerificationStatus birthdate_verification_status = 11;
   org.couchers.api.core.GenderVerificationStatus gender_verification_status = 12;
+
+  // whether the user has all non-security emails off
+  bool do_not_email = 13;
 }
 
 message ChangePasswordReq {
@@ -160,3 +167,7 @@ message DeleteAccountReq {
   bool confirm = 1;
   string reason = 2;
 }
+
+message SetDoNotEmailReq {
+  bool do_not_email = 1;
+}
diff --git a/app/proto/admin.proto b/app/proto/admin.proto
index 29c77e65cb..118137c2e2 100644
--- a/app/proto/admin.proto
+++ b/app/proto/admin.proto
@@ -42,11 +42,13 @@ service Admin {
 message UserDetails {
   int64 user_id = 1;
   string username = 2;
+  string name = 14;
   string email = 3;
   string gender = 4;
   string birthdate = 7;
   bool banned = 5;
   bool deleted = 6;
+  bool do_not_email = 15;
   repeated string badges = 8;
 
   bool has_strong_verification = 9;
diff --git a/app/proto/notifications.proto b/app/proto/notifications.proto
index 0f8f4e0a89..273e8692cc 100644
--- a/app/proto/notifications.proto
+++ b/app/proto/notifications.proto
@@ -13,6 +13,10 @@ service Notifications {
 
   rpc SetNotificationSettings(SetNotificationSettingsReq) returns (GetNotificationSettingsRes);
 
+  rpc GetDoNotEmail(GetDoNotEmailReq) returns (GetDoNotEmailRes);
+
+  rpc SetDoNotEmail(SetDoNotEmailReq) returns (SetDoNotEmailRes);
+
   rpc ListNotifications(ListNotificationsReq) returns (ListNotificationsRes);
 }
 
@@ -68,6 +72,18 @@ message SetNotificationSettingsReq {
   repeated SingleNotificationPreference preferences = 2;
 }
 
+message GetDoNotEmailReq {}
+
+message GetDoNotEmailRes {
+  bool do_not_email_enabled = 1;
+}
+
+message SetDoNotEmailReq {
+  bool enable_do_not_email = 1;
+}
+
+message SetDoNotEmailRes {}
+
 message Notification {
   int64 notification_id = 1;
   google.protobuf.Timestamp created = 2;
diff --git a/app/web/Dockerfile b/app/web/Dockerfile
index 3646fbf436..62bfef732e 100644
--- a/app/web/Dockerfile
+++ b/app/web/Dockerfile
@@ -16,7 +16,7 @@ RUN yarn install --frozen-lockfile
 
 COPY . .
 
-ENV PROTOS_SHA=5506ea116e15f9eee0d23a31ef8f62460139cf2affde42153ee66353a76d9997
+ENV PROTOS_SHA=d98e8893fb17a4986377857402ec14735eeef315bc3ff958ba4c37081c6f50e2
 
 RUN wget https://protos--by-sha.preview.couchershq.org/$PROTOS_SHA/ts.tar.gz
 RUN tar xf ts.tar.gz && rm -f typescript_protos.tar.gz
diff --git a/app/web/features/auth/Settings.tsx b/app/web/features/auth/Settings.tsx
index b72b5d0624..c6834964b8 100644
--- a/app/web/features/auth/Settings.tsx
+++ b/app/web/features/auth/Settings.tsx
@@ -3,6 +3,7 @@ import CircularProgress from "components/CircularProgress";
 import HtmlMeta from "components/HtmlMeta";
 import PageTitle from "components/PageTitle";
 import ChangeEmail from "features/auth/email/ChangeEmail";
+import DoNotEmail from "features/auth/email/DoNotEmail";
 import { ChangePassword } from "features/auth/password";
 import Section from "features/auth/section/Section";
 import Timezone from "features/auth/timezone/Timezone";
@@ -66,6 +67,7 @@ export default function Settings() {
             email={accountInfo.email}
             hasPassword={accountInfo.hasPassword}
           />
+          <DoNotEmail className={classes.section} />
           <ChangePassword
             className={classes.section}
             hasPassword={accountInfo.hasPassword}
diff --git a/app/web/features/auth/email/ChangeEmail.stories.tsx b/app/web/features/auth/email/ChangeEmail.stories.tsx
index 5c44e73ee4..ead477c82e 100644
--- a/app/web/features/auth/email/ChangeEmail.stories.tsx
+++ b/app/web/features/auth/email/ChangeEmail.stories.tsx
@@ -89,6 +89,7 @@ function setMocks({
             hasStrongVerification: true,
             birthdateVerificationStatus: 2,
             genderVerificationStatus: 2,
+            doNotEmail: false,
           })
       : Promise.reject(new Error("Error getting account info"));
   mockedService.account.changeEmail = () =>
diff --git a/app/web/features/auth/email/DoNotEmail.tsx b/app/web/features/auth/email/DoNotEmail.tsx
new file mode 100644
index 0000000000..57270b4f8d
--- /dev/null
+++ b/app/web/features/auth/email/DoNotEmail.tsx
@@ -0,0 +1,86 @@
+import { Typography } from "@material-ui/core";
+import Alert from "components/Alert";
+import Button from "components/Button";
+import CircularProgress from "components/CircularProgress";
+import { doNotEmailQueryKey } from "features/queryKeys";
+import { RpcError } from "grpc-web";
+import { Trans, useTranslation } from "i18n";
+import { AUTH } from "i18n/namespaces";
+import { GetDoNotEmailRes, SetDoNotEmailRes } from "proto/notifications_pb";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { service } from "service";
+
+interface DoNotEmailFormData {
+  doNotEmailEnabled: boolean;
+}
+
+export default function DoNotEmail({ className }: { className: string }) {
+  const { t } = useTranslation(AUTH);
+
+  const queryClient = useQueryClient();
+
+  const { data, error, isLoading } = useQuery<
+    GetDoNotEmailRes.AsObject,
+    RpcError
+  >(doNotEmailQueryKey, service.notifications.getDoNotEmail);
+
+  const mutation = useMutation<
+    SetDoNotEmailRes.AsObject,
+    RpcError,
+    DoNotEmailFormData
+  >(
+    ({ doNotEmailEnabled }) =>
+      service.notifications.setDoNotEmail(doNotEmailEnabled),
+    {
+      onSuccess: () => {
+        queryClient.invalidateQueries(doNotEmailQueryKey);
+      },
+    }
+  );
+
+  const toggleNewNotifications = async () => {
+    if (!data) return;
+    mutation.mutate({
+      doNotEmailEnabled: !data.doNotEmailEnabled,
+    });
+  };
+
+  return (
+    <div className={className}>
+      <Typography variant="h2">{t("do_not_email.title")}</Typography>
+      {mutation.error && (
+        <Alert severity="error">{mutation.error.message}</Alert>
+      )}
+      {error && <Alert severity="error">{error.message}</Alert>}
+      {isLoading || !data ? (
+        <CircularProgress />
+      ) : (
+        <>
+          <Typography variant="body1">
+            <Trans
+              t={t}
+              i18nKey={
+                data.doNotEmailEnabled
+                  ? "do_not_email.status.no_emails_enabled_message"
+                  : "do_not_email.status.no_emails_disabled_message"
+              }
+            >
+              Emails are currently <strong>disabled/enabled</strong> for your
+              account.
+            </Trans>
+          </Typography>
+          <Typography variant="body1">
+            <Button
+              onClick={() => toggleNewNotifications()}
+              loading={mutation.isLoading}
+            >
+              {data.doNotEmailEnabled
+                ? t("do_not_email.action_button.no_emails_disable_text")
+                : t("do_not_email.action_button.no_emails_enable_text")}
+            </Button>
+          </Typography>
+        </>
+      )}
+    </div>
+  );
+}
diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json
index 46fc28d6e0..2ff6400ce4 100644
--- a/app/web/features/auth/locales/en.json
+++ b/app/web/features/auth/locales/en.json
@@ -6,6 +6,18 @@
     "current_password": "Current password",
     "new_email": "New email"
   },
+  "do_not_email": {
+    "title": "Do not email me",
+    "status": {
+      "no_emails_enabled_message": "Emails are currently <1>disabled</1> for your account.",
+      "no_emails_disabled_message": "Emails are currently <1>enabled</1> for your account."
+    },
+    "action_button": {
+      "no_emails_enable_text": "Do not email me",
+      "no_emails_disable_text": "Re-enable emails"
+    },
+    "caveat": "By enabling this setting, your account will be set to not accept host requests. You will still receive security emails (such as those for resetting your password). You may still receive the newsletter, you can unsubscribe from it using the link in the newsletter footer."
+  },
   "change_email_confirmation": {
     "change_in_progress": "Email change in progress…",
     "success_message": "Your email has been changed successfully!",
diff --git a/app/web/features/auth/password/ChangePassword.stories.tsx b/app/web/features/auth/password/ChangePassword.stories.tsx
index be8a97d763..597dd1415f 100644
--- a/app/web/features/auth/password/ChangePassword.stories.tsx
+++ b/app/web/features/auth/password/ChangePassword.stories.tsx
@@ -89,6 +89,7 @@ function setMocks({
             hasStrongVerification: true,
             birthdateVerificationStatus: 2,
             genderVerificationStatus: 2,
+            doNotEmail: false,
           })
       : Promise.reject(new Error("Error getting account info"));
   mockedService.account.changePassword = () =>
diff --git a/app/web/features/queryKeys.ts b/app/web/features/queryKeys.ts
index ea2d0db6f3..ccb16b84cd 100644
--- a/app/web/features/queryKeys.ts
+++ b/app/web/features/queryKeys.ts
@@ -7,6 +7,7 @@ export const badgesKey = "badges";
 export const contributorFormInfoQueryKey = "contributorFormInfo";
 export const accountInfoQueryKey = "accountInfo";
 export const notificationSettingsQueryKey = "notificationSettings";
+export const doNotEmailQueryKey = "doNotEmail";
 export const tosQueryKey = "tos";
 export const communityGuidelinesQueryKey = "communityGuidelines";
 
diff --git a/app/web/service/account.ts b/app/web/service/account.ts
index e08a3cacf6..4b0493187c 100644
--- a/app/web/service/account.ts
+++ b/app/web/service/account.ts
@@ -6,6 +6,7 @@ import {
   ChangePhoneReq,
   DeleteAccountReq,
   FillContributorFormReq,
+  SetDoNotEmailReq,
   VerifyPhoneReq,
 } from "proto/account_pb";
 import {
@@ -57,6 +58,12 @@ export function changeEmail(newEmail: string, currentPassword?: string) {
   return client.account.changeEmail(req);
 }
 
+export function setDoNotEmail(newValue: boolean) {
+  const req = new SetDoNotEmailReq();
+  req.setDoNotEmail(newValue);
+  return client.account.setDoNotEmail(req);
+}
+
 export async function confirmChangeEmail(resetToken: string) {
   const req = new ConfirmChangeEmailReq();
   req.setChangeEmailToken(resetToken);
diff --git a/app/web/service/notifications.ts b/app/web/service/notifications.ts
index 1ab1f215ad..82dec46157 100644
--- a/app/web/service/notifications.ts
+++ b/app/web/service/notifications.ts
@@ -1,5 +1,7 @@
 import {
+  GetDoNotEmailReq,
   GetNotificationSettingsReq,
+  SetDoNotEmailReq,
   SetNotificationSettingsReq,
 } from "proto/notifications_pb";
 import client from "service/client";
@@ -17,3 +19,15 @@ export async function setNotificationSettings(enableNewNotifications: boolean) {
   const res = await client.notifications.setNotificationSettings(req);
   return res.toObject();
 }
+
+export async function getDoNotEmail() {
+  const res = await client.notifications.getDoNotEmail(new GetDoNotEmailReq());
+  return res.toObject();
+}
+
+export async function setDoNotEmail(enableDoNotEmail: boolean) {
+  const req = new SetDoNotEmailReq();
+  req.setEnableDoNotEmail(enableDoNotEmail);
+  const res = await client.notifications.setDoNotEmail(req);
+  return res.toObject();
+}


Test patch:
diff --git a/app/backend/src/tests/test_api.py b/app/backend/src/tests/test_api.py
index 89fc2394d6..7f1d7aac21 100644
--- a/app/backend/src/tests/test_api.py
+++ b/app/backend/src/tests/test_api.py
@@ -10,7 +10,7 @@
 from couchers.models import FriendRelationship, FriendStatus
 from couchers.sql import couchers_select as select
 from couchers.utils import create_coordinate, to_aware_datetime
-from proto import api_pb2, jail_pb2
+from proto import api_pb2, jail_pb2, notifications_pb2
 from tests.test_fixtures import (  # noqa
     api_session,
     blocking_session,
@@ -19,6 +19,7 @@
     make_friends,
     make_user_block,
     make_user_invisible,
+    notifications_session,
     real_api_session,
     real_jail_session,
     testconfig,
@@ -283,6 +284,24 @@ def test_update_profile(db):
         assert not user_details.additional_information
 
 
+def test_update_profile_do_not_email(db):
+    user, token = generate_user()
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=True))
+
+    with api_session(token) as api:
+        with pytest.raises(grpc.RpcError) as e:
+            api.UpdateProfile(api_pb2.UpdateProfileReq(hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST))
+        assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
+        assert e.value.details() == errors.DO_NOT_EMAIL_CANNOT_HOST
+
+        with pytest.raises(grpc.RpcError) as e:
+            api.UpdateProfile(api_pb2.UpdateProfileReq(meetup_status=api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP))
+        assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
+        assert e.value.details() == errors.DO_NOT_EMAIL_CANNOT_MEET
+
+
 def test_language_abilities(db):
     user, token = generate_user()
 
diff --git a/app/backend/src/tests/test_email.py b/app/backend/src/tests/test_email.py
index c8985a9c26..1911fd64e2 100644
--- a/app/backend/src/tests/test_email.py
+++ b/app/backend/src/tests/test_email.py
@@ -41,7 +41,8 @@
     send_signup_email,
 )
 from couchers.utils import now
-from tests.test_fixtures import db, generate_user, testconfig  # noqa
+from proto import notifications_pb2
+from tests.test_fixtures import db, generate_user, notifications_session, session_scope, testconfig  # noqa
 
 
 @pytest.fixture(autouse=True)
@@ -527,6 +528,95 @@ def test_account_recovery_successful_email(db):
         assert "support@couchers.org" in html
 
 
+def test_do_not_email_security(db):
+    _, token = generate_user()
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=True))
+
+    # make sure we still get security emails
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+
+        with patch("couchers.email.queue_email") as mock:
+            password_reset_token = send_password_reset_email(session, user)
+
+        assert mock.call_count == 1
+        (sender_name, sender_email, recipient, subject, plain, html), _ = mock.call_args
+        assert recipient == user.email
+        assert "reset" in subject.lower()
+        assert password_reset_token.token in plain
+        assert password_reset_token.token in html
+        unique_string = "You asked for your password to be reset on Couchers.org."
+        assert unique_string in plain
+        assert unique_string in html
+        assert f"{config['BASE_URL']}/complete-password-reset?token={password_reset_token.token}" in plain
+        assert f"{config['BASE_URL']}/complete-password-reset?token={password_reset_token.token}" in html
+        assert "support@couchers.org" in plain
+        assert "support@couchers.org" in html
+
+        assert "/unsubscribe?payload=" not in plain
+        assert "/unsubscribe?payload=" not in html
+
+
+def test_do_not_email_non_security(db):
+    _, token = generate_user()
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=True))
+
+    with session_scope() as session:
+        to_user = session.execute(select(User)).scalar_one()
+        # little trick here to get the upload correctly without invalidating users
+        key = random_hex(32)
+        filename = random_hex(32) + ".jpg"
+        session.add(
+            Upload(
+                key=key,
+                filename=filename,
+                creator_user_id=to_user.id,
+            )
+        )
+        session.commit()
+        from_user, api_token_from = generate_user(avatar_key=key)
+        friend_relationship = FriendRelationship(from_user=from_user, to_user=to_user, status=FriendStatus.pending)
+        session.add(friend_relationship)
+
+        with patch("couchers.email.queue_email") as mock:
+            send_friend_request_email(friend_relationship)
+
+        assert mock.call_count == 0
+
+
+def test_do_not_email_non_security_unsublink(db):
+    _, token = generate_user()
+
+    with session_scope() as session:
+        to_user = session.execute(select(User)).scalar_one()
+        # little trick here to get the upload correctly without invalidating users
+        key = random_hex(32)
+        filename = random_hex(32) + ".jpg"
+        session.add(
+            Upload(
+                key=key,
+                filename=filename,
+                creator_user_id=to_user.id,
+            )
+        )
+        session.commit()
+        from_user, api_token_from = generate_user(avatar_key=key)
+        friend_relationship = FriendRelationship(from_user=from_user, to_user=to_user, status=FriendStatus.pending)
+        session.add(friend_relationship)
+
+        with patch("couchers.email.queue_email") as mock:
+            send_friend_request_email(friend_relationship)
+
+        assert mock.call_count == 1
+        (_, _, _, _, plain, html), _ = mock.call_args
+        assert "/unsubscribe?payload=" in plain
+        assert "/unsubscribe?payload=" in html
+
+
 def test_email_prefix_config(db, monkeypatch):
     user, token = generate_user()
     user.new_email = f"{random_hex(12)}@couchers.org.invalid"
diff --git a/app/backend/src/tests/test_notifications.py b/app/backend/src/tests/test_notifications.py
index 6ed86fc89b..449bc8557b 100644
--- a/app/backend/src/tests/test_notifications.py
+++ b/app/backend/src/tests/test_notifications.py
@@ -9,7 +9,15 @@
 from couchers.crypto import b64decode
 from couchers.jobs.enqueue import queue_job
 from couchers.jobs.worker import process_job
-from couchers.models import Notification, NotificationDelivery, NotificationDeliveryType, NotificationTopicAction, User
+from couchers.models import (
+    HostingStatus,
+    MeetupStatus,
+    Notification,
+    NotificationDelivery,
+    NotificationDeliveryType,
+    NotificationTopicAction,
+    User,
+)
 from couchers.notifications.notify import notify
 from couchers.sql import couchers_select as select
 from proto import auth_pb2, notifications_pb2
@@ -245,3 +253,75 @@ def test_unsubscribe(db):
             pass
 
     assert mock.call_count == 0
+
+
+def test_notifications_do_not_email(db):
+    _, token = generate_user()
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=True))
+
+        with pytest.raises(grpc.RpcError) as e:
+            notifications.SetNotificationSettings(
+                notifications_pb2.SetNotificationSettingsReq(enable_new_notifications=True)
+            )
+        assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
+        assert e.value.details() == errors.DO_NOT_EMAIL_CANNOT_ENABLE_NEW_NOTIFICATIONS
+
+
+def test_GetDoNotEmail(db):
+    _, token = generate_user()
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        user.do_not_email = False
+
+    with notifications_session(token) as notifications:
+        res = notifications.GetDoNotEmail(notifications_pb2.GetDoNotEmailReq())
+    assert not res.do_not_email_enabled
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        user.do_not_email = True
+        user.hosting_status = HostingStatus.cant_host
+        user.meetup_status = MeetupStatus.does_not_want_to_meetup
+        user.new_notifications_enabled = False
+
+    with notifications_session(token) as notifications:
+        res = notifications.GetDoNotEmail(notifications_pb2.GetDoNotEmailReq())
+    assert res.do_not_email_enabled
+
+
+def test_SetDoNotEmail(db):
+    _, token = generate_user()
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        user.do_not_email = False
+        user.hosting_status = HostingStatus.can_host
+        user.meetup_status = MeetupStatus.wants_to_meetup
+        user.new_notifications_enabled = True
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=False))
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        assert not user.do_not_email
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=True))
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        assert user.do_not_email
+        assert user.hosting_status == HostingStatus.cant_host
+        assert user.meetup_status == MeetupStatus.does_not_want_to_meetup
+        assert user.new_notifications_enabled == False
+
+    with notifications_session(token) as notifications:
+        notifications.SetDoNotEmail(notifications_pb2.SetDoNotEmailReq(enable_do_not_email=False))
+
+    with session_scope() as session:
+        user = session.execute(select(User)).scalar_one()
+        assert not user.do_not_email
diff --git a/app/web/features/auth/email/ChangeEmail.test.tsx b/app/web/features/auth/email/ChangeEmail.test.tsx
index 6cf4c79aeb..0ba1b4f868 100644
--- a/app/web/features/auth/email/ChangeEmail.test.tsx
+++ b/app/web/features/auth/email/ChangeEmail.test.tsx
@@ -26,6 +26,7 @@ const accountInfo = {
   hasStrongVerification: false,
   birthdateVerificationStatus: 1,
   genderVerificationStatus: 3,
+  doNotEmail: false,
 };
 
 const accountWithLink = {
@@ -40,6 +41,7 @@ const accountWithLink = {
   hasStrongVerification: true,
   birthdateVerificationStatus: 2,
   genderVerificationStatus: 2,
+  doNotEmail: false,
 };
 
 describe("ChangeEmail", () => {
diff --git a/app/web/features/auth/password/ChangePassword.test.tsx b/app/web/features/auth/password/ChangePassword.test.tsx
index 8cfdd37ae8..c77d2f5741 100644
--- a/app/web/features/auth/password/ChangePassword.test.tsx
+++ b/app/web/features/auth/password/ChangePassword.test.tsx
@@ -26,6 +26,7 @@ const accountWithPassword = {
   hasStrongVerification: true,
   birthdateVerificationStatus: 2,
   genderVerificationStatus: 2,
+  doNotEmail: false,
 };
 
 const accountWithLink = {
@@ -40,6 +41,7 @@ const accountWithLink = {
   hasStrongVerification: true,
   birthdateVerificationStatus: 2,
   genderVerificationStatus: 2,
+  doNotEmail: false,
 };
 
 describe("ChangePassword", () => {