streams: Add 'ChannelEmailAddress' model.

This commit removes the 'email_token' field from the
'Stream' model, introduces a new model 'ChannelEmailAddress',
and backfills it.

This is a prep work towards our plan to generate unique
channel emails for different users, which can be used
to check post permissions at message send time.
This commit is contained in:
Prakhar Pratyush 2024-11-19 21:56:11 +05:30
parent 65f05794ee
commit 08be671c37
9 changed files with 207 additions and 42 deletions

View File

@ -59,6 +59,7 @@ from zerver.lib.users import (
from zerver.models import ( from zerver.models import (
ArchivedAttachment, ArchivedAttachment,
Attachment, Attachment,
ChannelEmailAddress,
DefaultStream, DefaultStream,
DefaultStreamGroup, DefaultStreamGroup,
Message, Message,
@ -90,6 +91,8 @@ def do_deactivate_stream(stream: Stream, *, acting_user: UserProfile | None) ->
stream.deactivated = True stream.deactivated = True
stream.save(update_fields=["deactivated"]) stream.save(update_fields=["deactivated"])
ChannelEmailAddress.objects.filter(channel=stream).update(deactivated=True)
assert stream.recipient_id is not None assert stream.recipient_id is not None
if was_web_public: if was_web_public:
assert was_public assert was_public
@ -201,6 +204,8 @@ def do_unarchive_stream(stream: Stream, new_name: str, *, acting_user: UserProfi
] ]
) )
ChannelEmailAddress.objects.filter(channel=stream).update(deactivated=False)
# Update caches # Update caches
cache_set(display_recipient_cache_key(stream.recipient_id), new_name) cache_set(display_recipient_cache_key(stream.recipient_id), new_name)
messages = Message.objects.filter( messages = Message.objects.filter(

View File

@ -31,7 +31,15 @@ from zerver.lib.send_email import FromAddress
from zerver.lib.streams import access_stream_for_send_message from zerver.lib.streams import access_stream_for_send_message
from zerver.lib.string_validation import is_character_printable from zerver.lib.string_validation import is_character_printable
from zerver.lib.upload import upload_message_attachment from zerver.lib.upload import upload_message_attachment
from zerver.models import Message, MissedMessageEmailAddress, Realm, Recipient, Stream, UserProfile from zerver.models import (
ChannelEmailAddress,
Message,
MissedMessageEmailAddress,
Realm,
Recipient,
Stream,
UserProfile,
)
from zerver.models.clients import get_client from zerver.models.clients import get_client
from zerver.models.streams import get_stream_by_id_in_realm from zerver.models.streams import get_stream_by_id_in_realm
from zerver.models.users import get_system_bot, get_user_profile_by_id from zerver.models.users import get_system_bot, get_user_profile_by_id
@ -359,11 +367,13 @@ def decode_stream_email_address(email: str) -> tuple[Stream, dict[str, bool]]:
token, options = decode_email_address(email) token, options = decode_email_address(email)
try: try:
stream = Stream.objects.get(email_token=token) channel_email_address = ChannelEmailAddress.objects.select_related("channel").get(
except Stream.DoesNotExist: email_token=token
)
except ChannelEmailAddress.DoesNotExist:
raise ZulipEmailForwardError("Bad stream token from email recipient " + email) raise ZulipEmailForwardError("Bad stream token from email recipient " + email)
return stream, options return channel_email_address.channel, options
def find_emailgateway_recipient(message: EmailMessage) -> str: def find_emailgateway_recipient(message: EmailMessage) -> str:

View File

@ -5,7 +5,8 @@ from typing import Any
from django.conf import settings from django.conf import settings
from django.utils.text import slugify from django.utils.text import slugify
from zerver.models import Stream from zerver.models import ChannelEmailAddress, Stream
from zerver.models.users import get_system_bot
def default_option_handler_factory(address_option: str) -> Callable[[dict[str, Any]], None]: def default_option_handler_factory(address_option: str) -> Callable[[dict[str, Any]], None]:
@ -48,7 +49,13 @@ def get_email_gateway_message_string_from_address(address: str) -> str:
def encode_email_address(stream: Stream, show_sender: bool = False) -> str: def encode_email_address(stream: Stream, show_sender: bool = False) -> str:
return encode_email_address_helper(stream.name, stream.email_token, show_sender) channel_email_address, ignored = ChannelEmailAddress.objects.get_or_create(
realm=stream.realm,
channel=stream,
creator=stream.creator,
sender=get_system_bot(settings.EMAIL_GATEWAY_BOT, stream.realm_id),
)
return encode_email_address_helper(stream.name, channel_email_address.email_token, show_sender)
def encode_email_address_helper(name: str, email_token: str, show_sender: bool = False) -> str: def encode_email_address_helper(name: str, email_token: str, show_sender: bool = False) -> str:

View File

@ -142,6 +142,7 @@ ALL_ZULIP_TABLES = {
"zerver_archivetransaction", "zerver_archivetransaction",
"zerver_botconfigdata", "zerver_botconfigdata",
"zerver_botstoragedata", "zerver_botstoragedata",
"zerver_channelemailaddress",
"zerver_client", "zerver_client",
"zerver_customprofilefield", "zerver_customprofilefield",
"zerver_customprofilefieldvalue", "zerver_customprofilefieldvalue",
@ -271,6 +272,9 @@ NON_EXPORTED_TABLES = {
# The importer cannot trust ImageAttachment objects anyway and needs to check # The importer cannot trust ImageAttachment objects anyway and needs to check
# and process images for thumbnailing on its own. # and process images for thumbnailing on its own.
"zerver_imageattachment", "zerver_imageattachment",
# ChannelEmailAddress entries are low value to export since
# channel email addresses include the server's hostname.
"zerver_channelemailaddress",
# For any tables listed below here, it's a bug that they are not present in the export. # For any tables listed below here, it's a bug that they are not present in the export.
} }
@ -905,7 +909,6 @@ def get_realm_config() -> Config:
stream_config = Config( stream_config = Config(
table="zerver_stream", table="zerver_stream",
model=Stream, model=Stream,
exclude=["email_token"],
normal_parent=realm_config, normal_parent=realm_config,
include_rows="realm_id__in", include_rows="realm_id__in",
) )
@ -2255,7 +2258,6 @@ def get_single_user_config() -> Config:
virtual_parent=recipient_config, virtual_parent=recipient_config,
id_source=("zerver_recipient", "type_id"), id_source=("zerver_recipient", "type_id"),
source_filter=lambda r: r["type"] == Recipient.STREAM, source_filter=lambda r: r["type"] == Recipient.STREAM,
exclude=["email_token"],
) )
Config( Config(

View File

@ -0,0 +1,119 @@
# Generated by Django 5.0.9 on 2024-11-21 09:30
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import connection, migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from psycopg2.sql import SQL
from zerver.models.streams import generate_email_token_for_stream
def backfill_channelemailaddress(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
UserProfile = apps.get_model("zerver", "UserProfile")
with connection.cursor() as cursor:
cursor.execute(SQL("SELECT MAX(id) FROM zerver_stream;"))
(max_id,) = cursor.fetchone()
if max_id is None:
return
BATCH_SIZE = 10000
max_id += BATCH_SIZE / 2
lower_id_bound = 0
mail_gateway_bot_id = UserProfile.objects.get(email__iexact=settings.EMAIL_GATEWAY_BOT).id
while lower_id_bound < max_id:
upper_id_bound = min(lower_id_bound + BATCH_SIZE, max_id)
with connection.cursor() as cursor:
query = SQL("""
INSERT INTO zerver_channelemailaddress (realm_id, channel_id, creator_id, sender_id, email_token, date_created, deactivated)
SELECT realm_id, id, creator_id, %(mail_gateway_bot_id)s, email_token, date_created, deactivated
FROM zerver_stream
WHERE id > %(lower_id_bound)s AND id <= %(upper_id_bound)s;
""")
cursor.execute(
query,
{
"mail_gateway_bot_id": mail_gateway_bot_id,
"lower_id_bound": lower_id_bound,
"upper_id_bound": upper_id_bound,
},
)
print(f"Processed {upper_id_bound} / {max_id}")
lower_id_bound += BATCH_SIZE
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0628_remove_realm_invite_to_realm_policy"),
]
operations = [
migrations.CreateModel(
name="ChannelEmailAddress",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"email_token",
models.CharField(
db_index=True,
default=generate_email_token_for_stream,
max_length=32,
unique=True,
),
),
("date_created", models.DateTimeField(default=django.utils.timezone.now)),
("deactivated", models.BooleanField(default=False)),
(
"channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.stream"
),
),
(
"creator",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
(
"sender",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("channel", "creator", "sender")},
},
),
migrations.RunPython(
backfill_channelemailaddress, reverse_code=migrations.RunPython.noop, elidable=True
),
migrations.RemoveField(
model_name="stream",
name="email_token",
),
]

View File

@ -60,6 +60,7 @@ from zerver.models.scheduled_jobs import ScheduledMessage as ScheduledMessage
from zerver.models.scheduled_jobs import ( from zerver.models.scheduled_jobs import (
ScheduledMessageNotificationEmail as ScheduledMessageNotificationEmail, ScheduledMessageNotificationEmail as ScheduledMessageNotificationEmail,
) )
from zerver.models.streams import ChannelEmailAddress as ChannelEmailAddress
from zerver.models.streams import DefaultStream as DefaultStream from zerver.models.streams import DefaultStream as DefaultStream
from zerver.models.streams import DefaultStreamGroup as DefaultStreamGroup from zerver.models.streams import DefaultStreamGroup as DefaultStreamGroup
from zerver.models.streams import Stream as Stream from zerver.models.streams import Stream as Stream

View File

@ -111,15 +111,6 @@ class Stream(models.Model):
# and the reason for denormalizing field is performance. # and the reason for denormalizing field is performance.
is_in_zephyr_realm = models.BooleanField(default=False) is_in_zephyr_realm = models.BooleanField(default=False)
# Used by the e-mail forwarder. The e-mail RFC specifies a maximum
# e-mail length of 254, and our max stream length is 30, so we
# have plenty of room for the token.
email_token = models.CharField(
max_length=32,
default=generate_email_token_for_stream,
unique=True,
)
# For old messages being automatically deleted. # For old messages being automatically deleted.
# Value NULL means "use retention policy of the realm". # Value NULL means "use retention policy of the realm".
# Value -1 means "disable retention policy for this stream unconditionally". # Value -1 means "disable retention policy for this stream unconditionally".
@ -175,7 +166,6 @@ class Stream(models.Model):
# Stream fields included whenever a Stream object is provided to # Stream fields included whenever a Stream object is provided to
# Zulip clients via the API. A few details worth noting: # Zulip clients via the API. A few details worth noting:
# * "id" is represented as "stream_id" in most API interfaces. # * "id" is represented as "stream_id" in most API interfaces.
# * "email_token" is not realm-public and thus is not included here.
# * is_in_zephyr_realm is a backend-only optimization. # * is_in_zephyr_realm is a backend-only optimization.
# * "deactivated" streams are filtered from the API entirely. # * "deactivated" streams are filtered from the API entirely.
# * "realm" and "recipient" are not exposed to clients via the API. # * "realm" and "recipient" are not exposed to clients via the API.
@ -400,3 +390,26 @@ class DefaultStreamGroup(models.Model):
def get_default_stream_groups(realm: Realm) -> QuerySet[DefaultStreamGroup]: def get_default_stream_groups(realm: Realm) -> QuerySet[DefaultStreamGroup]:
return DefaultStreamGroup.objects.filter(realm=realm) return DefaultStreamGroup.objects.filter(realm=realm)
class ChannelEmailAddress(models.Model):
realm = models.ForeignKey(Realm, on_delete=CASCADE)
channel = models.ForeignKey(Stream, on_delete=CASCADE)
creator = models.ForeignKey(UserProfile, null=True, on_delete=CASCADE, related_name="+")
sender = models.ForeignKey(UserProfile, on_delete=CASCADE, related_name="+")
# Used by the e-mail forwarder. The e-mail RFC specifies a maximum
# e-mail length of 254, and our max stream length is 30, so we
# have plenty of room for the token.
email_token = models.CharField(
max_length=32,
default=generate_email_token_for_stream,
unique=True,
db_index=True,
)
date_created = models.DateTimeField(default=timezone_now)
deactivated = models.BooleanField(default=False)
class Meta:
unique_together = ("channel", "creator", "sender")

