shared/types.ts
export type Provider = "gmail" | "gmail-imap" | "icloud" | "fastmail" | "yahoo" | "imap";
export const providerNames: Record<Provider, string> = {
gmail: "Gmail",
"gmail-imap": "Gmail",
icloud: "iCloud Mail",
fastmail: "Fastmail",
yahoo: "Yahoo Mail",
imap: "Custom IMAP",
};
export const appPasswordHelp: Partial<Record<Provider, string>> = {
"gmail-imap": "https://support.google.com/accounts/answer/185833",
icloud: "https://support.apple.com/102654",
fastmail: "https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords",
yahoo: "https://help.yahoo.com/kb/SLN15241.html",
};
import type { ComposePreferences } from "./mail-features";
export type SyncFailure = {
kind: "auth" | "access" | "transient" | "message" | "unknown";
phase: "auth" | "connect" | "list" | "read" | "parse" | "sync" | "cleanup";
code: string;
status?: number;
};
export type Account = {
id: string;
email: string;
name?: string;
provider: Provider;
error?: string;
syncFailure?: SyncFailure;
syncWarning?: SyncFailure;
syncStatus?: "queued" | "running";
};
export type Category = "important" | "other";
export type MailAttachment = { id: string; filename: string; contentType: string; size: number; unavailable?: boolean };
export type DraftAttachment = { filename: string; contentType: string; content: string };
export type RuleCondition = { field: "subject" | "body" | "recipient"; contains: string } | { field: "hasAttachment" | "directQuestion" };
export type DraftContent = {
cc?: string[];
attachments?: DraftAttachment[];
to: string;
subject: string;
body: string;
inReplyTo?: string;
threadId?: string;
references?: string[];
sendUnknown?: boolean;
};
export type Draft = DraftContent & {
id: string;
accountId: string;
revision: number;
updatedAt: number;
sendUnknown: boolean;
};
export type DraftSummary = Pick<Draft, "id" | "accountId" | "revision" | "updatedAt" | "to" | "cc" | "subject" | "sendUnknown"> & { preview?: string; attachmentCount?: number };
export type DraftSave = DraftContent & { revision: number };
export type DraftDelete = { revision: number; confirmUnknown?: boolean };
// Every System One style decision provider speaks the same request and answer
// shapes; only the URL, the model id and the key differ.
export type JevProvider = "typesafe" | "vercel" | "openrouter";
export const jevProviders: Record<
JevProvider,
{ name: string; endpoint: string; model: string; console: string }
> = {
typesafe: {
name: "TypeSafe",
endpoint: "https://api.typesafe.ai/v1/systemone",
model: "jev-latest",
console: "Key from console.typesafe.ai",
},
vercel: {
name: "Vercel AI Gateway",
endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone",
model: "typesafe-ai/jev",
console: "Key from the Vercel AI Gateway",
},
openrouter: {
name: "OpenRouter",
endpoint: "https://openrouter.ai/api/alpha/decisions",
model: "typesafe/jev-1.13",
console: "Key from openrouter.ai/keys",
},
};
export const jevProviderOf = (value?: string): JevProvider =>
value === "vercel" || value === "openrouter" ? value : "typesafe";
// Consumer mail hosts, where a favicon would show the provider's logo rather
// than the sender's, so those senders keep a letter avatar instead.
export const personalMailDomains = new Set([
"gmail.com",
"googlemail.com",
"outlook.com",
"hotmail.com",
"live.com",
"msn.com",
"yahoo.com",
"ymail.com",
"icloud.com",
"me.com",
"mac.com",
"aol.com",
"proton.me",
"protonmail.com",
"gmx.com",
"gmx.de",
"mail.com",
"zoho.com",
"fastmail.com",
"hey.com",
"yandex.com",
"qq.com",
"163.com",
"126.com",
]);
// Takes a bare host or a full address, since both the sender rows and the
// logo route need the same answer.
export function logoDomain(value: string) {
const domain = (value.split("@").pop() || "").trim().toLowerCase();
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/.test(domain))
return null;
return personalMailDomains.has(domain) ? null : domain;
}
export type MailImage = { src: string; alt: string };
export type Mail = {
direction?: "received" | "sent";
inReplyTo?: string;
attachments?: MailAttachment[];
mailbox?: { path: string; uidValidity: string; uid: number };
id: string;
accountId: string;
remoteId: string;
sender: string;
address: string;
to: string;
subject: string;
preview: string;
body: string;
images?: MailImage[];
// Undefined means an older cache; null means no supported HTML source.
html?: string | null;
date: number;
unread: boolean;
pinned?: boolean;
labelIds?: string[];
category: Category;
relevance: 1 | 2 | 3;
reason: string;
archived: boolean;
snoozedUntil: number | null;
messageId?: string;
threadId?: string;
cc?: string;
replyTo?: string;
references?: string[];
};
export type Rule = {
createdAt?: number;
excludedMailIds?: string[];
accountId?: string;
condition?: RuleCondition;
id: string;
sender: string;
instruction: string;
category: Category;
relevance: 1 | 2 | 3;
};
export type MailChanges = {
mailCursor: string;
mailMode: "snapshot" | "delta";
messages: Mail[];
removedMailIds: string[];
};
export type StateResponse = Omit<State, "messages"> & MailChanges & { csrf: string };
export type State = {
draftCount?: number;
composePreferences?: ComposePreferences;
unreadArrivals?: number;
mailArrivals?: import("./notifications").MailArrival[];
accounts: Account[];
messages: Mail[];
labels: GmailLabel[];
rules: Rule[];
googleConfigured: boolean;
googleClientId: string | null;
jevConfigured: boolean;
jevProvider: JevProvider;
aiConfigured: boolean;
lastSync: number | null;
syncing: boolean;
};
export type GmailLabel = {
id: string;
accountId: string;
name: string;
type: "system" | "user";
};
// What a run of POST /api/logos/sync found across the senders in the store.
export type LogoSummary = {
total: number;
found: number;
missing: number;
failed: number;
};
export type McpSettings = {
enabled: boolean;
allowSending: boolean;
token: string | null;
};
// Only explicit HTTPS URLs on public-looking DNS names can leave the reader.
export function imageSource(value: string): string | null {
if (value.length > 2048 || /[\\\s\u0000-\u001f\u007f]/.test(value)) return null;
try {
const url = new URL(value);
if (
url.protocol !== "https:" || url.username || url.password || url.port ||
!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(url.hostname) ||
/\.(?:localhost|local|internal|home|lan|onion)$/i.test(url.hostname)
) return null;
return url.href;
} catch {
return null;
}
}