streams: Add API endpoint to get stream email.

This commit adds new API endpoint to get stream email which is
used by the web-app as well to get the email when a user tries
to open the stream email modal.

The stream email is returned only to the users who have access
to it. Specifically for private streams only subscribed users
have access to its email. And for public streams, all non-guest
users and only subscribed guests have access to its email.
All users can access email of web-public streams.
This commit is contained in:
Sahil Batra 2023-09-29 23:24:03 +05:30 committed by Alex Vandiver
parent 432001656e
commit 3c8701ee36
11 changed files with 259 additions and 70 deletions

View File

@ -26,6 +26,9 @@ format used by the Zulip server that they are interacting with.
[`GET /users/me/subscriptions`](/api/get-subscriptions): Removed [`GET /users/me/subscriptions`](/api/get-subscriptions): Removed
`email_address` field from subscription objects. `email_address` field from subscription objects.
* [`GET /streams/{stream_id}/email_address`](/api/get-stream-email-address):
Added new endpoint to get email address of a stream.
**Feature level 225** **Feature level 225**
* `PATCH /realm`, [`POST /register`](/api/register-queue), * `PATCH /realm`, [`POST /register`](/api/register-queue),

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 225 API_FEATURE_LEVEL = 226
# Bump the minor PROVISION_VERSION to indicate that folks should provision # 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 # only when going from an old version of the code to a newer version. Bump

View File

