Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not email option #4277

Merged
merged 12 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/backend/proto/internal/unsubscribe.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,5 +33,6 @@ message UnsubscribePayload {
MuteAll all = 2;
UnsubscribeTopicKey topic_key = 3;
UnsubscribeTopicAction topic_action = 4;
DoNotEmail do_not_email = 5;
}
}
31 changes: 26 additions & 5 deletions app/backend/src/couchers/email/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from html import escape
from pathlib import Path

Expand All @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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"],
Expand All @@ -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,
)
3 changes: 3 additions & 0 deletions app/backend/src/couchers/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
12 changes: 6 additions & 6 deletions app/backend/src/couchers/jobs/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 8 additions & 0 deletions app/backend/src/couchers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("''"))
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/backend/src/couchers/notifications/unsubscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/backend/src/couchers/servicers/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/couchers/servicers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions app/backend/src/couchers/servicers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
19 changes: 18 additions & 1 deletion app/backend/src/couchers/servicers/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading