import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { assertNoUnknownFlags, getFlag, hasFlag } from "./lib/cli-args"; export const EMAIL_INBOX_HELP = ` dench email inbox search [--mailbox ] [--from ] [--to ] [--subject ] [--after ] [--before ] [--direction inbound|outbound] [--limit 1..50] [--cursor ] [--json] dench email inbox thread [--limit 1..50] [--cursor ] [--json] dench email inbox message [--format text|html] [--max-bytes 1..100000] [--json] Search reads synced headers only; it does not contact the provider or search message bodies. --from/--to match exact addresses (to includes cc/bcc); --subject is a case-insensitive substring. --after is inclusive, --before exclusive; date-only values mean midnight UTC. Older undated rows use createdAt. isDone means the persisted scan ended, not that the provider's entire mailbox has been synced. coverage.privacyLimited reports older rows omitted when complete record privacy cannot be established. Keep the returned cursor through empty filtered pages. Thread/message handles come from these results. `; export type EmailInboxCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; }; function valueFlag(args: string[], name: string) { const present = args.includes(name); const value = getFlag(args, name); if (present && (!value?.trim() || value.startsWith("--"))) throw new Error(`${name} requires a value`); return value; } function integerFlag(args: string[], name: string, maximum: number) { const raw = valueFlag(args, name); if (raw === undefined) return undefined; const value = Number(raw); if ( !/^\d+$/.test(raw) || !Number.isInteger(value) || value < 1 || value > maximum ) throw new Error(`${name} must be an integer from 1 to ${maximum}`); return value; } function dateFlag(args: string[], name: string) { const raw = valueFlag(args, name); if (raw === undefined) return undefined; if (/^-?\d+$/.test(raw)) { const value = Number(raw); if (Number.isSafeInteger(value)) return value; } const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(raw); if (dateOnly || /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(raw)) { const value = Date.parse(dateOnly ? `${raw}T00:00:00.000Z` : raw); const day = raw.slice(0, 10); const calendar = Date.parse(`${day}T00:00:00.000Z`); if ( Number.isFinite(value) && Number.isFinite(calendar) && new Date(calendar).toISOString().slice(0, 10) === day ) return value; } throw new Error( `${name} must be YYYY-MM-DD, an ISO timestamp with timezone, or Unix milliseconds`, ); } export async function runEmailInboxCommand( ctx: EmailInboxCliContext, ): Promise { const args = [...ctx.args]; if ( !args.length || args.includes("--help") || args.includes("-h") || args[0] === "help" ) { console.log(EMAIL_INBOX_HELP.trim()); return; } const command = args.shift(); const jsonOutput = hasFlag(args, "--json") || ctx.jsonOutput; const input: Record = {}; const add = (key: string, value: unknown) => { if (value !== undefined) input[key] = value; }; if (command === "search") { for (const flag of ["mailbox", "from", "to", "subject"]) add(flag, valueFlag(args, `--${flag}`)); add("after", dateFlag(args, "--after")); add("before", dateFlag(args, "--before")); const direction = valueFlag(args, "--direction"); if ( direction !== undefined && direction !== "inbound" && direction !== "outbound" ) throw new Error("--direction must be inbound or outbound"); add("direction", direction); if ( typeof input.after === "number" && typeof input.before === "number" && input.after >= input.before ) throw new Error("--after must be earlier than --before"); } else if (command === "thread" || command === "message") { const handle = args.shift(); if ( !handle?.startsWith(`email-${command}:`) || handle.length <= `email-${command}:`.length ) throw new Error( `Use an exact ${command} handle returned by email inbox search`, ); input[`${command}Handle`] = handle; } else throw new Error("Unknown inbox command. Run `dench email inbox --help`."); if (command === "message") { const format = valueFlag(args, "--format"); if (format !== undefined && format !== "text" && format !== "html") throw new Error("--format must be text or html"); add("format", format); add("maxBytes", integerFlag(args, "--max-bytes", 100_000)); } else { add("limit", integerFlag(args, "--limit", 50)); add("cursor", valueFlag(args, "--cursor")); } assertNoUnknownFlags(args, "email inbox"); if (args.length) throw new Error( `Unexpected inbox argument: ${args[0]}. Run \`dench email inbox --help\`.`, ); const result = await ctx.convex.query( makeFunctionReference<"query">(`functions/emailInbox:${command}`), { ...input, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), }, ); if (jsonOutput) { console.log(JSON.stringify(result, null, 2)); return; } if (command === "message") { console.log( `${result.header.subject}\nFrom: ${result.header.from.email}\n\n${result.body.content}`, ); if (result.body.truncated) console.log( `\nBody truncated to the requested byte limit (${result.body.totalBytes} bytes stored).`, ); } else { for (const row of result.page) console.log( `${new Date(row.at).toISOString()} ${row.from.email} ${row.subject}\n ${row.messageHandle} ${row.threadHandle}`, ); if (!result.page.length) console.log("No matching headers on this page."); if (!result.isDone) console.log( `Continue with --cursor ${JSON.stringify(result.continueCursor)}`, ); } if (result.coverage.privacyLimited) console.log( "Some older messages were omitted because their full record privacy scope is unavailable.", ); console.log( `Synced mailbox: ${result.coverage.mailboxEmail} (${result.coverage.phase}); provider completeness is not established.`, ); }