Files
lynkeduppro-crm/src/lib/mail-api.ts
T
maaz519 46aa7a767c feat(mail): dedicated Mail reader in the CRM
A Mail surface in the Communication group, distinct from Messenger (chat) and the
work-item Inbox: read app-to-app + email messages (subject + rendered body), reply,
and compose (in-app to a person, or external to an email).

- mail-api.ts: crm.mail.list/history/reply + compose (crm.mail.internal/send), reusing
  the messenger directory for the people picker. Live via the AppShell SDK; mock in demo mode.
- mail.tsx: thread list + reader + reply + New-mail composer. HTML bodies render inside a
  SANDBOXED iframe (no scripts) — safe against untrusted email HTML.
- Wired into the sidebar (Communication) + dashboard switch.

The existing work-item Inbox stays as the notifier (IIOS's projector already flags new
mail there); this is where you read it. tsc + next build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:38:16 +05:30

118 lines
6.1 KiB
TypeScript

"use client";
// Mail data layer. A dedicated Mail reader over the be-crm data door (crm.mail.*), distinct from
// the Messenger chat and from the work-item Inbox. Live via the AppShell SDK; a small mock keeps the
// demo working before the Shell + be-crm are connected.
//
// Live contract (be-crm):
// query crm.mail.list {} -> MailThread[]
// query crm.mail.history { threadId } -> MailMessage[]
// cmd crm.mail.reply { threadId, content } -> { interactionId, threadId }
// cmd crm.mail.internal { recipientUserId, subject?, text?, html? } -> { threadId }
// cmd crm.mail.send { target, subject?, text?, html?, mirrorToUserId? } -> { commandId }
// query crm.messenger.directory { kind, limit } -> people to compose to (reused)
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface MailThread {
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
export interface MailMessage {
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null;
}
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
const SHELL = isShellConfigured();
/* ============================ Thread list ============================ */
export interface MailListData {
live: boolean; loading: boolean; error: string | null; threads: MailThread[]; refetch: () => void;
}
export function useMailThreads(): MailListData {
if (SHELL) {
const q = useQuery<MailThread[]>("crm.mail.list", {});
return { live: true, loading: q.loading, error: q.error?.message ?? null, threads: q.data ?? [], refetch: q.refetch };
}
return { live: false, loading: false, error: null, threads: MOCK_THREADS, refetch: () => {} };
}
/* ============================ One thread ============================ */
export interface MailThreadData {
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string) => Promise<void>; refetch: () => void;
}
export function useMailThread(threadId: string | null): MailThreadData {
if (SHELL) return useLiveThread(threadId);
return useMockThread(threadId);
}
function useLiveThread(threadId: string | null): MailThreadData {
const { sdk } = useAppShell();
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
const reply = useCallback(async (content: string) => {
if (!threadId) return;
await sdk.command("crm.mail.reply", { threadId, content });
q.refetch();
}, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
}
/* ============================ Compose ============================ */
export interface ComposeData {
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, mirrorToUserId?: string) => Promise<void>;
}
export function useMailCompose(onSent: () => void): ComposeData {
if (SHELL) {
const { sdk } = useAppShell();
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string) => {
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>` });
onSent();
}, [sdk, onSent]);
const sendExternal = useCallback(async (target: string, subject: string, text: string, mirrorToUserId?: string) => {
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(mirrorToUserId ? { mirrorToUserId } : {}) });
onSent();
}, [sdk, onSent]);
return { directory, sendInternal, sendExternal };
}
return { directory: MOCK_PEOPLE, sendInternal: async () => onSent(), sendExternal: async () => onSent() };
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/* ============================ Mock (demo mode) ============================ */
const now = () => new Date().toISOString();
const MOCK_PEOPLE: MailPerson[] = [
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
];
const MOCK_THREADS: MailThread[] = [
{ threadId: "mt_1", subject: "Welcome to the Founders Club", participants: ["you", "system"], unread: 1, lastMessage: "Thanks for joining…", lastAt: now() },
{ threadId: "mt_2", subject: "Storm response — East side", participants: ["you", "pp_sofia"], unread: 0, lastMessage: "Crew rolling out at 7", lastAt: now() },
];
function useMockThread(threadId: string | null): MailThreadData {
const [extra, setExtra] = useState<MailMessage[]>([]);
const base: MailMessage[] = threadId === "mt_1"
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club." }]
: threadId === "mt_2"
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7." }]
: [];
const reply = useCallback(async (content: string) => {
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content }]);
}, []);
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
}