Add "Email display" preference to control email address visibility (#11103)

* Initial plan

* Add emailDisplay TeamPreference to control email address visibility

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add tests for emailDisplay TeamPreference policy logic

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* tweaks

* Add Everyone setting, tests

* PR feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Copilot
2026-01-08 10:15:36 -05:00
committed by GitHub
parent b06c18ecf6
commit f6e25b0d32
7 changed files with 305 additions and 10 deletions
+50 -1
View File
@@ -5,7 +5,7 @@ import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { TeamPreference } from "@shared/types";
import { TeamPreference, EmailDisplay } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
import type { Option } from "~/components/InputSelect";
@@ -50,6 +50,28 @@ function Security() {
[t]
);
const emailDisplayOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Members"),
value: EmailDisplay.Members,
},
{
type: "item",
label: t("Members and guests"),
value: EmailDisplay.Everyone,
},
{
type: "item",
label: t("No one"),
value: EmailDisplay.None,
},
] satisfies Option[],
[t]
);
const showSuccessMessage = React.useMemo(
() =>
debounce(() => {
@@ -146,6 +168,17 @@ function Security() {
[saveData, team.preferences]
);
const handleEmailDisplayChange = React.useCallback(
async (emailDisplay: string) => {
const preferences = {
...team.preferences,
[TeamPreference.EmailDisplay]: emailDisplay,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleInviteRequiredChange = React.useCallback(
async (checked: boolean) => {
const inviteRequired = checked;
@@ -307,6 +340,22 @@ function Security() {
onChange={handleDocumentEmbedsChange}
/>
</SettingRow>
<SettingRow
label={t("Email address visibility")}
name={TeamPreference.EmailDisplay}
description={t(
"Controls who can see user email addresses in the workspace"
)}
>
<InputSelect
value={team.getPreference(TeamPreference.EmailDisplay) as string}
options={emailDisplayOptions}
onChange={handleEmailDisplayChange}
label={t("Email address visibility")}
hideLabel
short
/>
</SettingRow>
<SettingRow
label={t("Collection creation")}
name="memberCollectionCreate"
+215
View File
@@ -0,0 +1,215 @@
import { TeamPreference, EmailDisplay, UserRole } from "@shared/types";
import {
buildUser,
buildTeam,
buildAdmin,
buildViewer,
} from "@server/test/factories";
import { serialize } from "./index";
describe("policies/user", () => {
describe("readEmail", () => {
it("should allow user to read their own email", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const abilities = serialize(user, user);
expect(abilities.readEmail).toBeTruthy();
});
it("should allow admin to read other users' emails", async () => {
const team = await buildTeam();
const admin = await buildAdmin({
teamId: team.id,
});
const user = await buildUser({
teamId: team.id,
});
const abilities = serialize(admin, user);
expect(abilities.readEmail).toBeTruthy();
});
it("should allow members to read other members' emails when emailDisplay is 'members' (default)", async () => {
const team = await buildTeam();
const user1 = await buildUser({
teamId: team.id,
});
const user2 = await buildUser({
teamId: team.id,
});
const abilities = serialize(user1, user2);
expect(abilities.readEmail).toBeTruthy();
});
it("should allow members to read other members' emails when emailDisplay is explicitly 'members'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Members);
await team.save();
const user1 = await buildUser({
teamId: team.id,
});
const user2 = await buildUser({
teamId: team.id,
});
// Reload to get fresh team with preferences
await user1.reload({ include: [{ association: "team" }] });
const abilities = serialize(user1, user2);
expect(abilities.readEmail).toBeTruthy();
});
it("should NOT allow members to read other members' emails when emailDisplay is 'none'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None);
await team.save();
const user1 = await buildUser({
teamId: team.id,
});
const user2 = await buildUser({
teamId: team.id,
});
// Reload to get fresh team with preferences
await user1.reload({ include: [{ association: "team" }] });
const abilities = serialize(user1, user2);
expect(abilities.readEmail).toBeFalsy();
});
it("should allow user to read their own email even when emailDisplay is 'none'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None);
await team.save();
const user = await buildUser({
teamId: team.id,
});
// Reload to get fresh team with preferences
await user.reload({ include: [{ association: "team" }] });
const abilities = serialize(user, user);
expect(abilities.readEmail).toBeTruthy();
});
it("should allow admin to read other users' emails even when emailDisplay is 'none'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None);
await team.save();
const admin = await buildAdmin({
teamId: team.id,
});
const user = await buildUser({
teamId: team.id,
});
// Reload to get fresh team with preferences
await admin.reload({ include: [{ association: "team" }] });
const abilities = serialize(admin, user);
expect(abilities.readEmail).toBeTruthy();
});
it("should allow members to read other members' emails when emailDisplay is 'everyone'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Everyone);
await team.save();
const user1 = await buildUser({
teamId: team.id,
});
const user2 = await buildUser({
teamId: team.id,
});
// Reload to get fresh team with preferences
await user1.reload({ include: [{ association: "team" }] });
const abilities = serialize(user1, user2);
expect(abilities.readEmail).toBeTruthy();
});
it("should NOT allow guest users to read other users' emails when emailDisplay is 'none'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None);
await team.save();
const guest = await buildUser({
teamId: team.id,
role: UserRole.Guest,
});
const user = await buildUser({
teamId: team.id,
});
const abilities = serialize(guest, user);
expect(abilities.readEmail).toBeFalsy();
});
it("should allow guest users to read their own email", async () => {
const team = await buildTeam();
const guest = await buildUser({
teamId: team.id,
role: UserRole.Guest,
});
const abilities = serialize(guest, guest);
expect(abilities.readEmail).toBeTruthy();
});
it("should NOT allow viewer users to read other users' emails when emailDisplay is 'members'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Members);
await team.save();
const viewer = await buildViewer({
teamId: team.id,
});
const user = await buildUser({
teamId: team.id,
});
const abilities = serialize(viewer, user);
expect(abilities.readEmail).toBeFalsy();
});
it("should NOT allow viewer users to read other users' emails when emailDisplay is 'none'", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None);
await team.save();
const viewer = await buildViewer({
teamId: team.id,
});
const user = await buildUser({
teamId: team.id,
});
const abilities = serialize(viewer, user);
expect(abilities.readEmail).toBeFalsy();
});
it("should NOT allow users from different teams to read each other's emails", async () => {
const team1 = await buildTeam();
const team2 = await buildTeam();
const user1 = await buildUser({
teamId: team1.id,
});
const user2 = await buildUser({
teamId: team2.id,
});
// Reload to ensure team is loaded
await user1.reload({ include: [{ association: "team" }] });
const abilities = serialize(user1, user2);
expect(abilities.readEmail).toBeFalsy();
});
});
});
+23 -7
View File
@@ -1,4 +1,4 @@
import { TeamPreference } from "@shared/types";
import { TeamPreference, EmailDisplay } from "@shared/types";
import { User, Team } from "@server/models";
import { allow } from "./cancan";
import {
@@ -38,14 +38,30 @@ allow(User, ["update", "readDetails", "listApiKeys"], User, (actor, user) =>
)
);
allow(User, "readEmail", User, (actor, user) =>
or(
allow(User, "readEmail", User, (actor, user) => {
const emailDisplay =
actor.team?.getPreference(TeamPreference.EmailDisplay) ??
EmailDisplay.Members;
if (emailDisplay === EmailDisplay.None) {
return or(isTeamAdmin(actor, user), actor.id === user?.id);
}
if (emailDisplay === EmailDisplay.Members) {
return or(
isTeamAdmin(actor, user),
isTeamMember(actor, user),
actor.id === user?.id
);
}
// EmailDisplay.Everyone
return or(
//
isTeamAdmin(actor, user),
isTeamMember(actor, user),
isTeamModel(actor, user),
actor.id === user?.id
)
);
);
});
allow(User, "delete", User, (actor, user) =>
or(
+2 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { TOCPosition, UserRole } from "@shared/types";
import { EmailDisplay, TOCPosition, UserRole } from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
export const TeamsUpdateSchema = BaseSchema.extend({
@@ -60,6 +60,7 @@ export const TeamsUpdateSchema = BaseSchema.extend({
.optional(),
/** Side to display the document's table of contents in relation to the main content. */
tocPosition: z.nativeEnum(TOCPosition).optional(),
emailDisplay: z.nativeEnum(EmailDisplay).optional(),
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
preventDocumentEmbedding: z.boolean().optional(),
})
+2 -1
View File
@@ -1,5 +1,5 @@
import type { TeamPreferences, UserPreferences } from "./types";
import { TOCPosition, TeamPreference, UserPreference } from "./types";
import { TOCPosition, TeamPreference, UserPreference, EmailDisplay } from "./types";
export const MAX_AVATAR_DISPLAY = 6;
@@ -28,6 +28,7 @@ export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.CustomTheme]: undefined,
[TeamPreference.TocPosition]: TOCPosition.Left,
[TeamPreference.PreventDocumentEmbedding]: false,
[TeamPreference.EmailDisplay]: EmailDisplay.Members,
};
export const UserPreferenceDefaults: UserPreferences = {
@@ -1232,6 +1232,8 @@
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "This could be your real name, or a nickname — however youd like people to refer to you.",
"Email address": "Email address",
"Members and guests": "Members and guests",
"No one": "No one",
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
"Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.",
@@ -1250,6 +1252,8 @@
"When enabled, users can delete their own account from the workspace": "When enabled, users can delete their own account from the workspace",
"Rich service embeds": "Rich service embeds",
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
"Email address visibility": "Email address visibility",
"Controls who can see user email addresses in the workspace": "Controls who can see user email addresses in the workspace",
"Collection creation": "Collection creation",
"Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace",
"Workspace creation": "Workspace creation",
+9
View File
@@ -287,6 +287,12 @@ export enum TOCPosition {
Right = "right",
}
export enum EmailDisplay {
None = "none",
Members = "members",
Everyone = "everyone",
}
export enum TeamPreference {
/** Whether documents have a separate edit mode instead of always editing. */
SeamlessEdit = "seamlessEdit",
@@ -310,6 +316,8 @@ export enum TeamPreference {
TocPosition = "tocPosition",
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
PreventDocumentEmbedding = "preventDocumentEmbedding",
/** Who can see user email addresses. */
EmailDisplay = "emailDisplay",
}
export type TeamPreferences = {
@@ -324,6 +332,7 @@ export type TeamPreferences = {
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
[TeamPreference.TocPosition]?: TOCPosition;
[TeamPreference.PreventDocumentEmbedding]?: boolean;
[TeamPreference.EmailDisplay]?: EmailDisplay;
};
export enum NavigationNodeType {