diff --git a/api_docs/changelog.md b/api_docs/changelog.md
index c25407aea0..ff9a80689b 100644
--- a/api_docs/changelog.md
+++ b/api_docs/changelog.md
@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0
+**Feature level 409**
+
+* `PATCH /realm`, [`POST /register`](/api/register-queue),
+ [`GET /events`](/api/get-events): Added a new
+ `require_e2ee_push_notifications` realm setting.
+
**Feature level 407**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
diff --git a/version.py b/version.py
index fd0f8c88aa..e3846a3614 100644
--- a/version.py
+++ b/version.py
@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
-API_FEATURE_LEVEL = 408
+API_FEATURE_LEVEL = 409
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump
diff --git a/web/src/admin.ts b/web/src/admin.ts
index 1a51217370..591ea6840d 100644
--- a/web/src/admin.ts
+++ b/web/src/admin.ts
@@ -41,6 +41,9 @@ const admin_settings_label = {
}),
realm_inline_url_embed_preview: $t({defaultMessage: "Show previews of linked websites"}),
realm_send_welcome_emails: $t({defaultMessage: "Send emails introducing Zulip to new users"}),
+ realm_require_e2ee_push_notifications: $t({
+ defaultMessage: "Require end-to-end encryption for push notification content",
+ }),
realm_message_content_allowed_in_email_notifications: $t({
defaultMessage: "Allow message content in message notification emails",
}),
@@ -194,6 +197,7 @@ export function build_page(): void {
realm_topics_policy_values: settings_config.get_realm_topics_policy_values(),
empty_string_topic_display_name: util.get_final_topic_display_name(""),
realm_send_welcome_emails: realm.realm_send_welcome_emails,
+ realm_require_e2ee_push_notifications: realm.realm_require_e2ee_push_notifications,
realm_message_content_allowed_in_email_notifications:
realm.realm_message_content_allowed_in_email_notifications,
realm_enable_spectator_access: realm.realm_enable_spectator_access,
diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js
index a2fc7f9a82..4fbc762732 100644
--- a/web/src/server_events_dispatch.js
+++ b/web/src/server_events_dispatch.js
@@ -289,6 +289,7 @@ export function dispatch_normal_event(event) {
require_unique_names: noop,
send_welcome_emails: noop,
topics_policy: noop,
+ require_e2ee_push_notifications: noop,
message_content_allowed_in_email_notifications: noop,
enable_spectator_access: noop,
signup_announcements_stream_id: noop,
diff --git a/web/src/state_data.ts b/web/src/state_data.ts
index 4fc0f34d86..5d77fa5431 100644
--- a/web/src/state_data.ts
+++ b/web/src/state_data.ts
@@ -426,6 +426,7 @@ export const realm_schema = z.object({
realm_presence_disabled: z.boolean(),
realm_push_notifications_enabled: z.boolean(),
realm_push_notifications_enabled_end_timestamp: z.nullable(z.number()),
+ realm_require_e2ee_push_notifications: z.boolean(),
realm_require_unique_names: z.boolean(),
realm_send_welcome_emails: z.boolean(),
realm_signup_announcements_stream_id: z.number(),
diff --git a/web/templates/settings/organization_settings_admin.hbs b/web/templates/settings/organization_settings_admin.hbs
index 4a5d655d18..69e8d720b2 100644
--- a/web/templates/settings/organization_settings_admin.hbs
+++ b/web/templates/settings/organization_settings_admin.hbs
@@ -70,6 +70,13 @@
{{> settings_save_discard_widget section_name="notifications-security" }}
+ {{> settings_checkbox
+ setting_name="realm_require_e2ee_push_notifications"
+ prefix="id_"
+ is_checked=realm_require_e2ee_push_notifications
+ label=admin_settings_label.realm_require_e2ee_push_notifications
+ help_link="/help/mobile-notifications"}}
+
{{> settings_checkbox
setting_name="realm_message_content_allowed_in_email_notifications"
prefix="id_"
diff --git a/zerver/migrations/0743_realm_require_e2ee_push_notifications.py b/zerver/migrations/0743_realm_require_e2ee_push_notifications.py
new file mode 100644
index 0000000000..db0a9e78fb
--- /dev/null
+++ b/zerver/migrations/0743_realm_require_e2ee_push_notifications.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.4 on 2025-07-28 18:58
+
+from django.conf import settings
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+
+def update_require_e2ee_push_notifications(
+ apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+ Realm = apps.get_model("zerver", "Realm")
+
+ # We use 'getattr' with a default value to allow this migration
+ # to run in development environment when PUSH_NOTIFICATION_REDACT_CONTENT
+ # setting is removed in the future.
+ require_e2ee = getattr(settings, "PUSH_NOTIFICATION_REDACT_CONTENT", False)
+ if require_e2ee:
+ Realm.objects.update(require_e2ee_push_notifications=require_e2ee)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("zerver", "0742_usermessage_zerver_usermessage_is_private_unread_message_id"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="realm",
+ name="require_e2ee_push_notifications",
+ field=models.BooleanField(db_default=False, default=False),
+ ),
+ migrations.RunPython(
+ update_require_e2ee_push_notifications,
+ elidable=True,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ]
diff --git a/zerver/models/realms.py b/zerver/models/realms.py
index a269a18626..6a4269c500 100644
--- a/zerver/models/realms.py
+++ b/zerver/models/realms.py
@@ -191,6 +191,7 @@ class Realm(models.Model):
# cease to be the case.
push_notifications_enabled = models.BooleanField(default=False, db_index=True)
push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True)
+ require_e2ee_push_notifications = models.BooleanField(default=False, db_default=False)
date_created = models.DateTimeField(default=timezone_now)
scheduled_deletion_date = models.DateTimeField(default=None, db_index=True, null=True)
@@ -726,6 +727,7 @@ class Realm(models.Model):
name=str,
name_changes_disabled=bool,
push_notifications_enabled=bool,
+ require_e2ee_push_notifications=bool,
require_unique_names=bool,
send_welcome_emails=bool,
topics_policy=RealmTopicsPolicyEnum,
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index fef61c9579..ea1bff9dd7 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -5402,6 +5402,32 @@ paths:
indicated timestamp is near.
**Changes**: New in Zulip 8.0 (feature level 231).
+ require_e2ee_push_notifications:
+ type: boolean
+ description: |
+ Whether this realm is configured to disallow sending mobile
+ push notifications with message content through the legacy
+ mobile push notifications APIs. The new API uses end-to-end
+ encryption to protect message content and metadata from
+ being accessible to the push bouncer service, APNs, and
+ FCM. Clients that support the new E2EE API will use it
+ automatically regardless of this setting.
+
+ If `true`, mobile push notifications sent to clients that
+ lack support for E2EE push notifications will always have
+ "New message" as their content. Note that these legacy
+ mobile notifications will still contain metadata, which may
+ include the message's ID, the sender's name, email address,
+ and avatar.
+
+ In a future release, once the official mobile apps have
+ implemented fully validated their E2EE protocol support,
+ this setting will become strict, and disable the legacy
+ protocol entirely.
+
+ **Changes**: New in Zulip 11.0 (feature level 409). Previously,
+ this behavior was available only via the
+ `PUSH_NOTIFICATION_REDACT_CONTENT` global server setting.
require_unique_names:
type: boolean
description: |
@@ -18953,6 +18979,34 @@ paths:
Present if `realm` is present in `fetch_event_types`.
The name of the organization, used in login pages etc.
+ realm_require_e2ee_push_notifications:
+ type: boolean
+ description: |
+ Present if `realm` is present in `fetch_event_types`.
+
+ Whether this realm is configured to disallow sending mobile
+ push notifications with message content through the legacy
+ mobile push notifications APIs. The new API uses end-to-end
+ encryption to protect message content and metadata from
+ being accessible to the push bouncer service, APNs, and
+ FCM. Clients that support the new E2EE API will use it
+ automatically regardless of this setting.
+
+ If `true`, mobile push notifications sent to clients that
+ lack support for E2EE push notifications will always have
+ "New message" as their content. Note that these legacy
+ mobile notifications will still contain metadata, which may
+ include the message's ID, the sender's name, email address,
+ and avatar.
+
+ In a future release, once the official mobile apps have
+ implemented fully validated their E2EE protocol support,
+ this setting will become strict, and disable the legacy
+ protocol entirely.
+
+ **Changes**: New in Zulip 11.0 (feature level 409). Previously,
+ this behavior was available only via the
+ `PUSH_NOTIFICATION_REDACT_CONTENT` global server setting.
realm_require_unique_names:
type: boolean
description: |
diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py
index 693d330615..1b44bda878 100644
--- a/zerver/tests/test_home.py
+++ b/zerver/tests/test_home.py
@@ -208,6 +208,7 @@ class HomeTest(ZulipTestCase):
"realm_presence_disabled",
"realm_push_notifications_enabled",
"realm_push_notifications_enabled_end_timestamp",
+ "realm_require_e2ee_push_notifications",
"realm_require_unique_names",
"realm_send_welcome_emails",
"realm_signup_announcements_stream_id",
diff --git a/zerver/views/realm.py b/zerver/views/realm.py
index b28a210cca..bc6d19ae59 100644
--- a/zerver/views/realm.py
+++ b/zerver/views/realm.py
@@ -178,6 +178,7 @@ def update_realm(
name_changes_disabled: Json[bool] | None = None,
new_stream_announcements_stream_id: Json[int] | None = None,
org_type: Json[OrgTypeEnum] | None = None,
+ require_e2ee_push_notifications: Json[bool] | None = None,
require_unique_names: Json[bool] | None = None,
send_welcome_emails: Json[bool] | None = None,
signup_announcements_stream_id: Json[int] | None = None,