View File

@ -38,7 +38,7 @@ from zerver.lib.send_email import FromAddress
from zerver.lib.streams import ensure_stream from zerver.lib.streams import ensure_stream
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import mock_queue_publish, most_recent_message, most_recent_usermessage from zerver.lib.test_helpers import mock_queue_publish, most_recent_message, most_recent_usermessage
from zerver.models import Attachment, Recipient, Stream, UserProfile from zerver.models import Attachment, ChannelEmailAddress, Recipient, Stream, UserProfile
from zerver.models.messages import Message from zerver.models.messages import Message
from zerver.models.realms import get_realm from zerver.models.realms import get_realm
from zerver.models.streams import get_stream from zerver.models.streams import get_stream
@ -74,35 +74,32 @@ class TestEncodeDecode(ZulipTestCase):
stream_name = "dev. help" stream_name = "dev. help"
stream = ensure_stream(realm, stream_name, acting_user=None) stream = ensure_stream(realm, stream_name, acting_user=None)
email_address = encode_email_address(stream) email_address = encode_email_address(stream)
self.assertEqual(email_address, f"dev-help.{stream.email_token}@testserver") email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
self.assertEqual(email_address, f"dev-help.{email_token}@testserver")
# The default form of the email address (with an option - "include-footer"): # The default form of the email address (with an option - "include-footer"):
token, options = decode_email_address( token, options = decode_email_address(f"dev-help.{email_token}.include-footer@testserver")
f"dev-help.{stream.email_token}.include-footer@testserver",
)
self._assert_options(options, include_footer=True) self._assert_options(options, include_footer=True)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
# Using + instead of . as the separator is also supported for backwards compatibility, # Using + instead of . as the separator is also supported for backwards compatibility,
# since that was the original form of addresses that we used: # since that was the original form of addresses that we used:
token, options = decode_email_address( token, options = decode_email_address(f"dev-help+{email_token}+include-footer@testserver")
f"dev-help+{stream.email_token}+include-footer@testserver",
)
self._assert_options(options, include_footer=True) self._assert_options(options, include_footer=True)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
token, options = decode_email_address(email_address) token, options = decode_email_address(email_address)
self._assert_options(options) self._assert_options(options)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
# We also handle mixing + and . but it shouldn't be recommended to users. # We also handle mixing + and . but it shouldn't be recommended to users.
email_address_all_options = ( email_address_all_options = (
"dev-help.{}+include-footer.show-sender+include-quotes@testserver" "dev-help.{}+include-footer.show-sender+include-quotes@testserver"
) )
email_address_all_options = email_address_all_options.format(stream.email_token) email_address_all_options = email_address_all_options.format(email_token)
token, options = decode_email_address(email_address_all_options) token, options = decode_email_address(email_address_all_options)
self._assert_options(options, show_sender=True, include_footer=True, include_quotes=True) self._assert_options(options, show_sender=True, include_footer=True, include_quotes=True)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
email_address = email_address.replace("@testserver", "@zulip.org") email_address = email_address.replace("@testserver", "@zulip.org")
email_address_all_options = email_address_all_options.replace("@testserver", "@zulip.org") email_address_all_options = email_address_all_options.replace("@testserver", "@zulip.org")
@ -115,13 +112,13 @@ class TestEncodeDecode(ZulipTestCase):
with self.settings(EMAIL_GATEWAY_EXTRA_PATTERN_HACK="@zulip.org"): with self.settings(EMAIL_GATEWAY_EXTRA_PATTERN_HACK="@zulip.org"):
token, options = decode_email_address(email_address) token, options = decode_email_address(email_address)
self._assert_options(options) self._assert_options(options)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
token, options = decode_email_address(email_address_all_options) token, options = decode_email_address(email_address_all_options)
self._assert_options( self._assert_options(
options, show_sender=True, include_footer=True, include_quotes=True options, show_sender=True, include_footer=True, include_quotes=True
) )
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
with self.assertRaises(ZulipEmailForwardError): with self.assertRaises(ZulipEmailForwardError):
decode_email_address("bogus") decode_email_address("bogus")
@ -133,6 +130,7 @@ class TestEncodeDecode(ZulipTestCase):
stream_name = "Тестовы some ascii letters" stream_name = "Тестовы some ascii letters"
stream = ensure_stream(realm, stream_name, acting_user=None) stream = ensure_stream(realm, stream_name, acting_user=None)
email_address = encode_email_address(stream) email_address = encode_email_address(stream)
email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
msg_string = get_email_gateway_message_string_from_address(email_address) msg_string = get_email_gateway_message_string_from_address(email_address)
parts = msg_string.split("+") parts = msg_string.split("+")
@ -143,7 +141,7 @@ class TestEncodeDecode(ZulipTestCase):
# Correctly decode the resulting address that doesn't have the stream name: # Correctly decode the resulting address that doesn't have the stream name:
token, show_sender = decode_email_address(email_address) token, show_sender = decode_email_address(email_address)
self.assertFalse(show_sender) self.assertFalse(show_sender)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
asciiable_stream_name = "ąężć" asciiable_stream_name = "ąężć"
stream = ensure_stream(realm, asciiable_stream_name, acting_user=None) stream = ensure_stream(realm, asciiable_stream_name, acting_user=None)
@ -153,24 +151,28 @@ class TestEncodeDecode(ZulipTestCase):
def test_decode_ignores_stream_name(self) -> None: def test_decode_ignores_stream_name(self) -> None:
stream = get_stream("Denmark", get_realm("zulip")) stream = get_stream("Denmark", get_realm("zulip"))
stream_to_address = encode_email_address(stream) stream_to_address = encode_email_address(stream)
email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
stream_to_address = stream_to_address.replace("denmark", "Some_name") stream_to_address = stream_to_address.replace("denmark", "Some_name")
# get the email_token: # get the email_token:
token = decode_email_address(stream_to_address)[0] token = decode_email_address(stream_to_address)[0]
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
def test_encode_with_show_sender(self) -> None: def test_encode_with_show_sender(self) -> None:
stream = get_stream("Denmark", get_realm("zulip")) stream = get_stream("Denmark", get_realm("zulip"))
stream_to_address = encode_email_address(stream, show_sender=True) stream_to_address = encode_email_address(stream, show_sender=True)
email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
token, options = decode_email_address(stream_to_address) token, options = decode_email_address(stream_to_address)
self._assert_options(options, show_sender=True) self._assert_options(options, show_sender=True)
self.assertEqual(token, stream.email_token) self.assertEqual(token, email_token)
def test_decode_prefer_text_options(self) -> None: def test_decode_prefer_text_options(self) -> None:
stream = get_stream("Denmark", get_realm("zulip")) stream = get_stream("Denmark", get_realm("zulip"))
address_prefer_text = f"Denmark.{stream.email_token}.prefer-text@testserver" encode_email_address(stream)
address_prefer_html = f"Denmark.{stream.email_token}.prefer-html@testserver" email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
address_prefer_text = f"Denmark.{email_token}.prefer-text@testserver"
address_prefer_html = f"Denmark.{email_token}.prefer-html@testserver"
token, options = decode_email_address(address_prefer_text) token, options = decode_email_address(address_prefer_text)
self._assert_options(options, prefer_text=True) self._assert_options(options, prefer_text=True)
@ -844,8 +846,10 @@ class TestEmailMirrorMessagesWithAttachments(ZulipTestCase):
self.login_user(user_profile) self.login_user(user_profile)
self.subscribe(user_profile, "Denmark") self.subscribe(user_profile, "Denmark")
stream = get_stream("Denmark", user_profile.realm) stream = get_stream("Denmark", user_profile.realm)
stream_address = f"Denmark.{stream.email_token}@testserver" encode_email_address(stream)
stream_address_prefer_html = f"Denmark.{stream.email_token}.prefer-html@testserver" email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
stream_address = f"Denmark.{email_token}@testserver"
stream_address_prefer_html = f"Denmark.{email_token}.prefer-html@testserver"
text = "Test message" text = "Test message"
html = "<html><body><b>Test html message</b></body></html>" html = "<html><body><b>Test html message</b></body></html>"
@ -877,7 +881,9 @@ class TestEmailMirrorMessagesWithAttachments(ZulipTestCase):
self.login_user(user_profile) self.login_user(user_profile)
self.subscribe(user_profile, "Denmark") self.subscribe(user_profile, "Denmark")
stream = get_stream("Denmark", user_profile.realm) stream = get_stream("Denmark", user_profile.realm)
stream_address_prefer_html = f"Denmark.{stream.email_token}.prefer-html@testserver" encode_email_address(stream)
email_token = ChannelEmailAddress.objects.get(channel=stream).email_token
stream_address_prefer_html = f"Denmark.{email_token}.prefer-html@testserver"
text = "Test message" text = "Test message"
# This should be correctly identified as empty html body: # This should be correctly identified as empty html body:

View File

@ -106,6 +106,7 @@ from zerver.lib.types import (
) )
from zerver.models import ( from zerver.models import (
Attachment, Attachment,
ChannelEmailAddress,
DefaultStream, DefaultStream,
DefaultStreamGroup, DefaultStreamGroup,
Message, Message,
@ -5881,8 +5882,9 @@ class GetStreamsTest(ZulipTestCase):
denmark_stream = get_stream("Denmark", realm) denmark_stream = get_stream("Denmark", realm)
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address") result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
json = self.assert_json_success(result) json = self.assert_json_success(result)
email_token = ChannelEmailAddress.objects.get(channel=denmark_stream).email_token
denmark_email = encode_email_address_helper( denmark_email = encode_email_address_helper(
denmark_stream.name, denmark_stream.email_token, show_sender=True denmark_stream.name, email_token, show_sender=True
) )
self.assertEqual(json["email"], denmark_email) self.assertEqual(json["email"], denmark_email)