Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/email-deliveries-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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, 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, `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.
55 changes: 47 additions & 8 deletions integration-tests/tests/api/organization/email-deliveries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
) {
organization: organizationBySlug(organizationSlug: $organizationSlug) {
id
viewerEmailDeliveryVisibility {
events
isLimited
}
emailDeliveryStats(period: { from: $from, to: $to }) {
totalDeliveries
totalFailures
Expand All @@ -55,11 +59,15 @@
}
`);

const ViewerCanSeeEmailDeliveriesQuery = graphql(`
query ViewerCanSeeEmailDeliveriesQuery($organizationSlug: String!) {
const EmailDeliveryAccessQuery = graphql(`
query EmailDeliveryAccessQuery($organizationSlug: String!) {
organization: organizationBySlug(organizationSlug: $organizationSlug) {
id
viewerCanSeeEmailDeliveries
viewerEmailDeliveryVisibility {
events
isLimited
}
}
}
`);
Expand Down Expand Up @@ -195,6 +203,16 @@
.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);
Expand All @@ -218,7 +236,7 @@
);

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();
Expand All @@ -228,37 +246,58 @@
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,
variables: { organizationSlug: organization.slug, ...lastHour() },
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({
id: organization.id,
viewerEmailDeliveryVisibility: { events: [], isLimited: true },
emailDeliveryStats: null,
emailDeliveryHealth: null,
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(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);

Check failure on line 300 in integration-tests/tests/api/organization/email-deliveries.spec.ts

View workflow job for this annotation

GitHub Actions / test / integration (1)

tests/api/organization/email-deliveries.spec.ts > Organization.emailDelivery* show each viewer the kinds of email they may manage

AssertionError: expected 0 to be greater than or equal to 1 ❯ tests/api/organization/email-deliveries.spec.ts:300:71
},
);

Expand Down
41 changes: 34 additions & 7 deletions packages/services/api/src/modules/deliveries/module.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -144,9 +159,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(
"""
Expand All @@ -155,15 +172,25 @@ 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!
"""
Which kinds of email the viewer may see on emailDeliveryStats, emailDeliveryHealth and
emailDeliveryLog. Never errors.
"""
viewerEmailDeliveryVisibility: EmailDeliveryVisibility!
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,11 +80,15 @@ export interface Delivery {
metadata: Record<string, string>;
}

/** 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.
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading