From 1f08adc0e6960430f31cef0a6aab2e84187ec79b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:05:00 -0800 Subject: [PATCH 1/4] Show email deliveries based on available permissions --- .changeset/email-deliveries-page.md | 4 +- .../api/organization/email-deliveries.spec.ts | 10 +- .../src/modules/deliveries/module.graphql.ts | 21 ++- .../deliveries/providers/delivery-reader.ts | 34 ++++- .../providers/email-deliveries.spec.ts | 137 ++++++++++++++++-- .../deliveries/providers/email-deliveries.ts | 100 ++++++++++--- .../deliveries/resolvers/Organization.ts | 13 +- .../emails/email-deliveries-sub-page.tsx | 2 +- 8 files changed, 256 insertions(+), 65 deletions(-) diff --git a/.changeset/email-deliveries-page.md b/.changeset/email-deliveries-page.md index 90037e3c820..70ee2899d40 100644 --- a/.changeset/email-deliveries-page.md +++ b/.changeset/email-deliveries-page.md @@ -2,6 +2,6 @@ 'hive': minor --- -Organization settings gain an "Email Deliveries" section, for members who can manage invitations (`member:modify`): the emails Hive sent for the organization in the last 72 hours (invitations, ownership transfers, audit log exports and usage notices), how many the mail provider accepted or refused, when it last did either, a chart of attempts and failures over time, and a log of each attempt with the recipient, the provider's answer, the duration, the retry count and the recorded details (subject, message id, request and job ids). Bodies, links and codes are never shown because they are never stored. +Organization settings gain an "Email Deliveries" section: the emails Hive sent for the organization in the last 72 hours, how many the mail provider accepted or refused, when it last did either, a chart of attempts and failures over time, and a log of each attempt with the recipient, the provider's answer, the duration, the retry count and the recorded details (subject, message id, request and job ids). Each member sees the kinds of email of the features they may manage: invitations with `member:modify`, audit log exports with `auditLog:export`, usage rate limit notices with `billing:describe`, and ownership transfers as the organization owner. Bodies, links and codes are never shown because they are never stored. -In GraphQL, `Organization.emailDeliveryStats(period)`, `emailDeliveryHealth` and `emailDeliveryLog(period, limit)` expose the same data, shaped like the delivery fields of an alert channel, and `Organization.viewerCanSeeEmailDeliveries` says whether the viewer may read them. +In GraphQL, `Organization.emailDeliveryStats(period)`, `emailDeliveryHealth` and `emailDeliveryLog(period, limit)` expose the same data, limited the same way and shaped like the delivery fields of an alert channel, and `Organization.viewerCanSeeEmailDeliveries` says whether the viewer may see at least one kind. diff --git a/integration-tests/tests/api/organization/email-deliveries.spec.ts b/integration-tests/tests/api/organization/email-deliveries.spec.ts index 0711e5993c8..b87f4678544 100644 --- a/integration-tests/tests/api/organization/email-deliveries.spec.ts +++ b/integration-tests/tests/api/organization/email-deliveries.spec.ts @@ -218,7 +218,7 @@ test.concurrent( ); test.concurrent( - 'Organization.emailDelivery* and viewerCanSeeEmailDeliveries follow member:modify', + 'Organization.emailDelivery* show each viewer the kinds of email they may manage', async ({ expect }) => { const seed = initSeed(); const { createOrg } = await seed.createOwner(); @@ -253,6 +253,14 @@ test.concurrent( emailDeliveryLog: null, }); + // Audit log exports are visible to this role; the member's own invitation email is not. + await updateMemberRole(role, ['auditLog:export']); + + await expect(canSee()).resolves.toBe(true); + const exportsOnly = await read().then(r => r.expectNoGraphQLErrors()); + expect(exportsOnly.organization!.emailDeliveryStats!.totalDeliveries).toBe(0); + expect(exportsOnly.organization!.emailDeliveryLog).toEqual([]); + await updateMemberRole(role, ['member:modify']); await expect(canSee()).resolves.toBe(true); diff --git a/packages/services/api/src/modules/deliveries/module.graphql.ts b/packages/services/api/src/modules/deliveries/module.graphql.ts index eb8d7fa02fe..744992ef9b0 100644 --- a/packages/services/api/src/modules/deliveries/module.graphql.ts +++ b/packages/services/api/src/modules/deliveries/module.graphql.ts @@ -144,9 +144,11 @@ export default gql` extend type Organization { """ - Attempts to email the organization's members and invitees in the period: invitations, - ownership transfers, audit log exports and usage rate limit notices. Requires member:modify. - Null when the delivery history could not be read. + Attempts to email the organization's members and invitees in the period, limited to the kinds + the viewer may see: invitations with member:modify, audit log exports with auditLog:export, + usage rate limit notices with billing:describe, and ownership transfers as the organization + owner. Errors when the viewer may see none of them. Null when the delivery history could not + be read. """ emailDeliveryStats( """ @@ -155,15 +157,20 @@ export default gql` period: DateRangeInput! ): DeliveryStats """ - When an email to any recipient last succeeded and last failed. Requires member:modify. Null - when the delivery history could not be read. + When an email to any recipient last succeeded and last failed, among the kinds of email the + viewer may see (as emailDeliveryStats describes them). Errors when the viewer may see none of + them. Null when the delivery history could not be read. """ emailDeliveryHealth: EmailDeliveryHealth """ - The most recent email attempts within the period, newest first. limit defaults to 50, at most - 200. Requires member:modify. Null when the delivery history could not be read. + The most recent attempts within the period, newest first, of the kinds of email the viewer may + see (as emailDeliveryStats describes them). limit defaults to 50, at most 200. Errors when the + viewer may see none of them. Null when the delivery history could not be read. """ emailDeliveryLog(period: DateRangeInput!, limit: Int): [Delivery!] + """ + Whether the viewer may see at least one kind of email delivery. + """ viewerCanSeeEmailDeliveries: Boolean! } `; diff --git a/packages/services/api/src/modules/deliveries/providers/delivery-reader.ts b/packages/services/api/src/modules/deliveries/providers/delivery-reader.ts index 9a87cd8025f..a2a8bc3d58d 100644 --- a/packages/services/api/src/modules/deliveries/providers/delivery-reader.ts +++ b/packages/services/api/src/modules/deliveries/providers/delivery-reader.ts @@ -12,18 +12,27 @@ export const MAX_DELIVERY_LOG_LIMIT = 200; /** A channel is failing once this many attempts in a row, the most recent ones, have failed. */ export const FAILURE_STREAK_LENGTH = 10; -/** The GraphQL `DeliveryEvent` members; the stored event name is the lowercase form. */ -const DELIVERY_EVENT_NAMES = [ +/** What an alert channel is sent; the stored event name is the lowercase form. */ +const CHANNEL_DELIVERY_EVENT_NAMES = [ 'SCHEMA_CHANGE', 'METRIC_ALERT', 'CHANNEL_CONFIRMATION', +] as const; +/** What an organization is emailed; the stored event name is the lowercase form. */ +export const EMAIL_DELIVERY_EVENT_NAMES = [ 'ORGANIZATION_INVITATION', 'ORGANIZATION_OWNERSHIP_TRANSFER', 'AUDIT_LOG_EXPORT', 'USAGE_RATE_LIMIT_WARNING', 'USAGE_RATE_LIMIT_EXCEEDED', ] as const; +/** The GraphQL `DeliveryEvent` members. */ +const DELIVERY_EVENT_NAMES = [ + ...CHANNEL_DELIVERY_EVENT_NAMES, + ...EMAIL_DELIVERY_EVENT_NAMES, +] as const; export type DeliveryEventName = (typeof DELIVERY_EVENT_NAMES)[number]; +export type EmailDeliveryEventName = (typeof EMAIL_DELIVERY_EVENT_NAMES)[number]; export type DeliveryHealthStatus = 'UNKNOWN' | 'HEALTHY' | 'FAILING'; /** An alert channel type, or EMAIL for mail sent through the configured provider. */ export type DeliveryType = AlertChannel['type'] | 'EMAIL'; @@ -71,11 +80,15 @@ export interface Delivery { metadata: Record; } -/** Which deliveries to read: one destination's, or every one of a type for the organization. */ +/** + * Which deliveries to read: one destination's, or every one of a type for the organization, and + * only the given events when `events` is set (never to an empty list). + */ export type DeliverySelector = { organizationId: string; deliveryType: DeliveryType; destination?: string; + events?: readonly DeliveryEventName[]; }; // UInt64 aggregates arrive quoted (output_format_json_quote_64bit_integers); smaller integers as numbers. @@ -136,12 +149,17 @@ export class DeliveryReader { this.logger = logger.child({ source: 'DeliveryReader' }); } - /** The key prefix: the organization and type, then the destination when one is given. */ + /** The key prefix: the organization and type, then the destination and the events when given. */ private whereSelector(selector: DeliverySelector) { - const prefix = sql`"organization_id" = ${selector.organizationId} AND "delivery_type" = ${selector.deliveryType}`; - return selector.destination === undefined - ? prefix - : sql`${prefix} AND "destination" = ${selector.destination}`; + let where = sql`"organization_id" = ${selector.organizationId} AND "delivery_type" = ${selector.deliveryType}`; + if (selector.destination !== undefined) { + where = sql`${where} AND "destination" = ${selector.destination}`; + } + if (selector.events !== undefined) { + const events = selector.events.map(event => event.toLowerCase()); + where = sql`${where} AND "event" IN (${sql.array(events, 'String')})`; + } + return where; } /** Attempts in the period, and how many of them the destination did not accept. */ diff --git a/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts b/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts index c74655d1cb3..6f208178ed5 100644 --- a/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts +++ b/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts @@ -1,9 +1,12 @@ import { vi } from 'vitest'; import type { OrganizationInvitation } from '../../../shared/entities'; +import { AccessError } from '../../../shared/errors'; import type { Delivery, DeliveryReader } from './delivery-reader'; import { EmailDeliveries, invitationDeliveryKey } from './email-deliveries'; const logger = { child: () => ({ debug: vi.fn(), error: vi.fn() }) }; +const OWNER_ID = 'owner-1'; +const period = { from: new Date('2026-11-01T00:00:00Z'), to: new Date('2026-11-03T00:00:00Z') }; function invitation(overrides: Partial = {}): OrganizationInvitation { return { @@ -36,14 +39,50 @@ function delivery(overrides: Partial = {}): Delivery { }; } -function createProvider(latest: Delivery[] | Error) { - const getLatestByKey = vi.fn(() => - latest instanceof Error - ? Promise.reject(latest) - : Promise.resolve(new Map(latest.map(delivery => [delivery.idempotencyKey!, delivery]))), +function createProvider( + options: { + latest?: Delivery[] | Error; + /** The permissions the session grants on the organization. */ + allowed?: string[]; + actor?: 'owner' | 'member' | 'token'; + } = {}, +) { + const latest: Delivery[] | Error = options.latest ?? []; + const allowed = new Set(options.allowed ?? []); + const actor = options.actor ?? 'member'; + + const reader = { + getLatestByKey: vi.fn(() => + latest instanceof Error + ? Promise.reject(latest) + : Promise.resolve(new Map(latest.map(delivery => [delivery.idempotencyKey!, delivery]))), + ), + getLog: vi.fn(async () => []), + getLastOutcomes: vi.fn(async () => ({ + lastSuccessAt: null, + lastFailureAt: null, + })), + }; + const session = { + canPerformAction: vi.fn(async (args: { action: string }) => allowed.has(args.action)), + getActor: async () => + actor === 'token' + ? { type: 'organizationAccessToken', organizationAccessToken: {} } + : { + type: 'user', + user: { id: actor === 'owner' ? OWNER_ID : 'member-1' }, + oidcIntegrationId: null, + }, + }; + const storage = { getOrganizationOwnerId: vi.fn(async () => OWNER_ID) }; + + const provider = new EmailDeliveries( + reader as any, + session as any, + storage as any, + logger as any, ); - const provider = new EmailDeliveries({ getLatestByKey } as any, {} as any, logger as any); - return { provider, getLatestByKey }; + return { provider, reader, storage, getLatestByKey: reader.getLatestByKey }; } describe('invitationDeliveryKey', () => { @@ -54,6 +93,70 @@ describe('invitationDeliveryKey', () => { }); }); +describe('EmailDeliveries reads only the kinds of email the viewer may see', () => { + test('member:modify shows the invitations', async () => { + const { provider, reader } = createProvider({ allowed: ['member:modify'] }); + + await expect(provider.canRead('o1')).resolves.toBe(true); + await provider.getLog({ organizationId: 'o1', period, limit: 10 }); + expect(reader.getLog).toHaveBeenCalledWith({ + organizationId: 'o1', + deliveryType: 'EMAIL', + events: ['ORGANIZATION_INVITATION'], + period, + limit: 10, + }); + }); + + test('auditLog:export and billing:describe show the exports and usage notices, not the invitations', async () => { + const { provider, reader } = createProvider({ + allowed: ['auditLog:export', 'billing:describe'], + }); + + await provider.getHealth('o1'); + expect(reader.getLastOutcomes).toHaveBeenCalledWith({ + organizationId: 'o1', + deliveryType: 'EMAIL', + events: ['AUDIT_LOG_EXPORT', 'USAGE_RATE_LIMIT_WARNING', 'USAGE_RATE_LIMIT_EXCEEDED'], + }); + }); + + test('the owner sees the ownership transfers; an organization access token never does', async () => { + const owner = createProvider({ actor: 'owner' }); + await owner.provider.getLog({ organizationId: 'o1', period }); + expect(owner.reader.getLog).toHaveBeenCalledWith( + expect.objectContaining({ events: ['ORGANIZATION_OWNERSHIP_TRANSFER'] }), + ); + + const token = createProvider({ + actor: 'token', + allowed: ['member:modify', 'auditLog:export', 'billing:describe'], + }); + await token.provider.getLog({ organizationId: 'o1', period }); + expect(token.reader.getLog).toHaveBeenCalledWith( + expect.objectContaining({ + events: [ + 'ORGANIZATION_INVITATION', + 'AUDIT_LOG_EXPORT', + 'USAGE_RATE_LIMIT_WARNING', + 'USAGE_RATE_LIMIT_EXCEEDED', + ], + }), + ); + expect(token.storage.getOrganizationOwnerId).not.toHaveBeenCalled(); + }); + + test('a viewer who may see no kind of email is refused before anything is read', async () => { + const { provider, reader } = createProvider(); + + await expect(provider.canRead('o1')).resolves.toBe(false); + await expect(provider.getLog({ organizationId: 'o1', period })).rejects.toThrow(AccessError); + await expect(provider.getHealth('o1')).rejects.toThrow(AccessError); + expect(reader.getLog).not.toHaveBeenCalled(); + expect(reader.getLastOutcomes).not.toHaveBeenCalled(); + }); +}); + describe('EmailDeliveries.getLatestInvitationDelivery', () => { test('gives each invitation the attempt recorded under its key, one query per organization', async () => { const alice = delivery(); @@ -64,7 +167,7 @@ describe('EmailDeliveries.getLatestInvitationDelivery', () => { statusCode: 422, statusText: '406: inactive', }); - const { provider, getLatestByKey } = createProvider([alice, bob]); + const { provider, getLatestByKey } = createProvider({ latest: [alice, bob] }); const results = await Promise.all([ provider.getLatestInvitationDelivery(invitation()), @@ -90,19 +193,21 @@ describe('EmailDeliveries.getLatestInvitationDelivery', () => { test('ignores the attempts of an earlier invitation of the same address, even newer ones', async () => { // The earlier invitation was deleted by a re-invite, but its job is still retrying. - const { provider } = createProvider([ - delivery({ - timestamp: new Date('2026-11-03T10:30:00.000Z'), - attempt: 4, - idempotencyKey: 'organization-invitation:2026-11-01T09:00:00.000Z:alice@example.com', - }), - ]); + const { provider } = createProvider({ + latest: [ + delivery({ + timestamp: new Date('2026-11-03T10:30:00.000Z'), + attempt: 4, + idempotencyKey: 'organization-invitation:2026-11-01T09:00:00.000Z:alice@example.com', + }), + ], + }); await expect(provider.getLatestInvitationDelivery(invitation())).resolves.toBeNull(); }); test('answers null for every invitation when the history could not be read', async () => { - const { provider } = createProvider(new Error('ClickHouse is down')); + const { provider } = createProvider({ latest: new Error('ClickHouse is down') }); await expect( Promise.all([ diff --git a/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts b/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts index b0614ae9e40..41dd5dd5777 100644 --- a/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts +++ b/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts @@ -1,12 +1,16 @@ import { Injectable, Scope } from 'graphql-modules'; import type { DateRange, OrganizationInvitation } from '../../../shared/entities'; +import { AccessError } from '../../../shared/errors'; import { batchBy } from '../../../shared/helpers'; import { Session } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; +import { Storage } from '../../shared/providers/storage'; import { DeliveryReader, + EMAIL_DELIVERY_EVENT_NAMES, type Delivery, type DeliverySelector, + type EmailDeliveryEventName, type LastOutcomes, } from './delivery-reader'; @@ -25,41 +29,64 @@ export function invitationDeliveryKey( return `organization-invitation:${invitation.createdAt}:${invitation.email}`; } -/** Reads an organization's email deliveries: its page of them, and the ones behind its invitations. */ +type EventGate = 'member:modify' | 'auditLog:export' | 'billing:describe' | 'owner'; + +/** + * What lets a viewer see each kind of email: the permission of the feature that sends it, or + * owning the organization for the transfers only the owner can request. + */ +const EVENT_GATE = { + ORGANIZATION_INVITATION: 'member:modify', + ORGANIZATION_OWNERSHIP_TRANSFER: 'owner', + AUDIT_LOG_EXPORT: 'auditLog:export', + USAGE_RATE_LIMIT_WARNING: 'billing:describe', + USAGE_RATE_LIMIT_EXCEEDED: 'billing:describe', +} satisfies Record; + +/** + * Reads an organization's email deliveries: the kinds the viewer may see, and the ones behind + * its invitations. + */ @Injectable({ scope: Scope.Operation }) export class EmailDeliveries { private logger: Logger; + /** Per organization, decided once per request: the fields of one query share it. */ + private visibleEventsCache = new Map>(); constructor( private reader: DeliveryReader, private session: Session, + private storage: Storage, logger: Logger, ) { this.logger = logger.child({ source: 'EmailDeliveries' }); } - /** Reading an organization's email deliveries takes member:modify, like its invitations. */ - async assertCanRead(organizationId: string): Promise { + /** + * The organization's email deliveries this viewer may read: the kinds whose feature they may + * manage. Throws when there is no such kind. + */ + async readableSelector(organizationId: string): Promise { this.logger.debug('Fetching email deliveries (organization=%s)', organizationId); - await this.session.assertPerformAction({ - action: 'member:modify', - organizationId, - params: { organizationId }, - }); + const events = await this.visibleEvents(organizationId); + if (events.length === 0) { + throw new AccessError( + `Insufficient permissions. Either 'member:modify', 'auditLog:export' or 'billing:describe', or organization ownership, is required to read email deliveries.`, + ); + } + return { ...emailDeliverySelector(organizationId), events }; } - canRead(organizationId: string): Promise { - return this.session.canPerformAction({ - action: 'member:modify', - organizationId, - params: { organizationId }, - }); + /** Whether the viewer may see at least one kind of email. */ + async canRead(organizationId: string): Promise { + const events = await this.visibleEvents(organizationId); + return events.length > 0; } /** Only the timestamps hold across recipients; the status and streak describe one destination. */ async getHealth(organizationId: string): Promise { - await this.assertCanRead(organizationId); - return this.reader.getLastOutcomes(emailDeliverySelector(organizationId)); + const selector = await this.readableSelector(organizationId); + return this.reader.getLastOutcomes(selector); } async getLog(input: { @@ -67,11 +94,40 @@ export class EmailDeliveries { period: DateRange; limit?: number; }): Promise { - await this.assertCanRead(input.organizationId); - return this.reader.getLog({ - ...emailDeliverySelector(input.organizationId), - period: input.period, - limit: input.limit, + const selector = await this.readableSelector(input.organizationId); + return this.reader.getLog({ ...selector, period: input.period, limit: input.limit }); + } + + /** The kinds of email the viewer may see, in the order of EMAIL_DELIVERY_EVENT_NAMES. */ + private visibleEvents(organizationId: string): Promise { + let events = this.visibleEventsCache.get(organizationId); + if (!events) { + events = this.decideVisibleEvents(organizationId); + this.visibleEventsCache.set(organizationId, events); + } + return events; + } + + private async decideVisibleEvents(organizationId: string): Promise { + const gates = [...new Set(Object.values(EVENT_GATE))]; + const passed = await Promise.all(gates.map(gate => this.passes(gate, organizationId))); + const open = new Set(gates.filter((_, index) => passed[index])); + return EMAIL_DELIVERY_EVENT_NAMES.filter(event => open.has(EVENT_GATE[event])); + } + + private async passes(gate: EventGate, organizationId: string): Promise { + if (gate === 'owner') { + const actor = await this.session.getActor(); + if (actor.type !== 'user') { + return false; + } + const ownerId = await this.storage.getOrganizationOwnerId({ organizationId }); + return actor.user.id === ownerId; + } + return this.session.canPerformAction({ + action: gate, + organizationId, + params: { organizationId }, }); } @@ -112,6 +168,6 @@ export class EmailDeliveries { } /** Every email delivery of the organization: the key prefix the reader needs. */ -export function emailDeliverySelector(organizationId: string): DeliverySelector { +function emailDeliverySelector(organizationId: string): DeliverySelector { return { organizationId, deliveryType: 'EMAIL' }; } diff --git a/packages/services/api/src/modules/deliveries/resolvers/Organization.ts b/packages/services/api/src/modules/deliveries/resolvers/Organization.ts index 34e3da6f354..73ed9209f01 100644 --- a/packages/services/api/src/modules/deliveries/resolvers/Organization.ts +++ b/packages/services/api/src/modules/deliveries/resolvers/Organization.ts @@ -1,5 +1,5 @@ import { parseDateRangeInput } from '../../../shared/helpers'; -import { EmailDeliveries, emailDeliverySelector } from '../providers/email-deliveries'; +import { EmailDeliveries } from '../providers/email-deliveries'; import type { OrganizationResolvers } from './../../../__generated__/types'; /** @@ -11,13 +11,10 @@ export const Organization: Pick< 'emailDeliveryHealth' | 'emailDeliveryLog' | 'emailDeliveryStats' | 'viewerCanSeeEmailDeliveries' > = { // Authorized here once; the DeliveryStats fields then read for the selection. - emailDeliveryStats: async (organization, { period }, { injector }) => { - await injector.get(EmailDeliveries).assertCanRead(organization.id); - return { - selector: emailDeliverySelector(organization.id), - period: parseDateRangeInput(period), - }; - }, + emailDeliveryStats: async (organization, { period }, { injector }) => ({ + selector: await injector.get(EmailDeliveries).readableSelector(organization.id), + period: parseDateRangeInput(period), + }), emailDeliveryHealth: (organization, _arg, { injector }) => injector.get(EmailDeliveries).getHealth(organization.id), emailDeliveryLog: (organization, { period, limit }, { injector }) => diff --git a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx index 8689a2dced9..793a52e142f 100644 --- a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx +++ b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx @@ -92,7 +92,7 @@ export function EmailDeliveriesSubPage() { {status ?? (
From a5d9a2870e1b73f2d5b2b891c16fcfb1eef36339 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:54:54 -0800 Subject: [PATCH 2/4] Expose the list of email events available to a viewer via the api --- .changeset/email-deliveries-page.md | 4 +- .../api/organization/email-deliveries.spec.ts | 46 +++++++++--- .../src/modules/deliveries/module.graphql.ts | 20 ++++++ .../providers/email-deliveries.spec.ts | 28 ++++++++ .../deliveries/providers/email-deliveries.ts | 8 +++ .../deliveries/resolvers/Organization.ts | 8 ++- .../emails/email-deliveries-sub-page.tsx | 29 +++++++- .../src/routes/organization/settings.spec.ts | 72 ++++++++++++++++--- 8 files changed, 192 insertions(+), 23 deletions(-) diff --git a/.changeset/email-deliveries-page.md b/.changeset/email-deliveries-page.md index 70ee2899d40..1f00d11aa4b 100644 --- a/.changeset/email-deliveries-page.md +++ b/.changeset/email-deliveries-page.md @@ -2,6 +2,6 @@ 'hive': minor --- -Organization settings gain an "Email Deliveries" section: the emails Hive sent for the organization in the last 72 hours, how many the mail provider accepted or refused, when it last did either, a chart of attempts and failures over time, and a log of each attempt with the recipient, the provider's answer, the duration, the retry count and the recorded details (subject, message id, request and job ids). Each member sees the kinds of email of the features they may manage: invitations with `member:modify`, audit log exports with `auditLog:export`, usage rate limit notices with `billing:describe`, and ownership transfers as the organization owner. Bodies, links and codes are never shown because they are never stored. +Organization settings gain an "Email Deliveries" section: the emails Hive sent for the organization in the last 72 hours, how many the mail provider accepted or refused, when it last did either, a chart of attempts and failures over time, and a log of each attempt with the recipient, the provider's answer, the duration, the retry count and the recorded details (subject, message id, request and job ids). Each member sees the kinds of email of the features they may manage: invitations with `member:modify`, audit log exports with `auditLog:export`, usage rate limit notices with `billing:describe`, and ownership transfers as the organization owner. When that leaves a kind out, the section names the kinds it shows. Bodies, links and codes are never shown because they are never stored. -In GraphQL, `Organization.emailDeliveryStats(period)`, `emailDeliveryHealth` and `emailDeliveryLog(period, limit)` expose the same data, limited the same way and shaped like the delivery fields of an alert channel, and `Organization.viewerCanSeeEmailDeliveries` says whether the viewer may see at least one kind. +In GraphQL, `Organization.emailDeliveryStats(period)`, `emailDeliveryHealth` and `emailDeliveryLog(period, limit)` expose the same data, limited the same way and shaped like the delivery fields of an alert channel, `Organization.viewerCanSeeEmailDeliveries` says whether the viewer may see at least one kind, and `Organization.viewerEmailDeliveryVisibility` lists the kinds they may see and whether any kind is kept from them. diff --git a/integration-tests/tests/api/organization/email-deliveries.spec.ts b/integration-tests/tests/api/organization/email-deliveries.spec.ts index b87f4678544..2552ca89751 100644 --- a/integration-tests/tests/api/organization/email-deliveries.spec.ts +++ b/integration-tests/tests/api/organization/email-deliveries.spec.ts @@ -35,6 +35,10 @@ const OrganizationEmailDeliveriesQuery = graphql(` ) { organization: organizationBySlug(organizationSlug: $organizationSlug) { id + viewerEmailDeliveryVisibility { + events + isLimited + } emailDeliveryStats(period: { from: $from, to: $to }) { totalDeliveries totalFailures @@ -55,11 +59,15 @@ const OrganizationEmailDeliveriesQuery = graphql(` } `); -const ViewerCanSeeEmailDeliveriesQuery = graphql(` - query ViewerCanSeeEmailDeliveriesQuery($organizationSlug: String!) { +const EmailDeliveryAccessQuery = graphql(` + query EmailDeliveryAccessQuery($organizationSlug: String!) { organization: organizationBySlug(organizationSlug: $organizationSlug) { id viewerCanSeeEmailDeliveries + viewerEmailDeliveryVisibility { + events + isLimited + } } } `); @@ -195,6 +203,16 @@ test.concurrent( .then(r => r.expectNoGraphQLErrors()) .then(r => r.organization!); + expect(deliveries.viewerEmailDeliveryVisibility).toEqual({ + events: [ + 'ORGANIZATION_INVITATION', + 'ORGANIZATION_OWNERSHIP_TRANSFER', + 'AUDIT_LOG_EXPORT', + 'USAGE_RATE_LIMIT_WARNING', + 'USAGE_RATE_LIMIT_EXCEEDED', + ], + isLimited: false, + }); // The refused address keeps being retried, so only the accepted count is exact. const stats = deliveries.emailDeliveryStats!; expect(stats.totalFailures).toBeGreaterThanOrEqual(1); @@ -228,14 +246,14 @@ test.concurrent( const role = await createMemberRole([]); await assignMemberRole({ roleId: role.id, userId: member.id }); - const canSee = () => + const access = () => execute({ - document: ViewerCanSeeEmailDeliveriesQuery, + document: EmailDeliveryAccessQuery, variables: { organizationSlug: organization.slug }, authToken: memberToken, }) .then(r => r.expectNoGraphQLErrors()) - .then(r => r.organization!.viewerCanSeeEmailDeliveries); + .then(r => r.organization!); const read = () => execute({ document: OrganizationEmailDeliveriesQuery, @@ -243,7 +261,11 @@ test.concurrent( authToken: memberToken, }); - await expect(canSee()).resolves.toBe(false); + await expect(access()).resolves.toEqual({ + id: organization.id, + viewerCanSeeEmailDeliveries: false, + viewerEmailDeliveryVisibility: { events: [], isLimited: true }, + }); const denied = await read(); expect(denied.expectGraphQLErrors()).not.toHaveLength(0); expect(denied.rawBody.data?.organization).toEqual({ @@ -256,14 +278,22 @@ test.concurrent( // Audit log exports are visible to this role; the member's own invitation email is not. await updateMemberRole(role, ['auditLog:export']); - await expect(canSee()).resolves.toBe(true); + await expect(access()).resolves.toEqual({ + id: organization.id, + viewerCanSeeEmailDeliveries: true, + viewerEmailDeliveryVisibility: { events: ['AUDIT_LOG_EXPORT'], isLimited: true }, + }); const exportsOnly = await read().then(r => r.expectNoGraphQLErrors()); expect(exportsOnly.organization!.emailDeliveryStats!.totalDeliveries).toBe(0); expect(exportsOnly.organization!.emailDeliveryLog).toEqual([]); await updateMemberRole(role, ['member:modify']); - await expect(canSee()).resolves.toBe(true); + await expect(access()).resolves.toEqual({ + id: organization.id, + viewerCanSeeEmailDeliveries: true, + viewerEmailDeliveryVisibility: { events: ['ORGANIZATION_INVITATION'], isLimited: true }, + }); const allowed = await read().then(r => r.expectNoGraphQLErrors()); // The member's own invitation email is at least one recorded attempt. expect(allowed.organization!.emailDeliveryStats!.totalDeliveries).toBeGreaterThanOrEqual(1); diff --git a/packages/services/api/src/modules/deliveries/module.graphql.ts b/packages/services/api/src/modules/deliveries/module.graphql.ts index 744992ef9b0..776eafac3c4 100644 --- a/packages/services/api/src/modules/deliveries/module.graphql.ts +++ b/packages/services/api/src/modules/deliveries/module.graphql.ts @@ -134,6 +134,21 @@ export default gql` lastFailureAt: DateTime } + """ + Which kinds of email the viewer may see among those Hive sends for the organization. + """ + type EmailDeliveryVisibility { + """ + The kinds the viewer may see (as emailDeliveryStats describes them), in the order Hive lists + them. Empty when the viewer may see none. + """ + events: [DeliveryEvent!]! + """ + Whether a kind Hive sends is missing from events because the viewer may not see it. + """ + isLimited: Boolean! + } + extend type OrganizationInvitation { """ The most recent attempt to email this invitation; null before one is recorded or when the @@ -172,5 +187,10 @@ export default gql` Whether the viewer may see at least one kind of email delivery. """ viewerCanSeeEmailDeliveries: Boolean! + """ + Which kinds of email the viewer may see on emailDeliveryStats, emailDeliveryHealth and + emailDeliveryLog. Never errors. + """ + viewerEmailDeliveryVisibility: EmailDeliveryVisibility! } `; diff --git a/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts b/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts index 6f208178ed5..7c5ca41189c 100644 --- a/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts +++ b/packages/services/api/src/modules/deliveries/providers/email-deliveries.spec.ts @@ -155,6 +155,34 @@ describe('EmailDeliveries reads only the kinds of email the viewer may see', () expect(reader.getLog).not.toHaveBeenCalled(); expect(reader.getLastOutcomes).not.toHaveBeenCalled(); }); + + test('names the kinds the viewer may see and whether any kind is kept from them', async () => { + const everything = createProvider({ + actor: 'owner', + allowed: ['member:modify', 'auditLog:export', 'billing:describe'], + }); + await expect(everything.provider.getVisibility('o1')).resolves.toEqual({ + events: [ + 'ORGANIZATION_INVITATION', + 'ORGANIZATION_OWNERSHIP_TRANSFER', + 'AUDIT_LOG_EXPORT', + 'USAGE_RATE_LIMIT_WARNING', + 'USAGE_RATE_LIMIT_EXCEEDED', + ], + isLimited: false, + }); + + const invitationsOnly = createProvider({ allowed: ['member:modify'] }); + await expect(invitationsOnly.provider.getVisibility('o1')).resolves.toEqual({ + events: ['ORGANIZATION_INVITATION'], + isLimited: true, + }); + + await expect(createProvider().provider.getVisibility('o1')).resolves.toEqual({ + events: [], + isLimited: true, + }); + }); }); describe('EmailDeliveries.getLatestInvitationDelivery', () => { diff --git a/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts b/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts index 41dd5dd5777..2fbf6e9048a 100644 --- a/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts +++ b/packages/services/api/src/modules/deliveries/providers/email-deliveries.ts @@ -83,6 +83,14 @@ export class EmailDeliveries { return events.length > 0; } + /** The kinds of email the viewer may see, and whether any kind is kept from them. */ + async getVisibility( + organizationId: string, + ): Promise<{ events: EmailDeliveryEventName[]; isLimited: boolean }> { + const events = await this.visibleEvents(organizationId); + return { events, isLimited: events.length < EMAIL_DELIVERY_EVENT_NAMES.length }; + } + /** Only the timestamps hold across recipients; the status and streak describe one destination. */ async getHealth(organizationId: string): Promise { const selector = await this.readableSelector(organizationId); diff --git a/packages/services/api/src/modules/deliveries/resolvers/Organization.ts b/packages/services/api/src/modules/deliveries/resolvers/Organization.ts index 73ed9209f01..9771a7954e2 100644 --- a/packages/services/api/src/modules/deliveries/resolvers/Organization.ts +++ b/packages/services/api/src/modules/deliveries/resolvers/Organization.ts @@ -8,7 +8,11 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; */ export const Organization: Pick< OrganizationResolvers, - 'emailDeliveryHealth' | 'emailDeliveryLog' | 'emailDeliveryStats' | 'viewerCanSeeEmailDeliveries' + | 'emailDeliveryHealth' + | 'emailDeliveryLog' + | 'emailDeliveryStats' + | 'viewerCanSeeEmailDeliveries' + | 'viewerEmailDeliveryVisibility' > = { // Authorized here once; the DeliveryStats fields then read for the selection. emailDeliveryStats: async (organization, { period }, { injector }) => ({ @@ -25,4 +29,6 @@ export const Organization: Pick< }), viewerCanSeeEmailDeliveries: (organization, _arg, { injector }) => injector.get(EmailDeliveries).canRead(organization.id), + viewerEmailDeliveryVisibility: (organization, _arg, { injector }) => + injector.get(EmailDeliveries).getVisibility(organization.id), }; diff --git a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx index 793a52e142f..533daaca5f9 100644 --- a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx +++ b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx @@ -1,5 +1,6 @@ import { useQuery } from 'urql'; import { DeliveriesOverTime } from '@/components/common/deliveries/deliveries-over-time'; +import { DELIVERY_EVENT_LABEL } from '@/components/common/deliveries/delivery-event-label'; import { DeliveryStatCards } from '@/components/common/deliveries/delivery-stat-cards'; import { DELIVERY_WINDOW_LABEL, @@ -9,6 +10,7 @@ import { Callout } from '@/components/ui/callout'; import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; import { Spinner } from '@/components/ui/primitives/spinner/spinner'; import { graphql } from '@/gql'; +import { DeliveryEvent } from '@/gql/graphql'; import { useSlugs } from '@/lib/hooks'; import { useKeepPreviousData } from '@/lib/hooks/use-keep-previous-data'; import { useRefetchOnTick } from '@/lib/hooks/use-refetch-on-tick'; @@ -25,6 +27,10 @@ export const EmailDeliveriesSubPage_Query = graphql(` ) { organization: organizationBySlug(organizationSlug: $organizationSlug) { id + viewerEmailDeliveryVisibility { + events + isLimited + } emailDeliveryStats(period: $period) { totalDeliveries totalFailures @@ -57,6 +63,23 @@ export const EmailDeliveriesSubPage_Query = graphql(` } `); +/** How the header names each kind; the two usage limit notices share one gate, so one name. */ +const EMAIL_KIND_PHRASE: Partial> = { + [DeliveryEvent.OrganizationInvitation]: 'invitations', + [DeliveryEvent.OrganizationOwnershipTransfer]: 'ownership transfers', + [DeliveryEvent.AuditLogExport]: 'audit log exports', + [DeliveryEvent.UsageRateLimitWarning]: 'usage limit notices', + [DeliveryEvent.UsageRateLimitExceeded]: 'usage limit notices', +}; + +/** The kinds of email as the header lists them: "invitations, audit log exports". */ +function describeKinds(events: readonly DeliveryEvent[]): string { + const phrases = events.map( + event => EMAIL_KIND_PHRASE[event] ?? DELIVERY_EVENT_LABEL[event].toLowerCase(), + ); + return [...new Set(phrases)].join(', '); +} + export function EmailDeliveriesSubPage() { const { organizationSlug } = useSlugs('organization'); const { now, from, to, resolution } = useDeliveryWindow(EMAIL_DELIVERIES_POLL_INTERVAL_MS); @@ -87,12 +110,16 @@ export function EmailDeliveriesSubPage() { (organization.emailDeliveryStats === null || organization.emailDeliveryHealth === null || organization.emailDeliveryLog === null); + const visibility = organization?.viewerEmailDeliveryVisibility; + const shownKinds = visibility?.isLimited + ? ` Showing only the kinds of email you have permission to manage: ${describeKinds(visibility.events)}.` + : ''; return ( {status ?? (
diff --git a/packages/web/app/src/routes/organization/settings.spec.ts b/packages/web/app/src/routes/organization/settings.spec.ts index 18313c1b62a..48fdc741011 100644 --- a/packages/web/app/src/routes/organization/settings.spec.ts +++ b/packages/web/app/src/routes/organization/settings.spec.ts @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { PersonalAccessTokensTable_MoreAccessTokensQuery } from '@/components/organization/settings/personal-access-tokens/personal-access-tokens-table'; +import { DeliveryEvent } from '@/gql/graphql'; import { layoutFixtures, organizationLayout, SLUGS } from '@/lib/testing/fixtures/layouts'; import { organizationSettings } from '@/lib/testing/fixtures/organization-settings'; import { renderAtUrl } from '@/lib/testing/router'; import { createTestClient } from '@/lib/testing/urql'; import { createAppRouter } from '@/router'; import { createMemoryHistory } from '@tanstack/react-router'; -import { waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; // The tree imports every page; these stand in for what cannot load under jsdom. vi.mock('@/env/frontend', () => import('@/lib/testing/mocks/env')); @@ -53,6 +54,33 @@ function expectRevalidating(client: TestClient, name: string) { expect(['cache-and-network', 'network-only']).toContain(policies[0]); } +const EVERY_EMAIL_KIND = [ + DeliveryEvent.OrganizationInvitation, + DeliveryEvent.OrganizationOwnershipTransfer, + DeliveryEvent.AuditLogExport, + DeliveryEvent.UsageRateLimitWarning, + DeliveryEvent.UsageRateLimitExceeded, +]; + +function emailDeliveriesPage( + visibility: { events: DeliveryEvent[]; isLimited: boolean } = { + events: EVERY_EMAIL_KIND, + isLimited: false, + }, +) { + return { + __typename: 'Query', + organization: { + __typename: 'Organization', + id: 'organization-1', + viewerEmailDeliveryVisibility: { __typename: 'EmailDeliveryVisibility', ...visibility }, + emailDeliveryStats: null, + emailDeliveryHealth: null, + emailDeliveryLog: null, + }, + }; +} + const SECTIONS = [ ['sso', 'SingleSignOnSubpageQuery'], ['access-tokens', 'AccessTokensSubPage_OrganizationQuery'], @@ -128,16 +156,7 @@ describe('organization settings loaders', () => { vi.setSystemTime(new Date('2026-09-28T15:30:00.000Z')); const client = createTestClient(layoutFixtures()); client.fixtures.set(PAGE, organizationSettings()); - client.fixtures.set('EmailDeliveriesSubPage_Query', { - __typename: 'Query', - organization: { - __typename: 'Organization', - id: 'organization-1', - emailDeliveryStats: null, - emailDeliveryHealth: null, - emailDeliveryLog: null, - }, - }); + client.fixtures.set('EmailDeliveriesSubPage_Query', emailDeliveriesPage()); renderAtUrl(`${SETTINGS}/emails`, { client }); const polls = () => client.requests('EmailDeliveriesSubPage_Query'); // The mount settles at two: the synchronous fixture answers the cache-and-network miss during @@ -154,6 +173,37 @@ describe('organization settings loaders', () => { }, ); + it( + 'names the kinds of email a viewer with a limited view is shown', + { timeout: 30_000 }, + async () => { + const client = createTestClient(layoutFixtures()); + client.fixtures.set(PAGE, organizationSettings()); + client.fixtures.set( + 'EmailDeliveriesSubPage_Query', + emailDeliveriesPage({ + events: [DeliveryEvent.OrganizationInvitation, DeliveryEvent.AuditLogExport], + isLimited: true, + }), + ); + renderAtUrl(`${SETTINGS}/emails`, { client }); + + await screen.findByText( + /Showing only the kinds of email you have permission to manage: invitations, audit log exports\./, + ); + }, + ); + + it('does not name the kinds for a viewer who sees every kind', { timeout: 30_000 }, async () => { + const client = createTestClient(layoutFixtures()); + client.fixtures.set(PAGE, organizationSettings()); + client.fixtures.set('EmailDeliveriesSubPage_Query', emailDeliveriesPage()); + renderAtUrl(`${SETTINGS}/emails`, { client }); + + await screen.findByText(/Details for emails Hive sent for this organization/); + expect(screen.queryByText(/Showing only/)).toBeNull(); + }); + it('sends a viewer who may not open General to Policy', { timeout: 30_000 }, async () => { const { router } = await loadedAt( SETTINGS, From 8730a4bb9853c1564cfd8af24dc94b188755c2e3 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:58:34 -0800 Subject: [PATCH 3/4] Phrasing --- .../organization/settings/emails/email-deliveries-sub-page.tsx | 2 +- packages/web/app/src/routes/organization/settings.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx index 533daaca5f9..7c41d57447a 100644 --- a/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx +++ b/packages/web/app/src/components/organization/settings/emails/email-deliveries-sub-page.tsx @@ -112,7 +112,7 @@ export function EmailDeliveriesSubPage() { organization.emailDeliveryLog === null); const visibility = organization?.viewerEmailDeliveryVisibility; const shownKinds = visibility?.isLimited - ? ` Showing only the kinds of email you have permission to manage: ${describeKinds(visibility.events)}.` + ? ` Showing only the kinds of email you have access to: ${describeKinds(visibility.events)}.` : ''; return ( diff --git a/packages/web/app/src/routes/organization/settings.spec.ts b/packages/web/app/src/routes/organization/settings.spec.ts index 48fdc741011..2897e700cfd 100644 --- a/packages/web/app/src/routes/organization/settings.spec.ts +++ b/packages/web/app/src/routes/organization/settings.spec.ts @@ -189,7 +189,7 @@ describe('organization settings loaders', () => { renderAtUrl(`${SETTINGS}/emails`, { client }); await screen.findByText( - /Showing only the kinds of email you have permission to manage: invitations, audit log exports\./, + /Showing only the kinds of email you have access to: invitations, audit log exports\./, ); }, ); From ef6a692ad600f56196afa330794e2524aeba6e1a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:57:49 -0800 Subject: [PATCH 4/4] Fix test --- .../tests/api/organization/email-deliveries.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/tests/api/organization/email-deliveries.spec.ts b/integration-tests/tests/api/organization/email-deliveries.spec.ts index 2552ca89751..f8354c001fd 100644 --- a/integration-tests/tests/api/organization/email-deliveries.spec.ts +++ b/integration-tests/tests/api/organization/email-deliveries.spec.ts @@ -270,6 +270,7 @@ test.concurrent( expect(denied.expectGraphQLErrors()).not.toHaveLength(0); expect(denied.rawBody.data?.organization).toEqual({ id: organization.id, + viewerEmailDeliveryVisibility: { events: [], isLimited: true }, emailDeliveryStats: null, emailDeliveryHealth: null, emailDeliveryLog: null,