diff --git a/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json b/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json new file mode 100644 index 00000000000..fd086f9de88 --- /dev/null +++ b/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.link_id AS \"link_id!\", COUNT(*) AS \"unread_count!\"\n FROM email_threads t\n WHERE t.link_id = ANY($1)\n AND t.inbox_visible\n AND t.is_signal\n AND NOT t.is_read\n AND t.latest_inbound_message_ts IS NOT NULL\n GROUP BY t.link_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "link_id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "unread_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9" +} diff --git a/apps/web/src/components/app/app-sidebar/sidebar.tsx b/apps/web/src/components/app/app-sidebar/sidebar.tsx index 2155face395..c2af3c7b0e6 100644 --- a/apps/web/src/components/app/app-sidebar/sidebar.tsx +++ b/apps/web/src/components/app/app-sidebar/sidebar.tsx @@ -90,6 +90,7 @@ import UsersThreeIcon from '@phosphor/users-three.svg'; import XIcon from '@phosphor/x.svg'; import { isRealNamePart, useOwnUserName } from '@queries/auth/user-name-self'; import { useEmailLinksQuery } from '@queries/email/link'; +import { useEmailUnreadCounts } from '@queries/email/unread-counts'; import { useJoinTeamMutation, useRejectInvitationMutation, @@ -1599,6 +1600,11 @@ interface SidebarLinkProps extends SidebarItem { * Email link's expand chevron. */ trailingWhenActive?: JSX.Element; + /** + * Unread count shown as a badge at the row's right edge, or over the icon in + * slim mode. `0` (or omitted) renders nothing. + */ + unreadCount?: number; /** * Swaps the icon for an X while the row is hovered (expanded sidebar only — * in slim mode the icon is the whole row, so the swap would hijack @@ -1607,6 +1613,36 @@ interface SidebarLinkProps extends SidebarItem { removeAction?: { tooltip: string; onRemove: () => void }; } +/** + * Unread-count badge for a sidebar row. Renders nothing at zero, so callers can + * pass a count unconditionally. + * + * `slim` is the compact variant that sits over a rail icon, where there is only + * room for a single digit before the count becomes an "attention" marker. + */ +const SidebarUnreadBadge = (props: { count: number; slim?: boolean }) => { + const label = () => { + if (props.slim) return props.count > 9 ? '9+' : `${props.count}`; + return props.count > 99 ? '99+' : `${props.count}`; + }; + + return ( + 0}> + + {label()} + + + ); +}; + /** Which action of {@link SidebarOpenInSplitMenu} placed the content. */ type SidebarOpenAction = 'current-split' | 'new-split' | 'fullscreen'; @@ -1780,6 +1816,12 @@ const SidebarLink = (props: SidebarLinkProps) => { globalSplitManager()?.returnFocus(); }} > + {/* Slim rail has no room for a trailing badge, so the count rides the + icon. Top-right keeps it clear of the bottom-right hotkey chip. */} + + + +
{ {props.label}
+ {/* Sits before the chevron and hotkey hints so the count keeps its + place as those come and go with hover and active state. */} + +
+ +
+
+ { !props.hotkeyVisible } > -
+
{props.trailingWhenActive}
@@ -1855,7 +1905,7 @@ const SidebarLink = (props: SidebarLinkProps) => { !(isActive() && props.trailingWhenActive !== undefined) } > -
+
@@ -1909,6 +1959,11 @@ const SidebarLink = (props: SidebarLinkProps) => { * Each row carries the same right-click menu as the parent link (open in the * current split, a new split, or fullscreen), scoping whichever split it opens * to that inbox. + * + * Unread Signal counts follow the same fan-out: while the rows are collapsed + * the Email link badges the total across every inbox, and once they expand each + * inbox badges its own. Noise is never counted, so the badge always matches the + * Signal tab a click lands on. */ const SidebarMailLink = (props: SidebarLinkProps) => { const layout = useSplitLayout(); @@ -1923,6 +1978,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => { ) ); + // Only worth asking once we know the user has an inbox at all. + const unread = useEmailUnreadCounts(() => links().length > 0); + const isMailList = (content: SplitContent | undefined) => content?.type === 'component' && content.id === 'mail'; @@ -1987,6 +2045,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => { <> { if (!canShow()) return; @@ -2064,6 +2125,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => { showTooltip={false} /> {link.email_address} +
+ +
diff --git a/apps/web/src/lib/queries/email/keys.ts b/apps/web/src/lib/queries/email/keys.ts index b3d0eea4f41..0229efc5158 100644 --- a/apps/web/src/lib/queries/email/keys.ts +++ b/apps/web/src/lib/queries/email/keys.ts @@ -6,6 +6,7 @@ export const emailKeys = createQueryKeys('email', { labels: null, links: null, linksHealthProbe: null, + unreadCounts: null, backfillJobs: null, threads: null, thread: (threadId: string) => ({ diff --git a/apps/web/src/lib/queries/email/thread.ts b/apps/web/src/lib/queries/email/thread.ts index 061c02653db..c2922ce7634 100644 --- a/apps/web/src/lib/queries/email/thread.ts +++ b/apps/web/src/lib/queries/email/thread.ts @@ -25,6 +25,7 @@ import { invalidateAllSoup } from '../soup/normalized-cache'; import { type UndoHandle, useUndoableMutation } from '../undo'; import { type MutationCallbacks, withCallbacks } from '../utils'; import { emailKeys } from './keys'; +import { invalidateEmailUnreadCounts } from './unread-counts'; const THREAD_STALE_TIME = 5 * 60 * 1000; @@ -216,6 +217,11 @@ export function useMarkThreadAsSeenMutation( // Note: We intentionally don't invalidate thread messages in onSuccess. // The optimistic update already sets isRead in soup, and invalidating // thread messages triggers Suspense which resets scroll position. + // + // The sidebar badge counts unread signal threads, so reading one moves + // it. Refetched once the server has actually applied the read — doing + // it in onMutate would race the write and re-cache the old count. + onSettled: invalidateEmailUnreadCounts, }, callbacks ), @@ -285,7 +291,10 @@ export function useMarkThreadAsUnreadMutation( ); }, ...withCallbacks( - { onMutate: threadUnreadOnMutate }, + { + onMutate: threadUnreadOnMutate, + onSettled: invalidateEmailUnreadCounts, + }, callbacks ), })); @@ -640,6 +649,9 @@ async function upsertSenderFilterWithToast( const filterId = result.value.filter.id; invalidateAllSoup(); + // Reclassifying a sender moves their threads between Signal and Noise, so + // the Signal-only badge count changes with them. + invalidateEmailUnreadCounts(); toast.success(`Sender marked as ${label}`, { subtext: `Messages from ${senderEmail} will appear in ${label}`, @@ -655,6 +667,7 @@ async function upsertSenderFilterWithToast( toast.failure('Failed to undo', { subtext: senderEmail }); } else { invalidateAllSoup(); + invalidateEmailUnreadCounts(); toast.success('Sender filter removed'); } }, diff --git a/apps/web/src/lib/queries/email/unread-counts.ts b/apps/web/src/lib/queries/email/unread-counts.ts new file mode 100644 index 00000000000..48a931c4499 --- /dev/null +++ b/apps/web/src/lib/queries/email/unread-counts.ts @@ -0,0 +1,77 @@ +import { throwOnErr } from '@core/util/result'; +import { queryClient } from '@queries/client'; +import { emailClient } from '@service-email/client'; +import { useQuery } from '@tanstack/solid-query'; +import { type Accessor, createMemo } from 'solid-js'; +import { emailKeys } from './keys'; + +/** + * How long a fetched count stays fresh. Short, because the badge is a + * "there is something waiting" signal — a stale one is worse than a late one. + */ +const UNREAD_COUNTS_STALE_TIME = 30 * 1000; + +/** + * Background refresh cadence. Mail arriving from the provider doesn't + * invalidate this query (the notification-driven soup refresh doesn't know + * about it), so a poll is what turns new mail into a badge while the tab + * stays open. Reads and unreads made in-app invalidate directly and don't + * wait for it. + */ +const UNREAD_COUNTS_REFETCH_INTERVAL = 60 * 1000; + +const queryEnabled = () => true; + +/** + * Unread Signal-view thread counts per connected inbox, as the sidebar's Email + * badge renders them. Counts are Signal only — Noise is deliberately excluded, + * so the number always matches the tab a click lands on. + * + * The server returns one entry per accessible inbox, including inboxes with + * nothing unread, so an inbox missing from the response means "not linked" + * rather than "caught up". + */ +export function useEmailUnreadCountsQuery( + enabled: Accessor = queryEnabled +) { + return useQuery(() => ({ + queryKey: emailKeys.unreadCounts.queryKey, + queryFn: async () => + throwOnErr(async () => await emailClient.getUnreadCounts()), + enabled: enabled(), + staleTime: UNREAD_COUNTS_STALE_TIME, + refetchInterval: UNREAD_COUNTS_REFETCH_INTERVAL, + refetchOnWindowFocus: 'always' as const, + })); +} + +/** + * Unread Signal counts keyed by email link id, plus the cross-inbox total the + * collapsed Email row shows. Both are `0`-safe before the query resolves, so + * the badge simply doesn't render rather than flashing a placeholder. + */ +export function useEmailUnreadCounts(enabled?: Accessor) { + const query = useEmailUnreadCountsQuery(enabled); + + const byLinkId = createMemo(() => { + const counts = new Map(); + for (const entry of query.data?.counts ?? []) { + counts.set(entry.link_id, entry.unread_count); + } + return counts; + }); + + return { + /** Unread Signal count for one inbox; `0` while the query is loading. */ + forLink: (linkId: string) => byLinkId().get(linkId) ?? 0, + /** Unread Signal count summed across every connected inbox. */ + total: () => query.data?.total ?? 0, + }; +} + +/** Refetch the unread counts — call after anything that reads or unreads mail. */ +export function invalidateEmailUnreadCounts() { + queryClient.invalidateQueries({ + queryKey: emailKeys.unreadCounts.queryKey, + }); +} diff --git a/apps/web/src/lib/service-clients/service-email/client.ts b/apps/web/src/lib/service-clients/service-email/client.ts index 06386998d9e..0f76bc32808 100644 --- a/apps/web/src/lib/service-clients/service-email/client.ts +++ b/apps/web/src/lib/service-clients/service-email/client.ts @@ -20,6 +20,7 @@ import type { ListEmailFiltersResponse, ListLabelsResponse, ListLinksResponse, + ListUnreadCountsResponse, PatchSettingsRequest, PatchSettingsResponse, ResyncResponse, @@ -298,6 +299,14 @@ export const emailClient = { ).map((result) => result); }, + async getUnreadCounts() { + return ( + await emailFetch('/email/links/unread-counts', { + method: 'GET', + }) + ).map((result) => result); + }, + async listBackfillJobs() { return ( await emailFetch('/email/backfill/gmail', { diff --git a/apps/web/src/lib/service-clients/service-email/generated/client.ts b/apps/web/src/lib/service-clients/service-email/generated/client.ts index d6bc950296e..208607a8e03 100644 --- a/apps/web/src/lib/service-clients/service-email/generated/client.ts +++ b/apps/web/src/lib/service-clients/service-email/generated/client.ts @@ -37,6 +37,7 @@ import type { ListEmailFiltersResponse, ListLabelsResponse, ListLinksResponse, + ListUnreadCountsResponse, ParsedMessage, PatchSettingsRequest, PatchSettingsResponse, @@ -1862,6 +1863,60 @@ export const healthCheckLinks = async ( } as healthCheckLinksResponse; }; +/** + * @summary Unread Signal-view thread counts for every inbox the caller can read. + */ +export type listUnreadCountsResponse200 = { + data: ListUnreadCountsResponse; + status: 200; +}; + +export type listUnreadCountsResponse401 = { + data: ErrorResponse; + status: 401; +}; + +export type listUnreadCountsResponse500 = { + data: ErrorResponse; + status: 500; +}; + +export type listUnreadCountsResponseSuccess = listUnreadCountsResponse200 & { + headers: Headers; +}; +export type listUnreadCountsResponseError = ( + | listUnreadCountsResponse401 + | listUnreadCountsResponse500 +) & { + headers: Headers; +}; + +export type listUnreadCountsResponse = + | listUnreadCountsResponseSuccess + | listUnreadCountsResponseError; + +export const getListUnreadCountsUrl = () => { + return `/email/links/unread-counts`; +}; + +export const listUnreadCounts = async ( + options?: RequestInit +): Promise => { + const res = await fetch(getListUnreadCountsUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: listUnreadCountsResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as listUnreadCountsResponse; +}; + /** * For an inbox the caller owns this enqueues a full cascade teardown (`LinkManagerMessage::DeleteLink`). For an inbox reached via delegation it diff --git a/apps/web/src/lib/service-clients/service-email/generated/schemas/apiInboxUnreadCount.ts b/apps/web/src/lib/service-clients/service-email/generated/schemas/apiInboxUnreadCount.ts new file mode 100644 index 00000000000..cd73b09b6eb --- /dev/null +++ b/apps/web/src/lib/service-clients/service-email/generated/schemas/apiInboxUnreadCount.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * email_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * Unread Signal-view thread count for one of the caller's inboxes. + */ +export interface ApiInboxUnreadCount { + /** The inbox (email link) the count belongs to. */ + link_id: string; + /** Unread signal threads currently visible in that inbox's inbox view. */ + unread_count: number; +} diff --git a/apps/web/src/lib/service-clients/service-email/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-email/generated/schemas/index.ts index eeac0afe761..05a42d2f0b2 100644 --- a/apps/web/src/lib/service-clients/service-email/generated/schemas/index.ts +++ b/apps/web/src/lib/service-clients/service-email/generated/schemas/index.ts @@ -66,6 +66,7 @@ export * from './apiDraftOutputTo'; export * from './apiEmailFilter'; export * from './apiEmailFilterEmailAddress'; export * from './apiEmailFilterEmailDomain'; +export * from './apiInboxUnreadCount'; export * from './apiLabel'; export * from './apiLabelListVisibility'; export * from './apiLabelType'; @@ -197,6 +198,7 @@ export * from './listContactsResponseContacts'; export * from './listEmailFiltersResponse'; export * from './listLabelsResponse'; export * from './listLinksResponse'; +export * from './listUnreadCountsResponse'; export * from './message'; export * from './messageBodyHtmlSanitized'; export * from './messageBodyMacro'; diff --git a/apps/web/src/lib/service-clients/service-email/generated/schemas/listUnreadCountsResponse.ts b/apps/web/src/lib/service-clients/service-email/generated/schemas/listUnreadCountsResponse.ts new file mode 100644 index 00000000000..a54eec5d456 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-email/generated/schemas/listUnreadCountsResponse.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * email_service + * OpenAPI spec version: 0.1.0 + */ +import type { ApiInboxUnreadCount } from './apiInboxUnreadCount'; + +/** + * Response body for the per-inbox unread Signal counts. + */ +export interface ListUnreadCountsResponse { + /** One entry per inbox accessible to the caller, including inboxes with +nothing unread. */ + counts: ApiInboxUnreadCount[]; + /** The sum across every inbox, for clients showing a single badge. */ + total: number; +} diff --git a/apps/web/src/lib/service-clients/service-email/openapi.json b/apps/web/src/lib/service-clients/service-email/openapi.json index 8825787b026..aeca2607525 100644 --- a/apps/web/src/lib/service-clients/service-email/openapi.json +++ b/apps/web/src/lib/service-clients/service-email/openapi.json @@ -1661,6 +1661,45 @@ } } }, + "/email/links/unread-counts": { + "get": { + "tags": ["Links"], + "summary": "Unread Signal-view thread counts for every inbox the caller can read.", + "operationId": "list_unread_counts", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnreadCountsResponse" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/email/links/{link_id}": { "delete": { "tags": ["Links"], @@ -3206,6 +3245,23 @@ } } }, + "ApiInboxUnreadCount": { + "type": "object", + "description": "Unread Signal-view thread count for one of the caller's inboxes.", + "required": ["link_id", "unread_count"], + "properties": { + "link_id": { + "type": "string", + "format": "uuid", + "description": "The inbox (email link) the count belongs to." + }, + "unread_count": { + "type": "integer", + "format": "int64", + "description": "Unread signal threads currently visible in that inbox's inbox view." + } + } + }, "ApiLabel": { "type": "object", "required": [ @@ -4397,6 +4453,25 @@ } } }, + "ListUnreadCountsResponse": { + "type": "object", + "description": "Response body for the per-inbox unread Signal counts.", + "required": ["counts", "total"], + "properties": { + "counts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiInboxUnreadCount" + }, + "description": "One entry per inbox accessible to the caller, including inboxes with\nnothing unread." + }, + "total": { + "type": "integer", + "format": "int64", + "description": "The sum across every inbox, for clients showing a single badge." + } + } + }, "Message": { "type": "object", "required": [ diff --git a/crates/complete_graph/src/schema/test.rs b/crates/complete_graph/src/schema/test.rs index bf7786ff5ce..51087ef351e 100644 --- a/crates/complete_graph/src/schema/test.rs +++ b/crates/complete_graph/src/schema/test.rs @@ -5,9 +5,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use axum::http::{Request as HttpRequest, header}; use email::domain::models::{ CreateDraftInput, CreatedDraft, EmailErr, EmailFilter, EmailSyncStatus, - EnrichedEmailThreadPreview, GetEmailsRequest, LabelListVisibility, LabelType, Link, LinkLabel, - MessageListVisibility, ParsedMessage, ParsedThread, Thread, UpdateThreadLabelsResult, - UpsertEmailFilterInput, UserEmailLink, UserEmailLinkSettings, UserProvider, + EnrichedEmailThreadPreview, GetEmailsRequest, InboxUnreadSignalCount, LabelListVisibility, + LabelType, Link, LinkLabel, MessageListVisibility, ParsedMessage, ParsedThread, Thread, + UpdateThreadLabelsResult, UpsertEmailFilterInput, UserEmailLink, UserEmailLinkSettings, + UserProvider, }; use entity_access::domain::models::{ AccessError, AccessLevel, BotAccessScope, BotId, CallChannelInfo, EditAccessLevel, @@ -279,6 +280,17 @@ impl EmailUserService for CountingEmailService { updated_at: Default::default(), }]) } + + async fn get_user_unread_signal_counts( + &self, + macro_id: MacroUserIdStr<'static>, + ) -> Result, EmailErr> { + self.user_catalog_identities + .lock() + .expect("user catalog identities lock") + .push(macro_id); + Ok(Vec::new()) + } } impl EmailService for CountingEmailService { diff --git a/crates/email/src/domain/models/link.rs b/crates/email/src/domain/models/link.rs index 1784f2488b5..34c48757587 100644 --- a/crates/email/src/domain/models/link.rs +++ b/crates/email/src/domain/models/link.rs @@ -113,6 +113,20 @@ pub struct EmailInboxDetails { pub updated_at: DateTime, } +/// Unread Signal-view thread count for one inbox. +/// +/// "Signal" is the denormalized `email_threads.is_signal` heuristic the mail +/// list's Signal tab filters on, scoped to the inbox view — noise threads are +/// deliberately not counted, so the badge only ever nags about mail the user +/// would see on the tab they land on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InboxUnreadSignalCount { + /// The inbox the count belongs to. + pub link_id: Uuid, + /// Unread signal threads currently visible in the inbox view. + pub unread_count: i64, +} + /// Enriched email link visible to an authenticated user. /// /// The model intentionally omits the provider's authentication-system user ID, diff --git a/crates/email/src/domain/models/mod.rs b/crates/email/src/domain/models/mod.rs index e550b39591f..ecd03a749a4 100644 --- a/crates/email/src/domain/models/mod.rs +++ b/crates/email/src/domain/models/mod.rs @@ -26,8 +26,8 @@ pub use label::{ UpdateThreadLabelsResult, }; pub use link::{ - EmailBackfillStatus, EmailInboxDetails, EmailSyncStatus, Link, UserEmailLink, - UserEmailLinkSettings, UserProvider, + EmailBackfillStatus, EmailInboxDetails, EmailSyncStatus, InboxUnreadSignalCount, Link, + UserEmailLink, UserEmailLinkSettings, UserProvider, }; pub use message::{Message, MessageRow, SimpleMessage}; pub use parsed_message::{ParsedLabel, ParsedMessage, ParsedThread}; diff --git a/crates/email/src/domain/ports.rs b/crates/email/src/domain/ports.rs index bf4b6c6b0ec..0874f002f12 100644 --- a/crates/email/src/domain/ports.rs +++ b/crates/email/src/domain/ports.rs @@ -1,11 +1,11 @@ use crate::domain::models::{ Attachment, AttachmentDraft, AttachmentForwarded, Contact, ContactInfo, CreateDraftInput, CreatedDraft, EmailErr, EmailFilter, EmailInboxDetails, EmailThreadPreview, - EnrichedEmailThreadPreview, GetEmailsRequest, Label, Link, LinkLabel, MessageAttachment, - MessageLabel, MessageRow, ParsedAddresses, ParsedMessage, ParsedThread, PreviewCursorQuery, - RecipientType, ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, Thread, ThreadRow, - UpdateThreadLabelsResult, UpsertEmailFilterInput, UpsertedContacts, UserEmailLink, - UserProvider, + EnrichedEmailThreadPreview, GetEmailsRequest, InboxUnreadSignalCount, Label, Link, LinkLabel, + MessageAttachment, MessageLabel, MessageRow, ParsedAddresses, ParsedMessage, ParsedThread, + PreviewCursorQuery, RecipientType, ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, + Thread, ThreadRow, UpdateThreadLabelsResult, UpsertEmailFilterInput, UpsertedContacts, + UserEmailLink, UserProvider, }; use chrono::{DateTime, Utc}; use entity_access::domain::models::{EditAccessLevel, EntityAccessReceipt, ViewAccessLevel}; @@ -72,6 +72,16 @@ pub trait EmailUserRepo: Send + Sync + 'static { &self, macro_id: MacroUserIdStr<'static>, ) -> impl Future, EmailErr>> + Send; + + /// Count unread signal threads in the inbox view for each of the given, + /// already-authorized inboxes. + /// + /// Inboxes with nothing unread may be omitted from the result — zero-fill + /// is the domain service's job, not the repository's. + fn unread_signal_counts_for_links( + &self, + link_ids: &[Uuid], + ) -> impl Future, EmailErr>> + Send; } pub trait EmailRepo: Send + Sync + 'static { @@ -421,6 +431,13 @@ pub trait EmailUserService: Send + Sync + 'static { &self, macro_id: MacroUserIdStr<'static>, ) -> impl Future, EmailErr>> + Send; + + /// Unread signal-thread counts for every inbox accessible to the user, one + /// entry per inbox including the ones with nothing unread. + fn get_user_unread_signal_counts( + &self, + macro_id: MacroUserIdStr<'static>, + ) -> impl Future, EmailErr>> + Send; } pub trait EmailService: Send + Sync + 'static { @@ -634,6 +651,13 @@ impl EmailUserService for NoOpEmailService { ) -> Result, EmailErr> { Err(no_op_email_err()) } + + async fn get_user_unread_signal_counts( + &self, + _macro_id: MacroUserIdStr<'static>, + ) -> Result, EmailErr> { + Err(no_op_email_err()) + } } impl EmailService for NoOpEmailService { diff --git a/crates/email/src/domain/service/user.rs b/crates/email/src/domain/service/user.rs index 293a83ea4f6..f8d9ab1ccd7 100644 --- a/crates/email/src/domain/service/user.rs +++ b/crates/email/src/domain/service/user.rs @@ -1,8 +1,9 @@ use crate::domain::{ - models::{EmailErr, LinkLabel, UserEmailLink}, + models::{EmailErr, InboxUnreadSignalCount, LinkLabel, UserEmailLink}, ports::{EmailUserRepo, EmailUserService}, }; use macro_user_id::user_id::MacroUserIdStr; +use std::collections::HashMap; use super::EmailServiceImpl; @@ -47,4 +48,34 @@ where .map(UserEmailLink::from) .collect()) } + + async fn get_user_unread_signal_counts( + &self, + macro_id: MacroUserIdStr<'static>, + ) -> Result, EmailErr> { + let inboxes = self.email_repo.user_accessible_inboxes(macro_id).await?; + if inboxes.is_empty() { + return Ok(Vec::new()); + } + + let link_ids: Vec<_> = inboxes.iter().map(|inbox| inbox.id).collect(); + let counted: HashMap<_, _> = self + .email_repo + .unread_signal_counts_for_links(&link_ids) + .await? + .into_iter() + .map(|count| (count.link_id, count.unread_count)) + .collect(); + + // One entry per accessible inbox, in the repository's inbox order: a + // caught-up inbox reports 0 rather than vanishing, so the client can + // clear a stale badge without inferring absence. + Ok(link_ids + .into_iter() + .map(|link_id| InboxUnreadSignalCount { + link_id, + unread_count: counted.get(&link_id).copied().unwrap_or(0), + }) + .collect()) + } } diff --git a/crates/email/src/domain/service/user/test.rs b/crates/email/src/domain/service/user/test.rs index be2bbf6919f..d3a5cd831ac 100644 --- a/crates/email/src/domain/service/user/test.rs +++ b/crates/email/src/domain/service/user/test.rs @@ -9,8 +9,8 @@ use uuid::Uuid; use crate::domain::{ models::{ - EmailBackfillStatus, EmailInboxDetails, EmailSyncStatus, Link, LinkLabel, - UserEmailLinkSettings, UserProvider, + EmailBackfillStatus, EmailInboxDetails, EmailSyncStatus, InboxUnreadSignalCount, Link, + LinkLabel, UserEmailLinkSettings, UserProvider, }, ports::{EmailUserRepo, EmailUserService}, }; @@ -22,8 +22,13 @@ struct FakeUserRepo { inboxes: Vec, labels: HashMap>, details: Vec, + /// Per-link unread counts the repo reports. Links absent from the map are + /// omitted from the repo's response, as the real one omits caught-up + /// inboxes. + unread_counts: HashMap, requested_users: Arc>>>, requested_label_links: Arc>>, + requested_count_links: Arc>>>, } impl EmailUserRepo for FakeUserRepo { @@ -50,6 +55,27 @@ impl EmailUserRepo for FakeUserRepo { self.requested_users.lock().unwrap().push(macro_id); Ok(self.details.clone()) } + + async fn unread_signal_counts_for_links( + &self, + link_ids: &[Uuid], + ) -> Result, crate::domain::models::EmailErr> { + self.requested_count_links + .lock() + .unwrap() + .push(link_ids.to_vec()); + Ok(link_ids + .iter() + .filter_map(|link_id| { + self.unread_counts + .get(link_id) + .map(|unread_count| InboxUnreadSignalCount { + link_id: *link_id, + unread_count: *unread_count, + }) + }) + .collect()) + } } fn service(repo: FakeUserRepo) -> EmailServiceImpl { @@ -168,3 +194,59 @@ async fn links_are_scoped_to_the_user_and_enriched_by_domain_policy() { Some("

Regards

") ); } + +#[tokio::test] +async fn unread_counts_cover_every_accessible_inbox_including_caught_up_ones() { + let owned = Uuid::from_u128(1); + let delegated = Uuid::from_u128(2); + let repo = FakeUserRepo { + inboxes: vec![ + link(owned, "viewer@example.com"), + link(delegated, "delegate@example.com"), + ], + // The delegated inbox is caught up, so the repo omits it entirely. + unread_counts: HashMap::from([(owned, 7)]), + ..Default::default() + }; + let requested_users = Arc::clone(&repo.requested_users); + let requested_count_links = Arc::clone(&repo.requested_count_links); + + let counts = service(repo) + .get_user_unread_signal_counts(user_id()) + .await + .unwrap(); + + assert_eq!(*requested_users.lock().unwrap(), vec![user_id()]); + // Counts are only ever asked for over the caller's own accessible inboxes. + assert_eq!( + *requested_count_links.lock().unwrap(), + vec![vec![owned, delegated]] + ); + assert_eq!( + counts, + vec![ + InboxUnreadSignalCount { + link_id: owned, + unread_count: 7, + }, + InboxUnreadSignalCount { + link_id: delegated, + unread_count: 0, + }, + ] + ); +} + +#[tokio::test] +async fn unread_counts_skip_the_repo_entirely_when_no_inbox_is_linked() { + let repo = FakeUserRepo::default(); + let requested_count_links = Arc::clone(&repo.requested_count_links); + + let counts = service(repo) + .get_user_unread_signal_counts(user_id()) + .await + .unwrap(); + + assert!(counts.is_empty()); + assert!(requested_count_links.lock().unwrap().is_empty()); +} diff --git a/crates/email/src/inbound/axum.rs b/crates/email/src/inbound/axum.rs index b662fce1be2..50d158adad2 100644 --- a/crates/email/src/inbound/axum.rs +++ b/crates/email/src/inbound/axum.rs @@ -8,6 +8,7 @@ pub mod previews_router; pub mod send_router; pub mod thread_labels_router; pub mod thread_project_router; +pub mod unread_counts_router; pub use api_types::{ ApiAttachment, ApiAttachmentDraft, ApiAttachmentForwarded, ApiContact, ApiContactInfo, diff --git a/crates/email/src/inbound/axum/unread_counts_router.rs b/crates/email/src/inbound/axum/unread_counts_router.rs new file mode 100644 index 00000000000..d5a7b4ed51f --- /dev/null +++ b/crates/email/src/inbound/axum/unread_counts_router.rs @@ -0,0 +1,113 @@ +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; +use axum_extra::extract::Cached; +use macro_authorization::{ + MacroAuthorizationExtractor, MacroAuthorizationService, MacroAuthorizationState, UserOrInternal, +}; +use model_error_response::ErrorResponse; +use thiserror::Error; +use uuid::Uuid; + +use crate::domain::{ + models::{EmailErr, InboxUnreadSignalCount}, + ports::EmailUserService, +}; + +use super::previews_router::EmailRouterState; + +/// Unread Signal-view thread count for one of the caller's inboxes. +#[derive(serde::Serialize, serde::Deserialize, Debug, utoipa::ToSchema)] +pub struct ApiInboxUnreadCount { + /// The inbox (email link) the count belongs to. + pub link_id: Uuid, + /// Unread signal threads currently visible in that inbox's inbox view. + pub unread_count: i64, +} + +impl From for ApiInboxUnreadCount { + fn from(count: InboxUnreadSignalCount) -> Self { + ApiInboxUnreadCount { + link_id: count.link_id, + unread_count: count.unread_count, + } + } +} + +/// Response body for the per-inbox unread Signal counts. +#[derive(serde::Serialize, serde::Deserialize, Debug, utoipa::ToSchema)] +pub struct ListUnreadCountsResponse { + /// One entry per inbox accessible to the caller, including inboxes with + /// nothing unread. + pub counts: Vec, + /// The sum across every inbox, for clients showing a single badge. + pub total: i64, +} + +/// Errors from the unread counts handler. +#[derive(Debug, Error)] +pub enum UnreadCountsError { + /// Internal error. + #[error("Internal error")] + Internal(EmailErr), +} + +impl IntoResponse for UnreadCountsError { + fn into_response(self) -> axum::response::Response { + tracing::error!(error=?self, "unread counts error"); + let message = self.to_string(); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + message: message.into(), + }), + ) + .into_response() + } +} + +impl From for UnreadCountsError { + fn from(err: EmailErr) -> Self { + UnreadCountsError::Internal(err) + } +} + +/// Create the unread counts router with a `GET /unread-counts` handler. +pub fn unread_counts_router() -> Router +where + S: Send + Sync + Clone + 'static, + T: EmailUserService, + Auth: MacroAuthorizationService, + EmailRouterState: axum::extract::FromRef, + MacroAuthorizationState: axum::extract::FromRef, +{ + Router::new().route("/unread-counts", get(unread_counts_handler::)) +} + +/// Unread Signal-view thread counts for every inbox the caller can read. +#[utoipa::path( + get, + tag = "Links", + path = "/email/links/unread-counts", + operation_id = "list_unread_counts", + responses( + (status = 200, body = ListUnreadCountsResponse), + (status = 401, body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(err, skip_all)] +pub async fn unread_counts_handler( + State(state): State>, + Cached(macro_user): Cached>, +) -> Result, UnreadCountsError> { + let counts = state + .inner + .get_user_unread_signal_counts(macro_user.authorization.user.macro_user_id.clone()) + .await?; + + let total = counts.iter().map(|count| count.unread_count).sum(); + + Ok(Json(ListUnreadCountsResponse { + counts: counts.into_iter().map(Into::into).collect(), + total, + })) +} diff --git a/crates/email/src/outbound/email_pg_repo/link.rs b/crates/email/src/outbound/email_pg_repo/link.rs index 6974960bee2..c181f607c9a 100644 --- a/crates/email/src/outbound/email_pg_repo/link.rs +++ b/crates/email/src/outbound/email_pg_repo/link.rs @@ -1,5 +1,6 @@ use crate::domain::models::{ - EmailBackfillStatus, EmailInboxDetails, Link, UserEmailLinkSettings, UserProvider, + EmailBackfillStatus, EmailInboxDetails, InboxUnreadSignalCount, Link, UserEmailLinkSettings, + UserProvider, }; use chrono::{DateTime, Utc}; use macro_user_id::{email::EmailStr, user_id::MacroUserIdStr}; @@ -313,3 +314,39 @@ pub(super) async fn inbox_details_for_macro_id( }) .collect() } + +/// Count unread threads in each inbox's Signal view. +/// +/// The predicate mirrors the Signal tab's candidate scan — the `inbox` view's +/// `inbox_visible AND latest_inbound_message_ts IS NOT NULL` plus the +/// `Importance(true)` literal's `is_signal` — so the badge and the tab it opens +/// always agree. Inboxes with nothing unread are absent from the result. +#[tracing::instrument(err, skip(pool))] +pub(super) async fn unread_signal_counts_for_links( + pool: &PgPool, + link_ids: &[Uuid], +) -> Result, sqlx::Error> { + sqlx::query!( + r#" + SELECT t.link_id AS "link_id!", COUNT(*) AS "unread_count!" + FROM email_threads t + WHERE t.link_id = ANY($1) + AND t.inbox_visible + AND t.is_signal + AND NOT t.is_read + AND t.latest_inbound_message_ts IS NOT NULL + GROUP BY t.link_id + "#, + link_ids + ) + .fetch_all(pool) + .await + .map(|rows| { + rows.into_iter() + .map(|row| InboxUnreadSignalCount { + link_id: row.link_id, + unread_count: row.unread_count, + }) + .collect() + }) +} diff --git a/crates/email/src/outbound/email_pg_repo/mod.rs b/crates/email/src/outbound/email_pg_repo/mod.rs index f7793d8068a..65cc0b2a950 100644 --- a/crates/email/src/outbound/email_pg_repo/mod.rs +++ b/crates/email/src/outbound/email_pg_repo/mod.rs @@ -1,10 +1,10 @@ use crate::domain::{ models::{ Attachment, AttachmentDraft, AttachmentForwarded, Contact, ContactInfo, EmailErr, - EmailFilter, EmailInboxDetails, EmailThreadPreview, Label, Link, LinkLabel, - MessageAttachment, MessageLabel, MessageRow, ParsedAddresses, PreviewCursorQuery, - ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, ThreadRow, UpsertEmailFilterInput, - UpsertedContacts, UserProvider, + EmailFilter, EmailInboxDetails, EmailThreadPreview, InboxUnreadSignalCount, Label, Link, + LinkLabel, MessageAttachment, MessageLabel, MessageRow, ParsedAddresses, + PreviewCursorQuery, ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, ThreadRow, + UpsertEmailFilterInput, UpsertedContacts, UserProvider, }, ports::{EmailRepo, EmailUserRepo, LinkEmailSettings, RecipientsByMessageId}, }; @@ -81,6 +81,15 @@ impl EmailUserRepo for EmailPgRepo { .await .map_err(|error| EmailErr::RepoErr(error.into())) } + + async fn unread_signal_counts_for_links( + &self, + link_ids: &[Uuid], + ) -> Result, EmailErr> { + link::unread_signal_counts_for_links(&self.pool, link_ids) + .await + .map_err(|error| EmailErr::RepoErr(error.into())) + } } impl EmailRepo for EmailPgRepo { diff --git a/crates/email/src/outbound/email_pg_repo/test/mod.rs b/crates/email/src/outbound/email_pg_repo/test/mod.rs index 9a9fd3d1b3d..fbb8f0474e2 100644 --- a/crates/email/src/outbound/email_pg_repo/test/mod.rs +++ b/crates/email/src/outbound/email_pg_repo/test/mod.rs @@ -12,6 +12,7 @@ mod settings; mod signal_flag; mod thread; mod thread_labels; +mod unread_counts; use std::sync::Arc; diff --git a/crates/email/src/outbound/email_pg_repo/test/unread_counts.rs b/crates/email/src/outbound/email_pg_repo/test/unread_counts.rs new file mode 100644 index 00000000000..17951c482ee --- /dev/null +++ b/crates/email/src/outbound/email_pg_repo/test/unread_counts.rs @@ -0,0 +1,194 @@ +use super::*; +use crate::domain::ports::EmailUserRepo; + +const LINK_ONE: Uuid = Uuid::from_u128(0xaaaaaaaa_aaaa_aaaa_aaaa_aaaaaaaaaaaa); +const LINK_TWO: Uuid = Uuid::from_u128(0xbbbbbbbb_bbbb_bbbb_bbbb_bbbbbbbbbbbb); + +/// Adds a second inbox alongside the fixture's, so the grouping is exercised +/// with more than one link in play. +async fn insert_second_link(pool: &Pool) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO email_links (id, macro_id, fusionauth_user_id, email_address, + provider, is_sync_active, created_at, updated_at) + VALUES ($1, 'macro|user2@test.com', 'fa-user-2', 'user2@test.com', 'GMAIL', true, NOW(), NOW()) + "#, + LINK_TWO + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Inserts one thread with the exact column combination the Signal-view count +/// discriminates on. +#[allow(clippy::too_many_arguments)] +async fn insert_thread( + pool: &Pool, + id: Uuid, + link_id: Uuid, + inbox_visible: bool, + is_read: bool, + is_signal: bool, + has_inbound: bool, +) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO email_threads (id, provider_id, link_id, inbox_visible, is_read, + is_signal, latest_inbound_message_ts, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, + CASE WHEN $7 THEN NOW() ELSE NULL END, NOW(), NOW()) + "#, + id, + id.to_string(), + link_id, + inbox_visible, + is_read, + is_signal, + has_inbound + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Clears the fixture's own threads so each case counts only what it inserts. +async fn clear_threads(pool: &Pool) -> anyhow::Result<()> { + sqlx::query!("DELETE FROM email_messages") + .execute(pool) + .await?; + sqlx::query!("DELETE FROM email_threads") + .execute(pool) + .await?; + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn unread_signal_counts_group_by_link(pool: Pool) -> anyhow::Result<()> { + clear_threads(&pool).await?; + insert_second_link(&pool).await?; + + insert_thread(&pool, Uuid::from_u128(1), LINK_ONE, true, false, true, true).await?; + insert_thread(&pool, Uuid::from_u128(2), LINK_ONE, true, false, true, true).await?; + insert_thread(&pool, Uuid::from_u128(3), LINK_TWO, true, false, true, true).await?; + + let repo = EmailPgRepo::new(pool); + let mut counts = repo + .unread_signal_counts_for_links(&[LINK_ONE, LINK_TWO]) + .await?; + counts.sort_by_key(|count| count.link_id); + + assert_eq!(counts.len(), 2); + assert_eq!(counts[0].link_id, LINK_ONE); + assert_eq!(counts[0].unread_count, 2); + assert_eq!(counts[1].link_id, LINK_TWO); + assert_eq!(counts[1].unread_count, 1); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn unread_signal_counts_exclude_read_noise_archived_and_outbound_only_threads( + pool: Pool, +) -> anyhow::Result<()> { + clear_threads(&pool).await?; + + // The one thread that should be counted. + insert_thread(&pool, Uuid::from_u128(1), LINK_ONE, true, false, true, true).await?; + // Read. + insert_thread(&pool, Uuid::from_u128(2), LINK_ONE, true, true, true, true).await?; + // Noise — deliberately not counted, the badge tracks Signal only. + insert_thread( + &pool, + Uuid::from_u128(3), + LINK_ONE, + true, + false, + false, + true, + ) + .await?; + // Archived out of the inbox view. + insert_thread( + &pool, + Uuid::from_u128(4), + LINK_ONE, + false, + false, + true, + true, + ) + .await?; + // No inbound message, so the inbox view never surfaces it. + insert_thread( + &pool, + Uuid::from_u128(5), + LINK_ONE, + true, + false, + true, + false, + ) + .await?; + + let repo = EmailPgRepo::new(pool); + let counts = repo.unread_signal_counts_for_links(&[LINK_ONE]).await?; + + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].unread_count, 1); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn unread_signal_counts_omit_links_with_nothing_unread( + pool: Pool, +) -> anyhow::Result<()> { + clear_threads(&pool).await?; + insert_second_link(&pool).await?; + + insert_thread(&pool, Uuid::from_u128(1), LINK_ONE, true, false, true, true).await?; + + let repo = EmailPgRepo::new(pool); + let counts = repo + .unread_signal_counts_for_links(&[LINK_ONE, LINK_TWO]) + .await?; + + // Zero-filling is the domain service's job — the repo just omits LINK_TWO. + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].link_id, LINK_ONE); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn unread_signal_counts_ignore_threads_outside_the_requested_links( + pool: Pool, +) -> anyhow::Result<()> { + clear_threads(&pool).await?; + insert_second_link(&pool).await?; + + insert_thread(&pool, Uuid::from_u128(1), LINK_ONE, true, false, true, true).await?; + insert_thread(&pool, Uuid::from_u128(2), LINK_TWO, true, false, true, true).await?; + + let repo = EmailPgRepo::new(pool); + let counts = repo.unread_signal_counts_for_links(&[LINK_TWO]).await?; + + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].link_id, LINK_TWO); + assert_eq!(counts[0].unread_count, 1); + + Ok(()) +} diff --git a/crates/graphql_email/src/user_query/test.rs b/crates/graphql_email/src/user_query/test.rs index a214625004f..b753d50cd45 100644 --- a/crates/graphql_email/src/user_query/test.rs +++ b/crates/graphql_email/src/user_query/test.rs @@ -4,8 +4,8 @@ use async_graphql::{EmptyMutation, EmptySubscription, Schema}; use chrono::{TimeZone, Utc}; use email::domain::{ models::{ - EmailErr, EmailSyncStatus, LabelListVisibility, LabelType, LinkLabel, - MessageListVisibility, UserEmailLink, UserEmailLinkSettings, UserProvider, + EmailErr, EmailSyncStatus, InboxUnreadSignalCount, LabelListVisibility, LabelType, + LinkLabel, MessageListVisibility, UserEmailLink, UserEmailLinkSettings, UserProvider, }, ports::EmailUserService, }; @@ -46,6 +46,14 @@ impl EmailUserService for FakeEmailUserService { } Ok(vec![link()]) } + + async fn get_user_unread_signal_counts( + &self, + macro_id: MacroUserIdStr<'static>, + ) -> Result, EmailErr> { + self.requested_users.lock().unwrap().push(macro_id); + Ok(Vec::new()) + } } fn user_id() -> MacroUserIdStr<'static> { diff --git a/crates/macro_db_client/migrations/20260804183545_email_threads_unread_signal_count_index.sql b/crates/macro_db_client/migrations/20260804183545_email_threads_unread_signal_count_index.sql new file mode 100644 index 00000000000..bb251bd3c63 --- /dev/null +++ b/crates/macro_db_client/migrations/20260804183545_email_threads_unread_signal_count_index.sql @@ -0,0 +1,8 @@ +-- no-transaction +-- Backs the sidebar's unread-Signal badge: a per-link count over the same +-- predicate as the Signal tab's candidate scan. Narrower than +-- idx_email_threads_signal_view (which also spans read threads), so the count +-- stays index-only as an inbox's read history grows. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_threads_unread_signal_count + ON email_threads (link_id) + WHERE inbox_visible AND is_signal AND NOT is_read AND latest_inbound_message_ts IS NOT NULL; diff --git a/packages/sdk/generated/email/index.ts b/packages/sdk/generated/email/index.ts index 9ae698ed07d..74930b47567 100644 --- a/packages/sdk/generated/email/index.ts +++ b/packages/sdk/generated/email/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { type Options, Sdk } from './sdk.gen'; -export type { AccessLevel, AddDraftAttachmentData, AddDraftAttachmentError, AddDraftAttachmentErrors, AddDraftAttachmentRequest, AddDraftAttachmentResponse, AddDraftAttachmentResponse2, AddDraftAttachmentResponses, AddForwardedAttachmentData, AddForwardedAttachmentError, AddForwardedAttachmentErrors, AddForwardedAttachmentRequest, AddForwardedAttachmentResponse, AddForwardedAttachmentResponse2, AddForwardedAttachmentResponses, AddRemoveLabelData, AddRemoveLabelError, AddRemoveLabelErrors, AddRemoveLabelResponse, AddRemoveLabelResponses, AddRemoveThreadLabelData, AddRemoveThreadLabelError, AddRemoveThreadLabelErrors, AddRemoveThreadLabelResponse, AddRemoveThreadLabelResponses, ApiAttachment, ApiAttachmentDraft, ApiAttachmentForwarded, ApiContact, ApiContactInfo, ApiDraftContactInfo, ApiDraftInput, ApiDraftOutput, ApiEmailFilter, ApiLabel, ApiLabelListVisibility, ApiLabelType, ApiMessage, ApiMessageAttachment, ApiMessageLabel, ApiMessageListVisibility, ApiPaginatedThreadCursor, ApiSortMethod, ApiThread, ApiThreadPreviewCursor, ApiThreadPreviewCursorInner, ArchiveThreadData, ArchiveThreadError, ArchiveThreadErrors, ArchiveThreadRequest, ArchiveThreadResponse, ArchiveThreadResponses, Attachment, AttachmentDraft, AttachmentForwarded, BackfillJob, BackfillJobStatus, BackfillStatus, BlockSenderData, BlockSenderError, BlockSenderErrors, BlockSenderRequest, BlockSenderResponses, CancelBackfillGmailData, CancelBackfillGmailError, CancelBackfillGmailErrors, CancelBackfillGmailResponse, CancelBackfillGmailResponses, CancelBackfillParams, ClientOptions, Contact, ContactInfo, ContactInfoLegacy, ContactInfoWithInteraction, CreateDraftData, CreateDraftError, CreateDraftErrors, CreateDraftRequest, CreateDraftResponse, CreateDraftResponse2, CreateDraftResponses, CreateLabelData, CreateLabelError, CreateLabelErrors, CreateLabelRequest, CreateLabelResponse, CreateLabelResponse2, CreateLabelResponses, DeleteDraftData, DeleteDraftError, DeleteDraftErrors, DeleteDraftResponse, DeleteDraftResponses, DeleteEmailFilterData, DeleteEmailFilterError, DeleteEmailFilterErrors, DeleteEmailFilterResponse, DeleteEmailFilterResponses, DeleteLabelData, DeleteLabelError, DeleteLabelErrors, DeleteLabelResponse, DeleteLabelResponses, DeleteLinkData, DeleteLinkError, DeleteLinkErrors, DeleteLinkResponse, DeleteLinkResponses, DeleteScheduledDraftData, DeleteScheduledDraftError, DeleteScheduledDraftErrors, DeleteScheduledDraftResponse, DeleteScheduledDraftResponses, DisableSyncData, DisableSyncError, DisableSyncErrors, DisableSyncResponse, DisableSyncResponses, EmptyResponse, ErrorResponse, GetActiveBackfillJobResponse, GetAttachmentData, GetAttachmentDocumentIdData, GetAttachmentDocumentIdError, GetAttachmentDocumentIdErrors, GetAttachmentDocumentIdResponse, GetAttachmentDocumentIdResponse2, GetAttachmentDocumentIdResponses, GetAttachmentError, GetAttachmentErrors, GetAttachmentResponse, GetAttachmentResponse2, GetAttachmentResponses, GetBackfillGmailActiveData, GetBackfillGmailActiveError, GetBackfillGmailActiveErrors, GetBackfillGmailActiveResponse, GetBackfillGmailActiveResponses, GetBackfillGmailData, GetBackfillGmailError, GetBackfillGmailErrors, GetBackfillGmailResponse, GetBackfillGmailResponses, GetBackfillJobResponse, GetMessageData, GetMessageError, GetMessageErrors, GetMessageResponse, GetMessageResponses, GetMessagesBatchData, GetMessagesBatchError, GetMessagesBatchErrors, GetMessagesBatchResponse, GetMessagesBatchResponses, GetPreviewsCursorParams, GetScheduledMessagesData, GetScheduledMessagesError, GetScheduledMessagesErrors, GetScheduledMessagesResponse, GetScheduledMessagesResponses, GetScheduledResponse, GetThreadData, GetThreadError, GetThreadErrors, GetThreadMessagesHandlerData, GetThreadMessagesHandlerError, GetThreadMessagesHandlerErrors, GetThreadMessagesHandlerResponse, GetThreadMessagesHandlerResponses, GetThreadResponse, GetThreadResponse2, GetThreadResponses, HealthCheckLinksData, HealthCheckLinksError, HealthCheckLinksErrors, HealthCheckLinksResponse, HealthCheckLinksResponses, HealthHandlerData, HealthHandlerResponse, HealthHandlerResponses, InitErrorCodeResponse, InitResponse, InitUserData, InitUserError, InitUserErrors, InitUserResponse, InitUserResponses, Label, LabelInfo, LabelListVisibility, LabelType, Link, ListBackfillGmailData, ListBackfillGmailError, ListBackfillGmailErrors, ListBackfillGmailResponse, ListBackfillGmailResponses, ListBackfillJobsResponse, ListBlockedResponse, ListBlockedSendersData, ListBlockedSendersError, ListBlockedSendersErrors, ListBlockedSendersResponse, ListBlockedSendersResponses, ListContactsData, ListContactsError, ListContactsErrors, ListContactsResponse, ListContactsResponse2, ListContactsResponses, ListEmailFiltersData, ListEmailFiltersError, ListEmailFiltersErrors, ListEmailFiltersResponse, ListEmailFiltersResponse2, ListEmailFiltersResponses, ListLabelsData, ListLabelsError, ListLabelsErrors, ListLabelsResponse, ListLabelsResponse2, ListLabelsResponses, ListLinksData, ListLinksError, ListLinksErrors, ListLinksResponse, ListLinksResponse2, ListLinksResponses, Message, MessageListVisibility, ParsedMessage, PatchSettingsData, PatchSettingsError, PatchSettingsErrors, PatchSettingsRequest, PatchSettingsResponse, PatchSettingsResponse2, PatchSettingsResponses, PreviewsInboxCursorData, PreviewsInboxCursorError, PreviewsInboxCursorErrors, PreviewsInboxCursorResponse, PreviewsInboxCursorResponses, PreviewView, PreviewViewStandardLabel, RefreshEmailEvent, RemoveDraftAttachmentData, RemoveDraftAttachmentError, RemoveDraftAttachmentErrors, RemoveDraftAttachmentResponse, RemoveDraftAttachmentResponses, RemoveForwardedAttachmentData, RemoveForwardedAttachmentError, RemoveForwardedAttachmentErrors, RemoveForwardedAttachmentResponse, RemoveForwardedAttachmentResponses, ResyncLinkData, ResyncLinkError, ResyncLinkErrors, ResyncLinkResponse, ResyncLinkResponses, ResyncResponse, SendMessageData, SendMessageError, SendMessageErrors, SendMessageRequest, SendMessageResponse, SendMessageResponse2, SendMessageResponses, Settings, SharedInboxConflictResponse, SyncStatus, Thread, ThreadList, ThreadPreviewCursor, ThreadSeenData, ThreadSeenError, ThreadSeenErrors, ThreadSeenResponse, ThreadSeenResponses, ThreadSummary, UnblockSenderData, UnblockSenderError, UnblockSenderErrors, UnblockSenderRequest, UnblockSenderResponse, UnblockSenderResponses, UnresolvedSignatureImagesError, UpdateLabelBatchRequest, UpdateLabelBatchResponse, UpdateThreadLabelRequest, UpdateThreadLabelsResponse, UpdateThreadProjectData, UpdateThreadProjectError, UpdateThreadProjectErrors, UpdateThreadProjectRequest, UpdateThreadProjectResponse, UpdateThreadProjectResponse2, UpdateThreadProjectResponses, UpsertEmailFilterData, UpsertEmailFilterError, UpsertEmailFilterErrors, UpsertEmailFilterRequest, UpsertEmailFilterResponse, UpsertEmailFilterResponse2, UpsertEmailFilterResponses, UpsertScheduledMessageData, UpsertScheduledMessageError, UpsertScheduledMessageErrors, UpsertScheduledMessageResponse, UpsertScheduledMessageResponses, UpsertScheduledRequest, UpsertScheduledResponse, UserProvider, Value } from './types.gen'; +export type { AccessLevel, AddDraftAttachmentData, AddDraftAttachmentError, AddDraftAttachmentErrors, AddDraftAttachmentRequest, AddDraftAttachmentResponse, AddDraftAttachmentResponse2, AddDraftAttachmentResponses, AddForwardedAttachmentData, AddForwardedAttachmentError, AddForwardedAttachmentErrors, AddForwardedAttachmentRequest, AddForwardedAttachmentResponse, AddForwardedAttachmentResponse2, AddForwardedAttachmentResponses, AddRemoveLabelData, AddRemoveLabelError, AddRemoveLabelErrors, AddRemoveLabelResponse, AddRemoveLabelResponses, AddRemoveThreadLabelData, AddRemoveThreadLabelError, AddRemoveThreadLabelErrors, AddRemoveThreadLabelResponse, AddRemoveThreadLabelResponses, ApiAttachment, ApiAttachmentDraft, ApiAttachmentForwarded, ApiContact, ApiContactInfo, ApiDraftContactInfo, ApiDraftInput, ApiDraftOutput, ApiEmailFilter, ApiInboxUnreadCount, ApiLabel, ApiLabelListVisibility, ApiLabelType, ApiMessage, ApiMessageAttachment, ApiMessageLabel, ApiMessageListVisibility, ApiPaginatedThreadCursor, ApiSortMethod, ApiThread, ApiThreadPreviewCursor, ApiThreadPreviewCursorInner, ArchiveThreadData, ArchiveThreadError, ArchiveThreadErrors, ArchiveThreadRequest, ArchiveThreadResponse, ArchiveThreadResponses, Attachment, AttachmentDraft, AttachmentForwarded, BackfillJob, BackfillJobStatus, BackfillStatus, BlockSenderData, BlockSenderError, BlockSenderErrors, BlockSenderRequest, BlockSenderResponses, CancelBackfillGmailData, CancelBackfillGmailError, CancelBackfillGmailErrors, CancelBackfillGmailResponse, CancelBackfillGmailResponses, CancelBackfillParams, ClientOptions, Contact, ContactInfo, ContactInfoLegacy, ContactInfoWithInteraction, CreateDraftData, CreateDraftError, CreateDraftErrors, CreateDraftRequest, CreateDraftResponse, CreateDraftResponse2, CreateDraftResponses, CreateLabelData, CreateLabelError, CreateLabelErrors, CreateLabelRequest, CreateLabelResponse, CreateLabelResponse2, CreateLabelResponses, DeleteDraftData, DeleteDraftError, DeleteDraftErrors, DeleteDraftResponse, DeleteDraftResponses, DeleteEmailFilterData, DeleteEmailFilterError, DeleteEmailFilterErrors, DeleteEmailFilterResponse, DeleteEmailFilterResponses, DeleteLabelData, DeleteLabelError, DeleteLabelErrors, DeleteLabelResponse, DeleteLabelResponses, DeleteLinkData, DeleteLinkError, DeleteLinkErrors, DeleteLinkResponse, DeleteLinkResponses, DeleteScheduledDraftData, DeleteScheduledDraftError, DeleteScheduledDraftErrors, DeleteScheduledDraftResponse, DeleteScheduledDraftResponses, DisableSyncData, DisableSyncError, DisableSyncErrors, DisableSyncResponse, DisableSyncResponses, EmptyResponse, ErrorResponse, GetActiveBackfillJobResponse, GetAttachmentData, GetAttachmentDocumentIdData, GetAttachmentDocumentIdError, GetAttachmentDocumentIdErrors, GetAttachmentDocumentIdResponse, GetAttachmentDocumentIdResponse2, GetAttachmentDocumentIdResponses, GetAttachmentError, GetAttachmentErrors, GetAttachmentResponse, GetAttachmentResponse2, GetAttachmentResponses, GetBackfillGmailActiveData, GetBackfillGmailActiveError, GetBackfillGmailActiveErrors, GetBackfillGmailActiveResponse, GetBackfillGmailActiveResponses, GetBackfillGmailData, GetBackfillGmailError, GetBackfillGmailErrors, GetBackfillGmailResponse, GetBackfillGmailResponses, GetBackfillJobResponse, GetMessageData, GetMessageError, GetMessageErrors, GetMessageResponse, GetMessageResponses, GetMessagesBatchData, GetMessagesBatchError, GetMessagesBatchErrors, GetMessagesBatchResponse, GetMessagesBatchResponses, GetPreviewsCursorParams, GetScheduledMessagesData, GetScheduledMessagesError, GetScheduledMessagesErrors, GetScheduledMessagesResponse, GetScheduledMessagesResponses, GetScheduledResponse, GetThreadData, GetThreadError, GetThreadErrors, GetThreadMessagesHandlerData, GetThreadMessagesHandlerError, GetThreadMessagesHandlerErrors, GetThreadMessagesHandlerResponse, GetThreadMessagesHandlerResponses, GetThreadResponse, GetThreadResponse2, GetThreadResponses, HealthCheckLinksData, HealthCheckLinksError, HealthCheckLinksErrors, HealthCheckLinksResponse, HealthCheckLinksResponses, HealthHandlerData, HealthHandlerResponse, HealthHandlerResponses, InitErrorCodeResponse, InitResponse, InitUserData, InitUserError, InitUserErrors, InitUserResponse, InitUserResponses, Label, LabelInfo, LabelListVisibility, LabelType, Link, ListBackfillGmailData, ListBackfillGmailError, ListBackfillGmailErrors, ListBackfillGmailResponse, ListBackfillGmailResponses, ListBackfillJobsResponse, ListBlockedResponse, ListBlockedSendersData, ListBlockedSendersError, ListBlockedSendersErrors, ListBlockedSendersResponse, ListBlockedSendersResponses, ListContactsData, ListContactsError, ListContactsErrors, ListContactsResponse, ListContactsResponse2, ListContactsResponses, ListEmailFiltersData, ListEmailFiltersError, ListEmailFiltersErrors, ListEmailFiltersResponse, ListEmailFiltersResponse2, ListEmailFiltersResponses, ListLabelsData, ListLabelsError, ListLabelsErrors, ListLabelsResponse, ListLabelsResponse2, ListLabelsResponses, ListLinksData, ListLinksError, ListLinksErrors, ListLinksResponse, ListLinksResponse2, ListLinksResponses, ListUnreadCountsData, ListUnreadCountsError, ListUnreadCountsErrors, ListUnreadCountsResponse, ListUnreadCountsResponse2, ListUnreadCountsResponses, Message, MessageListVisibility, ParsedMessage, PatchSettingsData, PatchSettingsError, PatchSettingsErrors, PatchSettingsRequest, PatchSettingsResponse, PatchSettingsResponse2, PatchSettingsResponses, PreviewsInboxCursorData, PreviewsInboxCursorError, PreviewsInboxCursorErrors, PreviewsInboxCursorResponse, PreviewsInboxCursorResponses, PreviewView, PreviewViewStandardLabel, RefreshEmailEvent, RemoveDraftAttachmentData, RemoveDraftAttachmentError, RemoveDraftAttachmentErrors, RemoveDraftAttachmentResponse, RemoveDraftAttachmentResponses, RemoveForwardedAttachmentData, RemoveForwardedAttachmentError, RemoveForwardedAttachmentErrors, RemoveForwardedAttachmentResponse, RemoveForwardedAttachmentResponses, ResyncLinkData, ResyncLinkError, ResyncLinkErrors, ResyncLinkResponse, ResyncLinkResponses, ResyncResponse, SendMessageData, SendMessageError, SendMessageErrors, SendMessageRequest, SendMessageResponse, SendMessageResponse2, SendMessageResponses, Settings, SharedInboxConflictResponse, SyncStatus, Thread, ThreadList, ThreadPreviewCursor, ThreadSeenData, ThreadSeenError, ThreadSeenErrors, ThreadSeenResponse, ThreadSeenResponses, ThreadSummary, UnblockSenderData, UnblockSenderError, UnblockSenderErrors, UnblockSenderRequest, UnblockSenderResponse, UnblockSenderResponses, UnresolvedSignatureImagesError, UpdateLabelBatchRequest, UpdateLabelBatchResponse, UpdateThreadLabelRequest, UpdateThreadLabelsResponse, UpdateThreadProjectData, UpdateThreadProjectError, UpdateThreadProjectErrors, UpdateThreadProjectRequest, UpdateThreadProjectResponse, UpdateThreadProjectResponse2, UpdateThreadProjectResponses, UpsertEmailFilterData, UpsertEmailFilterError, UpsertEmailFilterErrors, UpsertEmailFilterRequest, UpsertEmailFilterResponse, UpsertEmailFilterResponse2, UpsertEmailFilterResponses, UpsertScheduledMessageData, UpsertScheduledMessageError, UpsertScheduledMessageErrors, UpsertScheduledMessageResponse, UpsertScheduledMessageResponses, UpsertScheduledRequest, UpsertScheduledResponse, UserProvider, Value } from './types.gen'; diff --git a/packages/sdk/generated/email/sdk.gen.ts b/packages/sdk/generated/email/sdk.gen.ts index d976acbe051..71970937604 100644 --- a/packages/sdk/generated/email/sdk.gen.ts +++ b/packages/sdk/generated/email/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddDraftAttachmentData, AddDraftAttachmentErrors, AddDraftAttachmentResponses, AddForwardedAttachmentData, AddForwardedAttachmentErrors, AddForwardedAttachmentResponses, AddRemoveLabelData, AddRemoveLabelErrors, AddRemoveLabelResponses, AddRemoveThreadLabelData, AddRemoveThreadLabelErrors, AddRemoveThreadLabelResponses, ArchiveThreadData, ArchiveThreadErrors, ArchiveThreadResponses, BlockSenderData, BlockSenderErrors, BlockSenderResponses, CancelBackfillGmailData, CancelBackfillGmailErrors, CancelBackfillGmailResponses, CreateDraftData, CreateDraftErrors, CreateDraftResponses, CreateLabelData, CreateLabelErrors, CreateLabelResponses, DeleteDraftData, DeleteDraftErrors, DeleteDraftResponses, DeleteEmailFilterData, DeleteEmailFilterErrors, DeleteEmailFilterResponses, DeleteLabelData, DeleteLabelErrors, DeleteLabelResponses, DeleteLinkData, DeleteLinkErrors, DeleteLinkResponses, DeleteScheduledDraftData, DeleteScheduledDraftErrors, DeleteScheduledDraftResponses, DisableSyncData, DisableSyncErrors, DisableSyncResponses, GetAttachmentData, GetAttachmentDocumentIdData, GetAttachmentDocumentIdErrors, GetAttachmentDocumentIdResponses, GetAttachmentErrors, GetAttachmentResponses, GetBackfillGmailActiveData, GetBackfillGmailActiveErrors, GetBackfillGmailActiveResponses, GetBackfillGmailData, GetBackfillGmailErrors, GetBackfillGmailResponses, GetMessageData, GetMessageErrors, GetMessageResponses, GetMessagesBatchData, GetMessagesBatchErrors, GetMessagesBatchResponses, GetScheduledMessagesData, GetScheduledMessagesErrors, GetScheduledMessagesResponses, GetThreadData, GetThreadErrors, GetThreadMessagesHandlerData, GetThreadMessagesHandlerErrors, GetThreadMessagesHandlerResponses, GetThreadResponses, HealthCheckLinksData, HealthCheckLinksErrors, HealthCheckLinksResponses, HealthHandlerData, HealthHandlerResponses, InitUserData, InitUserErrors, InitUserResponses, ListBackfillGmailData, ListBackfillGmailErrors, ListBackfillGmailResponses, ListBlockedSendersData, ListBlockedSendersErrors, ListBlockedSendersResponses, ListContactsData, ListContactsErrors, ListContactsResponses, ListEmailFiltersData, ListEmailFiltersErrors, ListEmailFiltersResponses, ListLabelsData, ListLabelsErrors, ListLabelsResponses, ListLinksData, ListLinksErrors, ListLinksResponses, PatchSettingsData, PatchSettingsErrors, PatchSettingsResponses, PreviewsInboxCursorData, PreviewsInboxCursorErrors, PreviewsInboxCursorResponses, RemoveDraftAttachmentData, RemoveDraftAttachmentErrors, RemoveDraftAttachmentResponses, RemoveForwardedAttachmentData, RemoveForwardedAttachmentErrors, RemoveForwardedAttachmentResponses, ResyncLinkData, ResyncLinkErrors, ResyncLinkResponses, SendMessageData, SendMessageErrors, SendMessageResponses, ThreadSeenData, ThreadSeenErrors, ThreadSeenResponses, UnblockSenderData, UnblockSenderErrors, UnblockSenderResponses, UpdateThreadProjectData, UpdateThreadProjectErrors, UpdateThreadProjectResponses, UpsertEmailFilterData, UpsertEmailFilterErrors, UpsertEmailFilterResponses, UpsertScheduledMessageData, UpsertScheduledMessageErrors, UpsertScheduledMessageResponses } from './types.gen'; +import type { AddDraftAttachmentData, AddDraftAttachmentErrors, AddDraftAttachmentResponses, AddForwardedAttachmentData, AddForwardedAttachmentErrors, AddForwardedAttachmentResponses, AddRemoveLabelData, AddRemoveLabelErrors, AddRemoveLabelResponses, AddRemoveThreadLabelData, AddRemoveThreadLabelErrors, AddRemoveThreadLabelResponses, ArchiveThreadData, ArchiveThreadErrors, ArchiveThreadResponses, BlockSenderData, BlockSenderErrors, BlockSenderResponses, CancelBackfillGmailData, CancelBackfillGmailErrors, CancelBackfillGmailResponses, CreateDraftData, CreateDraftErrors, CreateDraftResponses, CreateLabelData, CreateLabelErrors, CreateLabelResponses, DeleteDraftData, DeleteDraftErrors, DeleteDraftResponses, DeleteEmailFilterData, DeleteEmailFilterErrors, DeleteEmailFilterResponses, DeleteLabelData, DeleteLabelErrors, DeleteLabelResponses, DeleteLinkData, DeleteLinkErrors, DeleteLinkResponses, DeleteScheduledDraftData, DeleteScheduledDraftErrors, DeleteScheduledDraftResponses, DisableSyncData, DisableSyncErrors, DisableSyncResponses, GetAttachmentData, GetAttachmentDocumentIdData, GetAttachmentDocumentIdErrors, GetAttachmentDocumentIdResponses, GetAttachmentErrors, GetAttachmentResponses, GetBackfillGmailActiveData, GetBackfillGmailActiveErrors, GetBackfillGmailActiveResponses, GetBackfillGmailData, GetBackfillGmailErrors, GetBackfillGmailResponses, GetMessageData, GetMessageErrors, GetMessageResponses, GetMessagesBatchData, GetMessagesBatchErrors, GetMessagesBatchResponses, GetScheduledMessagesData, GetScheduledMessagesErrors, GetScheduledMessagesResponses, GetThreadData, GetThreadErrors, GetThreadMessagesHandlerData, GetThreadMessagesHandlerErrors, GetThreadMessagesHandlerResponses, GetThreadResponses, HealthCheckLinksData, HealthCheckLinksErrors, HealthCheckLinksResponses, HealthHandlerData, HealthHandlerResponses, InitUserData, InitUserErrors, InitUserResponses, ListBackfillGmailData, ListBackfillGmailErrors, ListBackfillGmailResponses, ListBlockedSendersData, ListBlockedSendersErrors, ListBlockedSendersResponses, ListContactsData, ListContactsErrors, ListContactsResponses, ListEmailFiltersData, ListEmailFiltersErrors, ListEmailFiltersResponses, ListLabelsData, ListLabelsErrors, ListLabelsResponses, ListLinksData, ListLinksErrors, ListLinksResponses, ListUnreadCountsData, ListUnreadCountsErrors, ListUnreadCountsResponses, PatchSettingsData, PatchSettingsErrors, PatchSettingsResponses, PreviewsInboxCursorData, PreviewsInboxCursorErrors, PreviewsInboxCursorResponses, RemoveDraftAttachmentData, RemoveDraftAttachmentErrors, RemoveDraftAttachmentResponses, RemoveForwardedAttachmentData, RemoveForwardedAttachmentErrors, RemoveForwardedAttachmentResponses, ResyncLinkData, ResyncLinkErrors, ResyncLinkResponses, SendMessageData, SendMessageErrors, SendMessageResponses, ThreadSeenData, ThreadSeenErrors, ThreadSeenResponses, UnblockSenderData, UnblockSenderErrors, UnblockSenderResponses, UpdateThreadProjectData, UpdateThreadProjectErrors, UpdateThreadProjectResponses, UpsertEmailFilterData, UpsertEmailFilterErrors, UpsertEmailFilterResponses, UpsertScheduledMessageData, UpsertScheduledMessageErrors, UpsertScheduledMessageResponses } from './types.gen'; export type Options = Options2 & { /** @@ -329,6 +329,13 @@ export class Sdk extends HeyApiClient { return (options?.client ?? this.client).post({ url: '/email/links/health-check', ...options }); } + /** + * Unread Signal-view thread counts for every inbox the caller can read. + */ + public listUnreadCounts(options?: Options): RequestResult { + return (options?.client ?? this.client).get({ url: '/email/links/unread-counts', ...options }); + } + /** * Removes a linked inbox. * diff --git a/packages/sdk/generated/email/types.gen.ts b/packages/sdk/generated/email/types.gen.ts index de4a7f93b8a..f3695f8ad52 100644 --- a/packages/sdk/generated/email/types.gen.ts +++ b/packages/sdk/generated/email/types.gen.ts @@ -275,6 +275,20 @@ export type ApiEmailFilter = { is_important: boolean; }; +/** + * Unread Signal-view thread count for one of the caller's inboxes. + */ +export type ApiInboxUnreadCount = { + /** + * The inbox (email link) the count belongs to. + */ + link_id: string; + /** + * Unread signal threads currently visible in that inbox's inbox view. + */ + unread_count: number; +}; + export type ApiLabel = { createdAt: string; id: string; @@ -796,6 +810,21 @@ export type ListLinksResponse = { links: Array; }; +/** + * Response body for the per-inbox unread Signal counts. + */ +export type ListUnreadCountsResponse = { + /** + * One entry per inbox accessible to the caller, including inboxes with + * nothing unread. + */ + counts: Array; + /** + * The sum across every inbox, for clients showing a single badge. + */ + total: number; +}; + export type Message = { attachments: Array; /** @@ -1806,6 +1835,26 @@ export type HealthCheckLinksResponses = { export type HealthCheckLinksResponse = HealthCheckLinksResponses[keyof HealthCheckLinksResponses]; +export type ListUnreadCountsData = { + body?: never; + path?: never; + query?: never; + url: '/email/links/unread-counts'; +}; + +export type ListUnreadCountsErrors = { + 401: ErrorResponse; + 500: ErrorResponse; +}; + +export type ListUnreadCountsError = ListUnreadCountsErrors[keyof ListUnreadCountsErrors]; + +export type ListUnreadCountsResponses = { + 200: ListUnreadCountsResponse; +}; + +export type ListUnreadCountsResponse2 = ListUnreadCountsResponses[keyof ListUnreadCountsResponses]; + export type DeleteLinkData = { body?: never; path: { diff --git a/packages/sdk/specs/email.json b/packages/sdk/specs/email.json index 8825787b026..aeca2607525 100644 --- a/packages/sdk/specs/email.json +++ b/packages/sdk/specs/email.json @@ -1661,6 +1661,45 @@ } } }, + "/email/links/unread-counts": { + "get": { + "tags": ["Links"], + "summary": "Unread Signal-view thread counts for every inbox the caller can read.", + "operationId": "list_unread_counts", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnreadCountsResponse" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/email/links/{link_id}": { "delete": { "tags": ["Links"], @@ -3206,6 +3245,23 @@ } } }, + "ApiInboxUnreadCount": { + "type": "object", + "description": "Unread Signal-view thread count for one of the caller's inboxes.", + "required": ["link_id", "unread_count"], + "properties": { + "link_id": { + "type": "string", + "format": "uuid", + "description": "The inbox (email link) the count belongs to." + }, + "unread_count": { + "type": "integer", + "format": "int64", + "description": "Unread signal threads currently visible in that inbox's inbox view." + } + } + }, "ApiLabel": { "type": "object", "required": [ @@ -4397,6 +4453,25 @@ } } }, + "ListUnreadCountsResponse": { + "type": "object", + "description": "Response body for the per-inbox unread Signal counts.", + "required": ["counts", "total"], + "properties": { + "counts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiInboxUnreadCount" + }, + "description": "One entry per inbox accessible to the caller, including inboxes with\nnothing unread." + }, + "total": { + "type": "integer", + "format": "int64", + "description": "The sum across every inbox, for clients showing a single badge." + } + } + }, "Message": { "type": "object", "required": [ diff --git a/packages/sdk/src/entities/email/link.ts b/packages/sdk/src/entities/email/link.ts index 4b82072ee63..9877fc45b09 100644 --- a/packages/sdk/src/entities/email/link.ts +++ b/packages/sdk/src/entities/email/link.ts @@ -47,4 +47,16 @@ export class Link extends MacroEntity { /** The inbox's avatar URL, if any. */ readonly photoUrl = this.field('photo_url'); + + /** + * Unread threads in this inbox's Signal view — the important mail waiting, + * excluding anything classified as noise (promotions, social, bulk updates). + * + * Not part of the link record, so this is a live read rather than a cached + * field. `0` for an inbox with nothing unread. + */ + async unreadSignalCount(): Promise { + const { counts } = unwrap(await this.client.email.listUnreadCounts()); + return counts.find((c) => c.link_id === this.id)?.unread_count ?? 0; + } } diff --git a/packages/sdk/src/entities/email/namespace.ts b/packages/sdk/src/entities/email/namespace.ts index 0bbd640fde7..0bda2336a74 100644 --- a/packages/sdk/src/entities/email/namespace.ts +++ b/packages/sdk/src/entities/email/namespace.ts @@ -44,6 +44,15 @@ export class EmailNamespace { return Link.list(this.client); } + /** + * Unread Signal-view threads across every connected inbox — the important + * mail waiting, excluding anything classified as noise. Use + * {@link Link.unreadSignalCount} for a single inbox. + */ + async unreadSignalCount(): Promise { + return unwrap(await this.client.email.listUnreadCounts()).total; + } + /** Send a new email message. */ send(opts: SendEmailOptions): Promise { return EmailMessage.send(this.client, opts); diff --git a/services/email_service/src/api/email/links/mod.rs b/services/email_service/src/api/email/links/mod.rs index c29eeff4a25..7833e5580e2 100644 --- a/services/email_service/src/api/email/links/mod.rs +++ b/services/email_service/src/api/email/links/mod.rs @@ -14,4 +14,11 @@ pub fn router() -> Router { .route("/health-check", post(health_check::health_check_handler)) .route("/{link_id}", delete(delete::delete_link_handler)) .route("/{link_id}/resync", post(resync::resync_link_handler)) + .merge( + email::inbound::axum::unread_counts_router::unread_counts_router::< + ApiContext, + crate::api::context::EmailSvc, + crate::api::context::AuthorizationService, + >(), + ) } diff --git a/services/email_service/src/api/swagger.rs b/services/email_service/src/api/swagger.rs index 678fea66ca7..3e54155614d 100644 --- a/services/email_service/src/api/swagger.rs +++ b/services/email_service/src/api/swagger.rs @@ -41,6 +41,7 @@ use ::email::inbound::axum::thread_labels_router::{ use ::email::inbound::axum::thread_project_router::{ UpdateThreadProjectRequest, UpdateThreadProjectResponse, }; +use ::email::inbound::axum::unread_counts_router::{ApiInboxUnreadCount, ListUnreadCountsResponse}; use model::response::EmptyResponse; use models_email::api::link::SyncStatus; use models_email::api::refresh::{BackfillStatus, RefreshEmailEvent}; @@ -93,6 +94,7 @@ use utoipa::OpenApi; email::links::health_check::health_check_handler, email::links::delete::delete_link_handler, email::links::resync::resync_link_handler, + inbound::axum::unread_counts_router::unread_counts_handler, email::labels::create::handler, email::labels::delete::handler, inbound::axum::list_labels_router::list_labels_handler, @@ -159,6 +161,8 @@ use utoipa::OpenApi; GetAttachmentDocumentIDResponse, // Link types ListLinksResponse, + ListUnreadCountsResponse, + ApiInboxUnreadCount, Link, SyncStatus, RefreshEmailEvent,