diff --git a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx index a9670f71865..398de61f969 100644 --- a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx +++ b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx @@ -1,12 +1,18 @@ +import { + type CorrespondenceParty, + CorrespondenceSidePanelSection, + externalParties, +} from '@app/features/correspondence'; import { EntityPropertiesSection, EntityTagsSection, } from '@app/features/property/side-panel/properties'; import { SidePanel } from '@components/app/side-panel'; import { References } from '@core/component/References'; +import { useEmail } from '@core/context/user'; import { useAttachmentReferencesQuery } from '@queries/storage/attachment-references'; import type { ItemType } from '@service-storage/client'; -import { Show, Suspense } from 'solid-js'; +import { createMemo, Show, Suspense } from 'solid-js'; import { useEmailContext } from '../EmailContext'; interface EmailSidePanelSectionsProps { @@ -17,6 +23,30 @@ interface EmailSidePanelSectionsProps { export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) { const emailCtx = useEmailContext(); const canEdit = () => emailCtx.permissions().isOwner; + const currentUserEmail = useEmail(); + + // Everyone visibly on the chain — senders and To/Cc recipients across every + // message. Bcc is left out: it is deliberately hidden correspondence and + // doesn't belong in a "who is on this thread" summary. + const externalThreadParties = createMemo(() => { + const messages = emailCtx.thread()?.messages ?? []; + const participants: CorrespondenceParty[] = []; + for (const message of messages) { + if (message.from?.email) { + participants.push({ + email: message.from.email, + name: message.from.name ?? undefined, + }); + } + for (const contact of [...message.to, ...message.cc]) { + participants.push({ + email: contact.email, + name: contact.name ?? undefined, + }); + } + } + return externalParties(participants, currentUserEmail()); + }); return ( <> @@ -44,6 +74,10 @@ export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) { + ); } diff --git a/apps/web/src/features/calendar/CalendarSidePanelSections.tsx b/apps/web/src/features/calendar/CalendarSidePanelSections.tsx index 7c9b9eba34a..858a8cab2f6 100644 --- a/apps/web/src/features/calendar/CalendarSidePanelSections.tsx +++ b/apps/web/src/features/calendar/CalendarSidePanelSections.tsx @@ -1,4 +1,10 @@ +import { + type CorrespondenceParty, + CorrespondenceSidePanelSection, + externalParties, +} from '@app/features/correspondence'; import { SidePanel, useSidePanel } from '@components/app/side-panel/SidePanel'; +import { useEmail } from '@core/context/user'; import CloseIcon from '@phosphor/x.svg'; import { Button, Calendar as MiniCalendar } from '@ui'; import { createEffect, createMemo, createSignal, on, Show } from 'solid-js'; @@ -137,6 +143,42 @@ function CalendarSourcesSidePanelSection() { ); } +/** + * Correspondence for the selected event. Only present while an event is + * selected, and (via the section itself) only when that event has external + * parties on it. + */ +function CalendarCorrespondenceSidePanelSection() { + const calendarView = useCalendarView(); + const currentUserEmail = useEmail(); + + // The organizer plus every attendee. `isSelf` marks the connected account + // on the event; `externalParties` drops it again by address, so a + // provider that omits the flag still can't leak the user into the panel. + const parties = createMemo(() => { + const event = calendarView.selectedEvent(); + if (!event) return []; + + const participants: CorrespondenceParty[] = []; + if (event.organizerEmail) { + participants.push({ + email: event.organizerEmail, + name: event.organizerName, + }); + } + for (const attendee of event.attendees) { + if (attendee.isSelf) continue; + participants.push({ + email: attendee.email, + name: attendee.displayName ?? undefined, + }); + } + return externalParties(participants, currentUserEmail()); + }); + + return ; +} + /** Registers the calendar's contextual right-side panel sections. */ export function CalendarSidePanelSections() { const sidePanel = useSidePanel(); @@ -146,6 +188,7 @@ export function CalendarSidePanelSections() { + ); } diff --git a/apps/web/src/features/companies/Company/emailFilter.ts b/apps/web/src/features/companies/Company/emailFilter.ts index a8f715180ea..e99e3da5a73 100644 --- a/apps/web/src/features/companies/Company/emailFilter.ts +++ b/apps/web/src/features/companies/Company/emailFilter.ts @@ -35,6 +35,18 @@ export function emailFilterForAddress(email: string): unknown { return anyDirection({ Complete: email }); } +/** + * Any-direction match for any of the given addresses. Returns `undefined` + * when the list is empty so the caller can skip setting `ef` entirely. + */ +export function emailFilterForAddresses( + addresses: string[] +): unknown | undefined { + if (addresses.length === 0) return undefined; + const trees = addresses.map((address) => emailFilterForAddress(address)); + return trees.reduce((acc, cur) => ({ '|': [acc, cur] })); +} + /** * Any-direction match for any of the given domains. Returns `undefined` * when the list is empty so the caller can skip setting `ef` entirely. diff --git a/apps/web/src/features/correspondence/CorrespondenceSection.tsx b/apps/web/src/features/correspondence/CorrespondenceSection.tsx new file mode 100644 index 00000000000..c416827dbb0 --- /dev/null +++ b/apps/web/src/features/correspondence/CorrespondenceSection.tsx @@ -0,0 +1,212 @@ +import { openEntityInSplitFromUnifiedList } from '@app/features/next-soup/utils'; +import { SidePanel } from '@components/app/side-panel'; +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { + ListEntity, + ListEntityMetadataQueryProvider, + ListLayoutProvider, +} from '@entity'; +import { + createMemo, + createSignal, + For, + type JSX, + type ParentProps, + Show, + Suspense, +} from 'solid-js'; +import { type CorrespondenceParty, partyDomains } from './parties'; +import { + CORRESPONDENCE_THREAD_LIMIT, + useCorrespondenceThreadsQuery, +} from './use-correspondence-threads'; +import { + useCrmCompanyForDomain, + useCrmContactForAddress, +} from './use-crm-records'; + +/** + * The "Correspondence" side-panel section: who the external parties on the + * current email thread / calendar event are, the company they belong to, and + * the recent email history with them. + * + * Renders nothing when `parties` is empty, which is how the "external parties + * only" rule is enforced — callers pass the output of + * {@link import('./parties').externalParties}. + */ +export function CorrespondenceSidePanelSection(props: { + parties: CorrespondenceParty[]; + /** Render order within the panel — lower numbers appear first. */ + order?: number; +}) { + return ( + 0}> + + + + + ); +} + +function CorrespondenceContent(props: { parties: CorrespondenceParty[] }) { + const domains = createMemo(() => partyDomains(props.parties)); + const addresses = createMemo(() => props.parties.map((p) => p.email)); + + return ( +
+ + + {(party) => } + + + + + {(domain) => } + + + + + +
+ ); +} + +function Block(props: ParentProps<{ label: JSX.Element }>) { + return ( +
+
+ {props.label} +
+ {props.children} +
+ ); +} + +/** + * One external party. Opens their CRM contact record when the team tracks + * one; otherwise it is a plain, inert row showing what the thread/event knows + * about them. + */ +function ContactRow(props: { party: CorrespondenceParty }) { + const { replaceOrInsertSplit } = useSplitLayout(); + const { contact } = useCrmContactForAddress(() => props.party.email); + + const label = () => contact()?.name ?? props.party.name ?? props.party.email; + + return ( + + ); +} + +/** + * The CRM company behind an external domain. Falls back to the bare domain + * once the lookup settles without a match — the team simply doesn't track a + * company for it yet. + */ +function CompanyRow(props: { domain: string }) { + const { replaceOrInsertSplit } = useSplitLayout(); + const { company, isLoading } = useCrmCompanyForDomain(() => props.domain); + + return ( + }> +
{props.domain}
+
+ } + > + {(record) => ( + + )} + + ); +} + +/** + * The most recent shared email threads, capped at + * {@link CORRESPONDENCE_THREAD_LIMIT} and scrolled inside a fixed-height box + * so a chatty correspondent can't stretch the panel. + */ +function RecentThreads(props: { addresses: string[] }) { + const threadsQuery = useCorrespondenceThreadsQuery(() => props.addresses); + const threads = createMemo(() => + (threadsQuery.data?.entities ?? []).slice(0, CORRESPONDENCE_THREAD_LIMIT) + ); + + const [listRef, setListRef] = createSignal(); + + return ( + }> + }> + 0} + fallback={ +
+ No emails with these contacts yet. +
+ } + > +
+ + +
+ + {(entity) => ( + + openEntityInSplitFromUnifiedList(entity, {}) + } + /> + )} + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/correspondence/index.ts b/apps/web/src/features/correspondence/index.ts new file mode 100644 index 00000000000..3d16c6a3c47 --- /dev/null +++ b/apps/web/src/features/correspondence/index.ts @@ -0,0 +1,7 @@ +export { CorrespondenceSidePanelSection } from './CorrespondenceSection'; +export { + addressDomain, + type CorrespondenceParty, + externalParties, + partyDomains, +} from './parties'; diff --git a/apps/web/src/features/correspondence/parties.test.ts b/apps/web/src/features/correspondence/parties.test.ts new file mode 100644 index 00000000000..ea2e22f1877 --- /dev/null +++ b/apps/web/src/features/correspondence/parties.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { addressDomain, externalParties, partyDomains } from './parties'; + +describe('addressDomain', () => { + it('lowercases and trims the domain part', () => { + expect(addressDomain(' Jane@Acme.COM ')).toBe('acme.com'); + }); + + it('returns undefined for addresses without a usable domain', () => { + expect(addressDomain(undefined)).toBeUndefined(); + expect(addressDomain('')).toBeUndefined(); + expect(addressDomain('jane')).toBeUndefined(); + expect(addressDomain('@acme.com')).toBeUndefined(); + expect(addressDomain('jane@')).toBeUndefined(); + }); +}); + +describe('externalParties', () => { + const self = 'me@macro.com'; + + it('drops the signed-in user and same-domain teammates', () => { + expect( + externalParties( + [ + { email: 'me@macro.com' }, + { email: 'teammate@macro.com', name: 'Team Mate' }, + { email: 'jane@acme.com', name: 'Jane' }, + ], + self + ) + ).toEqual([{ email: 'jane@acme.com', name: 'Jane' }]); + }); + + it('matches the user and their domain case-insensitively', () => { + expect( + externalParties( + [{ email: 'ME@Macro.com' }, { email: 'Teammate@MACRO.com' }], + 'me@macro.com' + ) + ).toEqual([]); + }); + + it('dedupes on the address, keeping the first display name it sees', () => { + expect( + externalParties( + [ + { email: 'jane@acme.com' }, + { email: 'Jane@Acme.com', name: 'Jane Doe' }, + { email: 'jane@acme.com', name: 'J. Doe' }, + ], + self + ) + ).toEqual([{ email: 'jane@acme.com', name: 'Jane Doe' }]); + }); + + it('treats blank display names as absent', () => { + expect( + externalParties([{ email: 'jane@acme.com', name: ' ' }], self) + ).toEqual([{ email: 'jane@acme.com', name: undefined }]); + }); + + it('skips malformed addresses', () => { + expect( + externalParties([{ email: 'not-an-address' }, { email: '' }], self) + ).toEqual([]); + }); + + it('returns nothing when the signed-in address is unknown', () => { + const parties = [{ email: 'jane@acme.com' }]; + expect(externalParties(parties, undefined)).toEqual([]); + expect(externalParties(parties, 'nodomain')).toEqual([]); + }); +}); + +describe('partyDomains', () => { + it('returns distinct domains in first-seen order', () => { + expect( + partyDomains([ + { email: 'jane@acme.com' }, + { email: 'bo@globex.com' }, + { email: 'ann@acme.com' }, + ]) + ).toEqual(['acme.com', 'globex.com']); + }); +}); diff --git a/apps/web/src/features/correspondence/parties.ts b/apps/web/src/features/correspondence/parties.ts new file mode 100644 index 00000000000..19f45d0efc6 --- /dev/null +++ b/apps/web/src/features/correspondence/parties.ts @@ -0,0 +1,76 @@ +/** + * Deriving the *external* people on an email thread or a calendar event. + * + * "External" is defined the way the Correspondence panel needs it: everybody + * who is neither the signed-in user nor on the user's own email domain. That + * domain comparison is the whole internal/external test — a teammate on a + * second company domain reads as external, which is the trade the simple rule + * buys. + */ + +/** A person on an email thread or a calendar event. */ +export interface CorrespondenceParty { + /** Lowercased email address — the identity key for a party. */ + email: string; + /** Display name observed for the address, when the source carried one. */ + name?: string; +} + +/** + * The lowercased domain of `email`, or `undefined` when the address has no + * usable domain part (empty input, a bare local part, a trailing `@`). + */ +export function addressDomain(email: string | undefined): string | undefined { + if (!email) return undefined; + const trimmed = email.trim(); + const at = trimmed.lastIndexOf('@'); + if (at <= 0 || at === trimmed.length - 1) return undefined; + return trimmed.slice(at + 1).toLowerCase(); +} + +/** + * Narrows raw participants down to the external ones, deduped on the + * lowercased address (first non-empty display name wins) and returned in + * first-seen order. + * + * Returns an empty list when the signed-in user's own address is unknown or + * domainless: without a domain to compare against there is no way to tell a + * teammate from a customer, and guessing would put colleagues in the panel. + */ +export function externalParties( + parties: Iterable, + selfEmail: string | undefined +): CorrespondenceParty[] { + const self = selfEmail?.trim().toLowerCase(); + const internalDomain = addressDomain(self); + if (!self || !internalDomain) return []; + + const byAddress = new Map(); + for (const party of parties) { + const email = party.email?.trim().toLowerCase(); + if (!email || email === self) continue; + + const domain = addressDomain(email); + if (!domain || domain === internalDomain) continue; + + const name = party.name?.trim() || undefined; + const existing = byAddress.get(email); + if (!existing) { + byAddress.set(email, { email, name }); + } else if (!existing.name && name) { + existing.name = name; + } + } + + return [...byAddress.values()]; +} + +/** The distinct domains across `parties`, in first-seen order. */ +export function partyDomains(parties: CorrespondenceParty[]): string[] { + const domains = new Set(); + for (const party of parties) { + const domain = addressDomain(party.email); + if (domain) domains.add(domain); + } + return [...domains]; +} diff --git a/apps/web/src/features/correspondence/use-correspondence-threads.ts b/apps/web/src/features/correspondence/use-correspondence-threads.ts new file mode 100644 index 00000000000..05f4f30f759 --- /dev/null +++ b/apps/web/src/features/correspondence/use-correspondence-threads.ts @@ -0,0 +1,42 @@ +import { NIL_UUID } from '@app/features/next-soup/filters/filter-store'; +import { emailFilterForAddresses } from '@companies/Company/emailFilter'; +import { useSoupAstItemsQuery } from '@queries/soup/items'; +import type { Accessor } from 'solid-js'; + +/** How many threads the Correspondence section loads. */ +export const CORRESPONDENCE_THREAD_LIMIT = 50; + +/** + * The most recent email threads shared with `addresses`, newest first. + * + * Uses the raw `ef` any-direction OR-tree rather than the CRM-scoped `eca` + * widener, so the query stays inside the caller's own mailbox scope and works + * whether or not the team has CRM enabled for these correspondents. Every + * non-email soup source is switched off with the NIL-UUID sentinel. + * + * A single page of {@link CORRESPONDENCE_THREAD_LIMIT} is all the section + * shows — it scrolls rather than paginating. + */ +export function useCorrespondenceThreadsQuery(addresses: Accessor) { + return useSoupAstItemsQuery( + () => ({ + params: { + limit: CORRESPONDENCE_THREAD_LIMIT, + sort_method: 'updated_at', + }, + body: { + df: { l: { id: NIL_UUID } }, + chanf: { l: { ChannelId: NIL_UUID } }, + cthf: { l: { ThreadId: NIL_UUID } }, + cf: { l: { cid: NIL_UUID } }, + pf: { l: { pid: NIL_UUID } }, + callf: { l: { CallId: NIL_UUID } }, + ccf: { l: { id: NIL_UUID } }, + fef: { l: { id: NIL_UUID } }, + emailView: 'all', + ef: emailFilterForAddresses(addresses()), + }, + }), + () => ({ enabled: addresses().length > 0 }) + ); +} diff --git a/apps/web/src/features/correspondence/use-crm-records.ts b/apps/web/src/features/correspondence/use-crm-records.ts new file mode 100644 index 00000000000..70789b9314b --- /dev/null +++ b/apps/web/src/features/correspondence/use-crm-records.ts @@ -0,0 +1,45 @@ +import { useCompanyQuery } from '@queries/crm/companies'; +import { useCrmCompanyIdForDomainQuery } from '@queries/crm/company-for-domain'; +import { type Accessor, createMemo } from 'solid-js'; +import { addressDomain } from './parties'; + +/** + * The caller's team CRM company for an email domain, with its contacts. + * + * Two hops — domain → company id (unified search), then id → company + + * contacts — both cached by TanStack Query, so the rows in a panel that all + * share one domain issue one pair of requests between them. + */ +export function useCrmCompanyForDomain(domain: Accessor) { + const companyIdQuery = useCrmCompanyIdForDomainQuery(domain); + const companyId = () => companyIdQuery.data ?? ''; + const { query, company, contacts } = useCompanyQuery(companyId); + + return { + company, + contacts, + /** True while either hop is still in flight. */ + isLoading: () => + companyIdQuery.isLoading || (!!companyId() && query.isLoading), + }; +} + +/** + * The CRM contact record for a single address, resolved through the company + * that owns its domain. `contact` stays `undefined` when the team tracks no + * company for the domain, or tracks the company but not this address. + */ +export function useCrmContactForAddress(email: Accessor) { + const domain = createMemo(() => addressDomain(email())); + const { company, contacts, isLoading } = useCrmCompanyForDomain(domain); + + const contact = createMemo(() => { + const target = email().trim().toLowerCase(); + if (!target) return undefined; + return contacts().find( + (candidate) => candidate.email.trim().toLowerCase() === target + ); + }); + + return { contact, company, isLoading }; +} diff --git a/apps/web/src/lib/queries/crm/company-for-domain.ts b/apps/web/src/lib/queries/crm/company-for-domain.ts new file mode 100644 index 00000000000..3011361d164 --- /dev/null +++ b/apps/web/src/lib/queries/crm/company-for-domain.ts @@ -0,0 +1,100 @@ +import { NIL_UUID } from '@app/features/next-soup/filters/filter-store'; +import { ENABLE_SEARCH_SERVICE } from '@core/constant/featureFlags'; +import { throwOnErr } from '@core/util/result'; +import { searchClient } from '@service-search/client'; +import type { EntityFilters } from '@service-search/generated/models/entityFilters'; +import type { UnifiedSearchResponseItem } from '@service-search/generated/models/unifiedSearchResponseItem'; +import { useQuery } from '@tanstack/solid-query'; +import type { Accessor } from 'solid-js'; +import { crmKeys } from './keys'; + +/** The `company` arm of the unified-search result union. */ +type CompanySearchHit = Extract; + +/** + * Unified search scoped to CRM companies only. Every other source is + * switched off with the NIL-UUID sentinel in its primary id field — the + * same mechanism the soup views use (see `SearchEntityFilters::from` + * server-side, which reads `call_filters.channel_ids` rather than + * `call_ids` for call records). + */ +const CRM_ONLY_FILTERS: EntityFilters = { + document_filters: { document_ids: [NIL_UUID] }, + chat_filters: { chat_ids: [NIL_UUID] }, + email_filters: { email_thread_ids: [NIL_UUID] }, + channel_filters: { channel_ids: [NIL_UUID] }, + project_filters: { project_ids: [NIL_UUID] }, + call_filters: { channel_ids: [NIL_UUID] }, + // Visible companies only; the hidden set is admin-gated server-side. + crm_company_filters: { hidden: false }, +}; + +/** The search service rejects queries shorter than this. */ +const MIN_SEARCH_LENGTH = 3; + +/** + * Company domains change rarely, and the panel re-resolves the same handful + * of domains on every thread/event switch — a generous stale time keeps that + * to one request per domain per session. + */ +const DOMAIN_LOOKUP_STALE_TIME = 5 * 60 * 1000; + +/** + * Enough headroom that an exact-domain company isn't pushed off the page by + * substring matches (`acme.com` also matches `notacme.com`, `acme.com.br`, …) + * without paying for a large response. + */ +const DOMAIN_LOOKUP_PAGE_SIZE = 25; + +/** + * Resolves an email domain to the id of the caller's team CRM company that + * owns it, or `null` when the team tracks no company for that domain. + * + * There is no domain lookup endpoint on the CRM service, so this rides the + * unified-search CRM source, whose match runs over `crm_domains.domain` as + * well as the company name. That match is a case-insensitive *substring*, so + * the hit is only accepted when one of the company's domains equals the + * requested one exactly — a search for `acme.com` must never resolve to + * `notacme.com`. + * + * Pair with {@link useCompanyQuery} to hydrate the company and its contacts. + */ +export function useCrmCompanyIdForDomainQuery( + domain: Accessor +) { + return useQuery(() => { + const target = domain()?.trim().toLowerCase(); + return { + queryKey: crmKeys.companyForDomain(target ?? '').queryKey, + queryFn: async () => { + if (!target) return null; + const response = await throwOnErr(() => + searchClient.search({ + params: { page_size: DOMAIN_LOOKUP_PAGE_SIZE }, + request: { + query: target, + match_type: 'partial', + // CRM has no content index; it only participates under + // name / name_content searches. + search_on: 'name', + include_crm: true, + filters: CRM_ONLY_FILTERS, + }, + }) + ); + + const match = response.results.find( + (result): result is CompanySearchHit => + result.type === 'company' && + result.domains.some( + (entry) => entry.domain.trim().toLowerCase() === target + ) + ); + return match?.id ?? null; + }, + staleTime: DOMAIN_LOOKUP_STALE_TIME, + enabled: + ENABLE_SEARCH_SERVICE && !!target && target.length >= MIN_SEARCH_LENGTH, + }; + }); +} diff --git a/apps/web/src/lib/queries/crm/keys.ts b/apps/web/src/lib/queries/crm/keys.ts index 3b84246fa88..7c5c760dd21 100644 --- a/apps/web/src/lib/queries/crm/keys.ts +++ b/apps/web/src/lib/queries/crm/keys.ts @@ -3,6 +3,7 @@ import type { CrmCommentEntityType } from '@service-storage/generated/schemas/cr export const crmKeys = createQueryKeys('crm', { company: (companyId: string) => [companyId], + companyForDomain: (domain: string) => [domain], contact: (contactId: string) => [contactId], comments: (entityType: CrmCommentEntityType, entityId: string) => [ entityType,