@ -502,6 +502,13 @@ export function can_toggle_subscription(sub: StreamSubscription): boolean {
); );
} }
export function can_access_stream_email(sub: StreamSubscription): boolean {
return (
(sub.subscribed || sub.is_web_public || (!page_params.is_guest && !sub.invite_only)) &&
!page_params.is_spectator
);
}
export function can_access_topic_history(sub: StreamSubscription): boolean { export function can_access_topic_history(sub: StreamSubscription): boolean {
// Anyone can access topic history for web-public streams and // Anyone can access topic history for web-public streams and
// subscriptions; additionally, members can access history for // subscriptions; additionally, members can access history for

View File

@ -259,6 +259,7 @@ export function show_settings_for(node) {
page_params.realm_org_type === settings_config.all_org_type_values.business.code, page_params.realm_org_type === settings_config.all_org_type_values.business.code,
is_admin: page_params.is_admin, is_admin: page_params.is_admin,
org_level_message_retention_setting: get_display_text_for_realm_message_retention_setting(), org_level_message_retention_setting: get_display_text_for_realm_message_retention_setting(),
can_access_stream_email: stream_data.can_access_stream_email(sub),
}); });
scroll_util.get_content_element($("#stream_settings")).html(html); scroll_util.get_content_element($("#stream_settings")).html(html);
@ -352,6 +353,71 @@ export function get_stream_email_address(flags, address) {
return clean_address.replace("@", flag_string + "@"); return clean_address.replace("@", flag_string + "@");
} }
function show_stream_email_address_modal(address) {
const copy_email_address_modal_html = render_copy_email_address_modal({
email_address: address,
tags: [
{
name: "show-sender",
description: $t({
defaultMessage: "The sender's email address",
}),
},
{
name: "include-footer",
description: $t({defaultMessage: "Email footers (e.g., signature)"}),
},
{
name: "include-quotes",
description: $t({defaultMessage: "Quoted original email (in replies)"}),
},
{
name: "prefer-html",
description: $t({
defaultMessage: "Use html encoding (not recommended)",
}),
},
],
});
dialog_widget.launch({
html_heading: $t_html({defaultMessage: "Generate stream email address"}),
html_body: copy_email_address_modal_html,
id: "copy_email_address_modal",
html_submit_button: $t_html({defaultMessage: "Copy address"}),
html_exit_button: $t_html({defaultMessage: "Close"}),
help_link: "/help/message-a-stream-by-email#configuration-options",
on_click() {},
close_on_submit: false,
});
$("#show-sender").prop("checked", true);
const clipboard = new ClipboardJS("#copy_email_address_modal .dialog_submit_button", {
text() {
return address;
},
});
// Show a tippy tooltip when the stream email address copied
clipboard.on("success", (e) => {
show_copied_confirmation(e.trigger);
});
$("#copy_email_address_modal .tag-checkbox").on("change", () => {
const $checked_checkboxes = $(".copy-email-modal").find("input:checked");
const flags = [];
$($checked_checkboxes).each(function () {
flags.push($(this).attr("id"));
});
address = get_stream_email_address(flags, address);
$(".email-address").text(address);
});
}
export function initialize() { export function initialize() {
$("#main_div").on("click", ".stream_sub_unsub_button", (e) => { $("#main_div").on("click", ".stream_sub_unsub_button", (e) => {
e.preventDefault(); e.preventDefault();
@ -457,70 +523,20 @@ export function initialize() {
e.stopPropagation(); e.stopPropagation();
const stream_id = get_stream_id(e.target); const stream_id = get_stream_id(e.target);
const stream = sub_store.get(stream_id);
let address = stream.email_address;
const copy_email_address = render_copy_email_address_modal({ channel.get({
email_address: address, url: "/json/streams/" + stream_id + "/email_address",
tags: [ success(data) {
{ const address = data.email;
name: "show-sender", show_stream_email_address_modal(address);
description: $t({
defaultMessage: "The sender's email address",
}),
}, },
{ error(xhr) {
name: "include-footer", ui_report.error(
description: $t({defaultMessage: "Email footers (e.g., signature)"}), $t_html({defaultMessage: "Failed"}),
xhr,
$(".stream_email_address_error"),
);
}, },
{
name: "include-quotes",
description: $t({defaultMessage: "Quoted original email (in replies)"}),
},
{
name: "prefer-html",
description: $t({
defaultMessage: "Use html encoding (not recommended)",
}),
},
],
});
dialog_widget.launch({
html_heading: $t_html({defaultMessage: "Generate stream email address"}),
html_body: copy_email_address,
id: "copy_email_address_modal",
html_submit_button: $t_html({defaultMessage: "Copy address"}),
html_exit_button: $t_html({defaultMessage: "Close"}),
help_link: "/help/message-a-stream-by-email#configuration-options",
on_click() {},
close_on_submit: false,
});
$("#show-sender").prop("checked", true);
const clipboard = new ClipboardJS("#copy_email_address_modal .dialog_submit_button", {
text() {
return address;
},
});
// Show a tippy tooltip when the stream email address copied
clipboard.on("success", (e) => {
show_copied_confirmation(e.trigger);
});
$("#copy_email_address_modal .tag-checkbox").on("change", () => {
const $checked_checkboxes = $(".copy-email-modal").find("input:checked");
const flags = [];
$($checked_checkboxes).each(function () {
flags.push($(this).attr("id"));
});
address = get_stream_email_address(flags, address);
$(".email-address").text(address);
}); });
}); });

View File

@ -946,9 +946,16 @@ h4.user_group_setting_subsection_title {
} }
} }
.stream-email-box {
.stream_email_address_error {
vertical-align: top;
margin-left: 15px;
}
.copy_email_button { .copy_email_button {
padding: 10px 15px; padding: 10px 15px;
} }
}
.loading_indicator_text { .loading_indicator_text {
font-weight: 400; font-weight: 400;

View File

@ -75,11 +75,14 @@
can_remove_subscribers_setting_widget_name="can_remove_subscribers_group" }} can_remove_subscribers_setting_widget_name="can_remove_subscribers_group" }}
</div> </div>
{{/with}} {{/with}}
<div class="stream-email-box" {{#unless sub.email_address}}style="display: none;"{{/unless}}> <div class="stream-email-box" {{#unless can_access_stream_email}}style="display: none;"{{/unless}}>
<div class="stream-email-box-header">
<h3 class="stream_setting_subsection_title"> <h3 class="stream_setting_subsection_title">
{{t "Email address" }} {{t "Email address" }}
{{> ../help_link_widget link="/help/message-a-stream-by-email" }} {{> ../help_link_widget link="/help/message-a-stream-by-email" }}
</h3> </h3>
<div class="stream_email_address_error alert-notification"></div>
</div>
<p> <p>
{{t "You can use email to send messages to Zulip streams."}} {{t "You can use email to send messages to Zulip streams."}}
</p> </p>

View File

@ -1150,3 +1150,44 @@ test("options for dropdown widget", () => {
}, },
]); ]);
}); });
test("can_access_stream_email", () => {
const social = {
subscribed: true,
color: "red",
name: "social",
stream_id: 2,
is_muted: false,
invite_only: true,
history_public_to_subscribers: false,
};
page_params.is_admin = false;
assert.equal(stream_data.can_access_stream_email(social), true);
page_params.is_admin = true;
assert.equal(stream_data.can_access_stream_email(social), true);
social.subscribed = false;
assert.equal(stream_data.can_access_stream_email(social), false);
social.invite_only = false;
assert.equal(stream_data.can_access_stream_email(social), true);
page_params.is_admin = false;
assert.equal(stream_data.can_access_stream_email(social), true);
page_params.is_guest = true;
assert.equal(stream_data.can_access_stream_email(social), false);
social.subscribed = true;
assert.equal(stream_data.can_access_stream_email(social), true);
social.is_web_public = true;
assert.equal(stream_data.can_access_stream_email(social), true);
social.subscribed = false;
assert.equal(stream_data.can_access_stream_email(social), true);
page_params.is_spectator = true;
assert.equal(stream_data.can_access_stream_email(social), false);
});

