models: Replace ScheduledJob with ScheduledEmail.

ScheduledJob was written for much more generality than it ended up being
used for. Currently it is used by send_future_email, and nothing
else. Tailoring the model to emails in particular will make it easier to do
things like selectively clear emails when people unsubscribe from particular
email types, or seamlessly handle using the same email on multiple realms.
This commit is contained in:
Rishi Gupta
2017-07-02 12:10:41 -07:00
committed by Tim Abbott
parent dd58406f03
commit aa845e7f60
14 changed files with 100 additions and 51 deletions

View File

@@ -22,7 +22,7 @@ with only a few things you need to know to get started.
* All email is eventually sent by `zerver.lib.send_email.send_email`. There * All email is eventually sent by `zerver.lib.send_email.send_email`. There
are several other functions in `zerver.lib.send_email`, but all of them are several other functions in `zerver.lib.send_email`, but all of them
eventually call the `send_email` function. The most interesting one is eventually call the `send_email` function. The most interesting one is
`send_future_email`. The `ScheduledJob` entries are eventually processed `send_future_email`. The `ScheduledEmail` entries are eventually processed
by a cron job that runs `zerver/management/commands/deliver_email.py`. by a cron job that runs `zerver/management/commands/deliver_email.py`.
* A good way to find a bunch of example email pathways is to `git grep` for * A good way to find a bunch of example email pathways is to `git grep` for
`zerver/emails` in the `zerver/` directory. `zerver/emails` in the `zerver/` directory.

View File

@@ -43,7 +43,7 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity,
get_user_profile_by_email, get_user, get_stream_cache_key, \ get_user_profile_by_email, get_user, get_stream_cache_key, \
UserActivityInterval, get_active_user_dicts_in_realm, get_active_streams, \ UserActivityInterval, get_active_user_dicts_in_realm, get_active_streams, \
realm_filters_for_realm, RealmFilter, receives_offline_notifications, \ realm_filters_for_realm, RealmFilter, receives_offline_notifications, \
ScheduledJob, get_owned_bot_dicts, \ get_owned_bot_dicts, \
get_old_unclaimed_attachments, get_cross_realm_emails, \ get_old_unclaimed_attachments, get_cross_realm_emails, \
Reaction, EmailChangeStatus, CustomProfileField, \ Reaction, EmailChangeStatus, CustomProfileField, \
custom_profile_fields_for_realm, \ custom_profile_fields_for_realm, \

View File

@@ -209,7 +209,7 @@ def handle_digest_email(user_profile_id, cutoff):
if enough_traffic(context["unread_pms"], context["hot_conversations"], if enough_traffic(context["unread_pms"], context["hot_conversations"],
new_streams_count, new_users_count): new_streams_count, new_users_count):
logger.info("Sending digest email for %s" % (user_profile.email,)) logger.info("Sending digest email for %s" % (user_profile.email,))
# Send now, as a ScheduledJob # Send now, as a ScheduledEmail
send_future_email('zerver/emails/digest', to_user_id=user_profile.id, send_future_email('zerver/emails/digest', to_user_id=user_profile.id,
from_name="Zulip Digest", from_address=FromAddress.NOREPLY, from_name="Zulip Digest", from_address=FromAddress.NOREPLY,
context=context) context=context)

View File

@@ -77,7 +77,7 @@ ALL_ZERVER_TABLES = [
'zerver_realmemoji', 'zerver_realmemoji',
'zerver_realmfilter', 'zerver_realmfilter',
'zerver_recipient', 'zerver_recipient',
'zerver_scheduledjob', 'zerver_scheduledemail',
'zerver_stream', 'zerver_stream',
'zerver_subscription', 'zerver_subscription',
'zerver_useractivity', 'zerver_useractivity',
@@ -96,7 +96,7 @@ NON_EXPORTED_TABLES = [
'zerver_preregistrationuser', 'zerver_preregistrationuser',
'zerver_preregistrationuser_streams', 'zerver_preregistrationuser_streams',
'zerver_pushdevicetoken', 'zerver_pushdevicetoken',
'zerver_scheduledjob', 'zerver_scheduledemail',
'zerver_userprofile_groups', 'zerver_userprofile_groups',
'zerver_userprofile_user_permissions', 'zerver_userprofile_user_permissions',
] ]

View File

@@ -12,7 +12,7 @@ from zerver.lib.send_email import send_future_email, \
from zerver.lib.queue import queue_json_publish from zerver.lib.queue import queue_json_publish
from zerver.models import ( from zerver.models import (
Recipient, Recipient,
ScheduledJob, ScheduledEmail,
UserMessage, UserMessage,
Stream, Stream,
get_display_recipient, get_display_recipient,
@@ -398,7 +398,7 @@ def clear_followup_emails_queue(email):
""" """
Clear out queued emails that would otherwise be sent to a specific email address. Clear out queued emails that would otherwise be sent to a specific email address.
""" """
items = ScheduledJob.objects.filter(type=ScheduledJob.EMAIL, filter_string__iexact = email) items = ScheduledEmail.objects.filter(address__iexact=email)
items.delete() items.delete()
def log_digest_event(msg): def log_digest_event(msg):

View File

@@ -2,7 +2,8 @@ from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template import loader from django.template import loader
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from zerver.models import UserProfile, ScheduledJob, get_user_profile_by_id from zerver.models import UserProfile, ScheduledEmail, get_user_profile_by_id, \
EMAIL_TYPES
import datetime import datetime
from email.utils import parseaddr, formataddr from email.utils import parseaddr, formataddr
@@ -59,6 +60,8 @@ def build_email(template_prefix, to_user_id=None, to_email=None, from_name=None,
class EmailNotDeliveredException(Exception): class EmailNotDeliveredException(Exception):
pass pass
# When changing the arguments to this function, you may need to write a
# migration to change or remove any emails in ScheduledEmail.
def send_email(template_prefix, to_user_id=None, to_email=None, from_name=None, def send_email(template_prefix, to_user_id=None, to_email=None, from_name=None,
from_address=None, reply_to_email=None, context={}): from_address=None, reply_to_email=None, context={}):
# type: (str, Optional[int], Optional[Text], Optional[Text], Optional[Text], Optional[Text], Dict[str, Any]) -> None # type: (str, Optional[int], Optional[Text], Optional[Text], Optional[Text], Optional[Text], Dict[str, Any]) -> None
@@ -82,6 +85,9 @@ def send_future_email(template_prefix, to_user_id=None, to_email=None, from_name
to_user_id = None to_user_id = None
email_fields = {'template_prefix': template_prefix, 'to_user_id': to_user_id, 'to_email': to_email, email_fields = {'template_prefix': template_prefix, 'to_user_id': to_user_id, 'to_email': to_email,
'from_name': from_name, 'from_address': from_address, 'context': context} 'from_name': from_name, 'from_address': from_address, 'context': context}
ScheduledJob.objects.create(type=ScheduledJob.EMAIL, filter_string=parseaddr(to_email)[1], template_name = template_prefix.split('/')[-1]
data=ujson.dumps(email_fields), ScheduledEmail.objects.create(
scheduled_timestamp=timezone_now() + delay) address=parseaddr(to_email)[1],
type=EMAIL_TYPES[template_name],
scheduled_timestamp=timezone_now() + delay,
data=ujson.dumps(email_fields))

View File

@@ -15,7 +15,7 @@ from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.html import format_html from django.utils.html import format_html
from zerver.models import ScheduledJob from zerver.models import ScheduledEmail
from zerver.lib.context_managers import lockfile from zerver.lib.context_managers import lockfile
from zerver.lib.send_email import send_email, EmailNotDeliveredException from zerver.lib.send_email import send_email, EmailNotDeliveredException
@@ -58,8 +58,7 @@ Usage: ./manage.py deliver_email
with lockfile("/tmp/zulip_email_deliver.lockfile"): with lockfile("/tmp/zulip_email_deliver.lockfile"):
while True: while True:
email_jobs_to_deliver = ScheduledJob.objects.filter(type=ScheduledJob.EMAIL, email_jobs_to_deliver = ScheduledEmail.objects.filter(scheduled_timestamp__lte=timezone_now())
scheduled_timestamp__lte=timezone_now())
if email_jobs_to_deliver: if email_jobs_to_deliver:
for job in email_jobs_to_deliver: for job in email_jobs_to_deliver:
try: try:

View File

@@ -35,7 +35,7 @@ class Command(BaseCommand):
* Sessions (everyone will need to login again post-export) * Sessions (everyone will need to login again post-export)
* Users' passwords and API keys (users will need to use SSO or reset password) * Users' passwords and API keys (users will need to use SSO or reset password)
* Mobile tokens for APNS/GCM (users will need to reconnect their mobile devices) * Mobile tokens for APNS/GCM (users will need to reconnect their mobile devices)
* ScheduledJob (Not relevant on a new server) * ScheduledEmail (Not relevant on a new server)
* Deployment (Unused) * Deployment (Unused)
* third_party_api_results cache (this means rerending all old * third_party_api_results cache (this means rerending all old
messages could be expensive) messages could be expensive)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """
Shows backlog count of ScheduledJobs of type Email Shows backlog count of ScheduledEmail
""" """
from __future__ import absolute_import from __future__ import absolute_import
@@ -12,12 +12,12 @@ from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from zerver.models import ScheduledJob from zerver.models import ScheduledEmail
from datetime import datetime, timedelta from datetime import datetime, timedelta
class Command(BaseCommand): class Command(BaseCommand):
help = """Shows backlog count of ScheduledJobs of type Email help = """Shows backlog count of ScheduledEmail
(The number of currently overdue (by at least a minute) email jobs) (The number of currently overdue (by at least a minute) email jobs)
This is run as part of the nagios health check for the deliver_email command. This is run as part of the nagios health check for the deliver_email command.
@@ -27,5 +27,5 @@ Usage: ./manage.py print_email_delivery_backlog
def handle(self, *args, **options): def handle(self, *args, **options):
# type: (*Any, **Any) -> None # type: (*Any, **Any) -> None
print(len(ScheduledJob.objects.filter(type=ScheduledJob.EMAIL, print(ScheduledEmail.objects.filter(
scheduled_timestamp__lte=timezone_now()-timedelta(minutes=1)))) scheduled_timestamp__lte=timezone_now()-timedelta(minutes=1)).count())

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-07-11 23:41
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('zerver', '0091_realm_allow_edit_history'),
]
operations = [
migrations.CreateModel(
name='ScheduledEmail',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('scheduled_timestamp', models.DateTimeField(db_index=True)),
('data', models.TextField()),
('address', models.EmailField(db_index=True, max_length=254, null=True)),
('type', models.PositiveSmallIntegerField()),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='ScheduledJob',
),
]

View File

@@ -1687,18 +1687,33 @@ class DefaultStream(models.Model):
class Meta(object): class Meta(object):
unique_together = ("realm", "stream") unique_together = ("realm", "stream")
class ScheduledJob(models.Model): class AbstractScheduledJob(models.Model):
scheduled_timestamp = models.DateTimeField(auto_now_add=False, null=False) # type: datetime.datetime scheduled_timestamp = models.DateTimeField(db_index=True) # type: datetime.datetime
type = models.PositiveSmallIntegerField() # type: int # JSON representation of arguments to consumer
# Valid types are {email}
# for EMAIL, filter_string is recipient_email
EMAIL = 1
# JSON representation of the job's data. Be careful, as we are not relying on Django to do validation
data = models.TextField() # type: Text data = models.TextField() # type: Text
# Kind if like a ForeignKey, but table is determined by type.
filter_id = models.IntegerField(null=True) # type: Optional[int] class Meta(object):
filter_string = models.CharField(max_length=100) # type: Text abstract = True
class ScheduledEmail(AbstractScheduledJob):
# Exactly one of user or address should be set. These are used to
# filter the set of ScheduledEmails.
user = models.ForeignKey(UserProfile, null=True, on_delete=CASCADE) # type: UserProfile
# Just the address part of a full "name <address>" email address
address = models.EmailField(null=True, db_index=True) # type: Text
# Valid types are below
WELCOME = 1
DIGEST = 2
INVITATION_REMINDER = 3
type = models.PositiveSmallIntegerField() # type: int
EMAIL_TYPES = {
'followup_day1': ScheduledEmail.WELCOME,
'followup_day2': ScheduledEmail.WELCOME,
'digest': ScheduledEmail.DIGEST,
'invitation_reminder': ScheduledEmail.INVITATION_REMINDER,
}
class RealmAuditLog(models.Model): class RealmAuditLog(models.Model):
realm = models.ForeignKey(Realm, on_delete=CASCADE) # type: Realm realm = models.ForeignKey(Realm, on_delete=CASCADE) # type: Realm

View File

@@ -17,7 +17,7 @@ from zerver.lib.actions import (
from zerver.lib.send_email import send_future_email from zerver.lib.send_email import send_future_email
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import tornado_redirected_to_list from zerver.lib.test_helpers import tornado_redirected_to_list
from zerver.models import get_realm, Realm, UserProfile, ScheduledJob from zerver.models import get_realm, Realm, UserProfile, ScheduledEmail
class RealmTest(ZulipTestCase): class RealmTest(ZulipTestCase):
def assert_user_profile_cache_gets_new_name(self, user_profile, new_realm_name): def assert_user_profile_cache_gets_new_name(self, user_profile, new_realm_name):
@@ -144,10 +144,10 @@ class RealmTest(ZulipTestCase):
def test_do_deactivate_realm_clears_scheduled_jobs(self): def test_do_deactivate_realm_clears_scheduled_jobs(self):
# type: () -> None # type: () -> None
user = self.example_user('hamlet') user = self.example_user('hamlet')
send_future_email('template_prefix', to_user_id=user.id, delay=datetime.timedelta(hours=1)) send_future_email('zerver/emails/followup_day1', to_user_id=user.id, delay=datetime.timedelta(hours=1))
self.assertEqual(ScheduledJob.objects.count(), 1) self.assertEqual(ScheduledEmail.objects.count(), 1)
do_deactivate_realm(user.realm) do_deactivate_realm(user.realm)
self.assertEqual(ScheduledJob.objects.count(), 0) self.assertEqual(ScheduledEmail.objects.count(), 0)
def test_do_deactivate_realm_on_deactived_realm(self): def test_do_deactivate_realm_on_deactived_realm(self):
# type: () -> None # type: () -> None

View File

@@ -27,8 +27,8 @@ from zerver.models import (
get_unique_open_realm, get_unique_non_system_realm, get_unique_open_realm, get_unique_non_system_realm,
completely_open, get_recipient, completely_open, get_recipient,
PreregistrationUser, Realm, RealmDomain, Recipient, Message, PreregistrationUser, Realm, RealmDomain, Recipient, Message,
ScheduledJob, UserProfile, UserMessage, ScheduledEmail, UserProfile, UserMessage,
Stream, Subscription, ScheduledJob, flush_per_request_caches Stream, Subscription, flush_per_request_caches
) )
from zerver.lib.actions import ( from zerver.lib.actions import (
set_default_streams, set_default_streams,
@@ -708,8 +708,7 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
send_future_email( send_future_email(
"zerver/emails/invitation_reminder", to_email=data["email"], "zerver/emails/invitation_reminder", to_email=data["email"],
from_address=FromAddress.NOREPLY, context=context) from_address=FromAddress.NOREPLY, context=context)
email_jobs_to_deliver = ScheduledJob.objects.filter( email_jobs_to_deliver = ScheduledEmail.objects.filter(
type=ScheduledJob.EMAIL,
scheduled_timestamp__lte=timezone_now()) scheduled_timestamp__lte=timezone_now())
self.assertEqual(len(email_jobs_to_deliver), 1) self.assertEqual(len(email_jobs_to_deliver), 1)
email_count = len(outbox) email_count = len(outbox)
@@ -794,8 +793,7 @@ class EmailUnsubscribeTests(ZulipTestCase):
user_profile = self.example_user('hamlet') user_profile = self.example_user('hamlet')
# Simulate a new user signing up, which enqueues 2 welcome e-mails. # Simulate a new user signing up, which enqueues 2 welcome e-mails.
enqueue_welcome_emails(user_profile.id) enqueue_welcome_emails(user_profile.id)
self.assertEqual(2, len(ScheduledJob.objects.filter( self.assertEqual(2, ScheduledEmail.objects.filter(address__iexact=email).count())
type=ScheduledJob.EMAIL, filter_string__iexact=email)))
# Simulate unsubscribing from the welcome e-mails. # Simulate unsubscribing from the welcome e-mails.
unsubscribe_link = one_click_unsubscribe_link(user_profile, "welcome") unsubscribe_link = one_click_unsubscribe_link(user_profile, "welcome")
@@ -803,8 +801,7 @@ class EmailUnsubscribeTests(ZulipTestCase):
# The welcome email jobs are no longer scheduled. # The welcome email jobs are no longer scheduled.
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
self.assertEqual(0, len(ScheduledJob.objects.filter( self.assertEqual(0, ScheduledEmail.objects.filter(address__iexact=email).count())
type=ScheduledJob.EMAIL, filter_string__iexact=email)))
def test_digest_unsubscribe(self): def test_digest_unsubscribe(self):
# type: () -> None # type: () -> None
@@ -824,8 +821,7 @@ class EmailUnsubscribeTests(ZulipTestCase):
'new_users': [], 'new_streams': {'plain': []}, 'unsubscribe_link': ''} 'new_users': [], 'new_streams': {'plain': []}, 'unsubscribe_link': ''}
send_future_email('zerver/emails/digest', to_user_id=user_profile.id, context=context) send_future_email('zerver/emails/digest', to_user_id=user_profile.id, context=context)
self.assertEqual(1, len(ScheduledJob.objects.filter( self.assertEqual(1, ScheduledEmail.objects.filter(address__iexact=email).count())
type=ScheduledJob.EMAIL, filter_string__iexact=email)))
# Simulate unsubscribing from digest e-mails. # Simulate unsubscribing from digest e-mails.
unsubscribe_link = one_click_unsubscribe_link(user_profile, "digest") unsubscribe_link = one_click_unsubscribe_link(user_profile, "digest")
@@ -836,8 +832,7 @@ class EmailUnsubscribeTests(ZulipTestCase):
# Circumvent user_profile caching. # Circumvent user_profile caching.
user_profile = UserProfile.objects.get(email=self.example_email("hamlet")) user_profile = UserProfile.objects.get(email=self.example_email("hamlet"))
self.assertFalse(user_profile.enable_digest_emails) self.assertFalse(user_profile.enable_digest_emails)
self.assertEqual(0, len(ScheduledJob.objects.filter( self.assertEqual(0, ScheduledEmail.objects.filter(address__iexact=email).count())
type=ScheduledJob.EMAIL, filter_string__iexact=email)))
class RealmCreationTest(ZulipTestCase): class RealmCreationTest(ZulipTestCase):

View File

@@ -22,7 +22,7 @@ from zerver.lib.test_runner import slow
from zerver.models import UserProfile, Recipient, \ from zerver.models import UserProfile, Recipient, \
Realm, RealmDomain, UserActivity, \ Realm, RealmDomain, UserActivity, \
get_user, get_realm, get_client, get_stream, \ get_user, get_realm, get_client, get_stream, \
Message, get_context_for_message, ScheduledJob Message, get_context_for_message, ScheduledEmail
from zerver.lib.avatar import avatar_url from zerver.lib.avatar import avatar_url
from zerver.lib.email_mirror import create_missed_message_address from zerver.lib.email_mirror import create_missed_message_address
@@ -345,10 +345,10 @@ class ActivateTest(ZulipTestCase):
def test_clear_scheduled_jobs(self): def test_clear_scheduled_jobs(self):
# type: () -> None # type: () -> None
user = self.example_user('hamlet') user = self.example_user('hamlet')
send_future_email('template_prefix', to_user_id=user.id, delay=datetime.timedelta(hours=1)) send_future_email('zerver/emails/followup_day1', to_user_id=user.id, delay=datetime.timedelta(hours=1))
self.assertEqual(ScheduledJob.objects.count(), 1) self.assertEqual(ScheduledEmail.objects.count(), 1)
do_deactivate_user(user) do_deactivate_user(user)
self.assertEqual(ScheduledJob.objects.count(), 0) self.assertEqual(ScheduledEmail.objects.count(), 0)
class GetProfileTest(ZulipTestCase): class GetProfileTest(ZulipTestCase):