46aa7a767c
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>
118 lines
6.1 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
/* ============================ 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: () => {} };
|
|
}
|