View File

@ -16846,6 +16846,50 @@ paths:
description: | description: |
An example JSON response for when invalid combination of stream permission An example JSON response for when invalid combination of stream permission
parameters are passed: parameters are passed:
/streams/{stream_id}/email_address:
get:
operationId: get-stream-email-address
summary: Get the email address of a stream
tags: ["streams"]
description: |
Get email address of a stream.
**Changes**: New in Zulip 8.0 (feature level 226).
parameters:
- $ref: "#/components/parameters/StreamIdInPath"
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
email:
type: string
description: |
Email address of the stream.
example:
{
"result": "success",
"msg": "",
"email": "test_stream.af64447e9e39374841063747ade8e6b0.show-sender@testserver",
}
"400":
description: Bad request.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/InvalidStreamError"
- description: |
An example JSON response for when the requested stream does not exist,
or where the user does not have permission to access the target stream:
/streams/{stream_id}/delete_topic: /streams/{stream_id}/delete_topic:
post: post:
operationId: delete-topic operationId: delete-topic

View File

@ -31,6 +31,7 @@ from zerver.actions.streams import (
bulk_remove_subscriptions, bulk_remove_subscriptions,
deactivated_streams_by_old_name, deactivated_streams_by_old_name,
do_change_stream_group_based_setting, do_change_stream_group_based_setting,
do_change_stream_permission,
do_change_stream_post_policy, do_change_stream_post_policy,
do_deactivate_stream, do_deactivate_stream,
do_unarchive_stream, do_unarchive_stream,
@ -41,6 +42,7 @@ from zerver.lib.default_streams import (
get_default_stream_ids_for_realm, get_default_stream_ids_for_realm,
get_default_streams_for_realm_as_dicts, get_default_streams_for_realm_as_dicts,
) )
from zerver.lib.email_mirror_helpers import encode_email_address_helper
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.message import UnreadStreamInfo, aggregate_unread_data, get_raw_unread_data from zerver.lib.message import UnreadStreamInfo, aggregate_unread_data, get_raw_unread_data
from zerver.lib.response import json_success from zerver.lib.response import json_success
@ -5729,6 +5731,54 @@ class GetStreamsTest(ZulipTestCase):
self.assertEqual(json["stream"]["name"], "private_stream") self.assertEqual(json["stream"]["name"], "private_stream")
self.assertEqual(json["stream"]["stream_id"], private_stream.id) self.assertEqual(json["stream"]["stream_id"], private_stream.id)
def test_get_stream_email_address(self) -> None:
self.login("hamlet")
hamlet = self.example_user("hamlet")
iago = self.example_user("iago")
polonius = self.example_user("polonius")
realm = get_realm("zulip")
denmark_stream = get_stream("Denmark", realm)
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
json = self.assert_json_success(result)
denmark_email = encode_email_address_helper(
denmark_stream.name, denmark_stream.email_token, show_sender=True
)
self.assertEqual(json["email"], denmark_email)
self.login("polonius")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
self.assert_json_error(result, "Invalid stream ID")
self.subscribe(polonius, "Denmark")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
json = self.assert_json_success(result)
self.assertEqual(json["email"], denmark_email)
do_change_stream_permission(
denmark_stream,
invite_only=True,
history_public_to_subscribers=True,
is_web_public=False,
acting_user=iago,
)
self.login("hamlet")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
json = self.assert_json_success(result)
self.assertEqual(json["email"], denmark_email)
self.unsubscribe(hamlet, "Denmark")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
self.assert_json_error(result, "Invalid stream ID")
self.login("iago")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
json = self.assert_json_success(result)
self.assertEqual(json["email"], denmark_email)
self.unsubscribe(iago, "Denmark")
result = self.client_get(f"/json/streams/{denmark_stream.id}/email_address")
self.assert_json_error(result, "Invalid stream ID")
class StreamIdTest(ZulipTestCase): class StreamIdTest(ZulipTestCase):
def test_get_stream_id(self) -> None: def test_get_stream_id(self) -> None:

View File

@ -46,6 +46,7 @@ from zerver.decorator import (
require_post, require_post,
require_realm_admin, require_realm_admin,
) )
from zerver.lib.email_mirror_helpers import encode_email_address
from zerver.lib.default_streams import get_default_stream_ids_for_realm from zerver.lib.default_streams import get_default_stream_ids_for_realm
from zerver.lib.exceptions import ( from zerver.lib.exceptions import (
JsonableError, JsonableError,
@ -1085,3 +1086,18 @@ def update_subscription_properties_backend(
) )
return json_success(request) return json_success(request)
@has_request_variables
def get_stream_email_address(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ("stream", converter=to_non_negative_int, path_only=True),
) -> HttpResponse:
(stream, sub) = access_stream_by_id(
user_profile,
stream_id,
)
stream_email = encode_email_address(stream, show_sender=True)
return json_success(request, data={"email": stream_email})

View File

@ -149,6 +149,7 @@ from zerver.views.streams import (
deactivate_stream_backend, deactivate_stream_backend,
delete_in_topic, delete_in_topic,
get_stream_backend, get_stream_backend,
get_stream_email_address,
get_streams_backend, get_streams_backend,
get_subscribers_backend, get_subscribers_backend,
get_topics_backend, get_topics_backend,
@ -467,6 +468,7 @@ v1_api_and_json_patterns = [
PATCH=update_stream_backend, PATCH=update_stream_backend,
DELETE=deactivate_stream_backend, DELETE=deactivate_stream_backend,
), ),
rest_path("streams/<int:stream_id>/email_address", GET=get_stream_email_address),
# Delete topic in stream # Delete topic in stream
rest_path("streams/<int:stream_id>/delete_topic", POST=delete_in_topic), rest_path("streams/<int:stream_id>/delete_topic", POST=delete_in_topic),
rest_path("default_streams", POST=add_default_stream, DELETE=remove_default_stream), rest_path("default_streams", POST=add_default_stream, DELETE=remove_default_stream),