SLOPSHOPPER

mail-pane

Your latest mail beside the chat, through the Microsoft 365 connector: read each mail and its attachments in the pane, save them to a folder and pass them to…

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mail-pane
│ ┃ Mail ✕ › fix the failing auth test and add an audit log call │ ┃ Mail │ ┃ Inbox · 0 unread ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Search: Words in the subject, the body or an ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ All ] [ 0 unread ] refresh │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Loading… │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ The Microsoft 365 connector did not answer. │ ┃ Check that it is connected in this session. › /mail │ ┃ ⎿ mail-pane: Mail pane opened. │ ┃ Theme theme:blue theme:cyan theme:green them │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mail
Mail Inbox · 0 unread Search: Words in the subject, the body or an attachment ⏎ Se [ All ] [ 0 unread ] refresh Loading… The Microsoft 365 connector did not answer. Check that it is connected in this session. Theme theme:blue theme:cyan theme:green theme:yellow theme:o
README

mail-pane

Your latest mail in a pane beside the chat, through the Microsoft 365 connector of Claude: the mail of one sender domain, or the newest of the inbox, read right there, passed to the chat in one press, and a toast when new mail comes in.

Install

At the prompt of a terminal session:

/plugin install mail-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods

From a shell:

claude plugin install mail-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with DiegoFernandoLojanTenesaca/indaga-mods, then Add plugin.

It needs the Microsoft 365 connector connected in the session (claude.ai, Settings, Connectors). The mod calls it with the session's own connection and asks for nothing else.

Options

OptionWhat it is
domainOnly mail sent from this domain or address (example.com). Empty shows the newest mail of the inbox.
watchAddresses or names, separated by commas, whose new mail raises a toast. Empty: every new mail of the domain does.
refreshMinutesHow often the pane looks for new mail. 5 by default.
connectorThe connector's name as /mcp lists it. claude.ai Microsoft 365 by default.
saveFolderWhere each mail is saved with its attachments, one folder per mail. Empty: a Mail folder in Downloads.
openOnStartOpen the pane when a session starts. On by default.

The pane

A banner in the theme color says the domain and how many mails are unread. Under it come the search (words of the subject, the body or an attachment), All and Unread, Refresh, and the list: a dot for the unread, who sent it, the subject, how long ago and whether it has an attachment, with Show more at the end.

A press opens a mail: its subject, From, To and Cc, its attachments, and its text, the newest part first, with Show the whole thread for the quoted rest. View shows an attachment in the pane: a text or Markdown file as it is, an image as a picture; other files (a PDF, a spreadsheet) are opened from the saved folder.

Save writes the mail (mail.md, the whole thread) and every attachment into a folder of its own, <date> <subject>. Pass to the chat saves it the same way and puts the mail in the prompt box with that folder and its files, so the session can read the attachments and work on them; where the prompt cannot be filled it copies the text instead. Open in Outlook opens it on the web.

An attachment comes from the connector as text or as bytes; bytes are written with the system's own decoder, certutil on Windows and base64 elsewhere.

The pane looks for new mail every few minutes. A toast says who wrote and the subject of each new one from the watched senders; the first look after the mod starts only remembers where the mail stands. The color dots at the bottom pick the theme, kept for the next session.

Reading a mail here does not mark it read in Outlook.

Source 8 files
hooks/register.tsx 559 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpContentBlock, Register } from 'claude-code'
3
4import type {
5  MailAttachment,
6  MailBusy,
7  MailFilter,
8  MailMessage,
9  MailPreview,
10  MailProblem,
11  MailSaved,
12  MailSummary,
13} from '../types'
14import { bannerSvgOf, imageSvgOf } from './art'
15import { ICONS } from './icons'
16import { LAYOUT } from './layout'
17import { localeOf, translate } from './locales'
18import type { Locale, MessageKey } from './locales'
19import {
20  contentOf,
21  dayOf,
22  isImage,
23  isWatched,
24  joinPath,
25  jsonObjectsOf,
26  messageOf,
27  pageOf,
28  relativeTimeOf,
29  safeNameOf,
30  savedPathOf,
31  sizeOf,
32  textFileNameOf,
33  watchListOf,
34} from './mail'
35import { DEFAULT_THEME, PALETTE, THEMES, isTheme } from './palette'
36import type { ThemeName } from './palette'
37
38const PANE = 'mail-pane'
39const COMMAND = 'mail'
40const OPEN_TOOL = 'open'
41const OPEN_CALLED = 'mcp__mail-pane__open'
42const OPEN_DESCRIPTION =
43  'Opens the mail pane beside the chat, with the latest mail from the configured sender domain. Call it when the person asks to see their mail.'
44const DEFAULT_CONNECTOR = 'claude.ai Microsoft 365'
45const SEARCH_TOOL = 'outlook_email_search'
46const READ_TOOL = 'read_resource'
47const MINUTE_MS = 60_000
48const STORE = { theme: 'theme', seenUntil: 'seenUntil' }
49const DOWNLOADS = 'Downloads'
50const WINDOWS = 'Windows_NT'
51const DECODE_SCRIPT = 'base64 --decode < "$0" > "$1"'
52const MARKDOWN_NAME = /\.(md|markdown)$/i
53
54const mails = atom({ plugin: 'mail-pane', key: 'mails' } as const, [] as MailSummary[])
55const nextOffset = atom({ plugin: 'mail-pane', key: 'nextOffset' } as const, null as number | null)
56const query = atom({ plugin: 'mail-pane', key: 'query' } as const, '')
57const filter = atom({ plugin: 'mail-pane', key: 'filter' } as const, 'all' as MailFilter)
58const message = atom({ plugin: 'mail-pane', key: 'message' } as const, null as MailMessage | null)
59const isThreadShown = atom({ plugin: 'mail-pane', key: 'isThreadShown' } as const, false)
60const preview = atom({ plugin: 'mail-pane', key: 'preview' } as const, null as MailPreview | null)
61const saved = atom({ plugin: 'mail-pane', key: 'saved' } as const, null as MailSaved | null)
62const busy = atom({ plugin: 'mail-pane', key: 'busy' } as const, null as MailBusy | null)
63const problem = atom({ plugin: 'mail-pane', key: 'problem' } as const, null as MailProblem | null)
64const theme = atom({ plugin: 'mail-pane', key: 'theme' } as const, DEFAULT_THEME as string)
65const now = atom({ plugin: 'mail-pane', key: 'now' } as const, 0)
66
67type Config = { domain: string; watch: string[]; refreshMs: number; connector: string; saveFolder: string }
68
69type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
70
71let locale: Promise<Locale> | undefined
72
73const textOption = (value: unknown, fallback = '') => (typeof value === 'string' && value.trim() ? value.trim() : fallback)
74
75export const configOf = (options: Record<string, unknown>): Config => ({
76  domain: textOption(options.domain),
77  watch: watchListOf(textOption(options.watch)),
78  refreshMs:
79    (typeof options.refreshMinutes === 'number' && options.refreshMinutes > 0 ? options.refreshMinutes : LAYOUT.refreshMinutes) *
80    MINUTE_MS,
81  connector: textOption(options.connector, DEFAULT_CONNECTOR),
82  saveFolder: textOption(options.saveFolder),
83})
84
85export const searchArgsOf = (config: Config, words: string, offset: number) => ({
86  ...(config.domain ? { sender: config.domain } : {}),
87  ...(words ? { query: words } : { order: 'newest' }),
88  limit: LAYOUT.pageSize,
89  offset,
90})
91
92function localeFor($: EngineInterface) {
93  locale ??= $.settings.read({ key: 'language' }).then(
94    language => localeOf(typeof language === 'string' ? language : undefined),
95    () => localeOf(undefined),
96  )
97  return locale
98}
99
100async function openPane($: EngineInterface) {
101  const current = await localeFor($)
102  await $.ui.open({ id: PANE, title: translate(current, 'title') })
103  return translate(current, 'commandOpened')
104}
105
106async function setProblem($: EngineInterface, next: MailProblem | null) {
107  await update($, problem, () => next)
108}
109
110async function setBusy($: EngineInterface, next: MailBusy | null) {
111  await update($, busy, () => next)
112}
113
114async function wholeBlockOf($: EngineInterface, block: McpContentBlock): Promise<McpContentBlock> {
115  const path = block.type === 'text' && block.text ? savedPathOf(block.text) : undefined
116  return path ? { type: 'text', text: await $.fs.read(path) } : block
117}
118
119async function callBlocks($: EngineInterface, config: Config, tool: string, args: Record<string, unknown>) {
120  const answer = await $.mcp.call(config.connector, tool, args)
121  if (answer.isError) throw new Error(tool)
122  return Promise.all(answer.content.map(block => wholeBlockOf($, block)))
123}
124
125async function call($: EngineInterface, config: Config, tool: string, args: Record<string, unknown>) {
126  const blocks = await callBlocks($, config, tool, args)
127  return blocks.flatMap(block => (block.type === 'text' && block.text ? [block.text] : [])).join('')
128}
129
130async function announce($: EngineInterface, config: Config, list: MailSummary[]) {
131  const seenUntil = Number(await $.store.get(STORE.seenUntil)) || 0
132  const newest = Math.max(seenUntil, ...list.map(mail => mail.receivedMs))
133  if (seenUntil > 0) {
134    const current = await localeFor($)
135    const fresh = list.filter(mail => mail.receivedMs > seenUntil && isWatched(mail, config.watch)).slice(0, LAYOUT.toastsAtOnce)
136    for (const mail of fresh) await $.ui.toast(translate(current, 'newMail', { who: mail.fromName, subject: mail.subject }))
137  }
138  if (newest !== seenUntil) await $.store.set(STORE.seenUntil, newest)
139}
140
141async function loadMails($: EngineInterface, config: Config, isMore = false) {
142  const words = await read($, query)
143  const offset = isMore ? ((await read($, nextOffset)) ?? 0) : 0
144  await setBusy($, isMore ? 'more' : 'list')
145  const text = await call($, config, SEARCH_TOOL, searchArgsOf(config, words, offset)).catch(() => undefined)
146  await setBusy($, null)
147  const checkedMs = await $.clock.now()
148  await update($, now, () => checkedMs)
149  if (text === undefined) return setProblem($, { key: 'connectorFailed' })
150  const page = pageOf(text)
151  await update($, mails, current =>
152    isMore ? [...current, ...page.mails.filter(mail => !current.some(known => known.uri === mail.uri))] : page.mails,
153  )
154  await update($, nextOffset, () => page.nextOffset)
155  await setProblem($, null)
156  if (!isMore && !words) await announce($, config, page.mails)
157}
158
159async function search($: EngineInterface, config: Config, words: string) {
160  await update($, query, () => words.trim())
161  await update($, filter, (): MailFilter => 'all')
162  await loadMails($, config)
163}
164
165async function openMail($: EngineInterface, config: Config, mail: MailSummary) {
166  await update($, isThreadShown, () => false)
167  await update($, preview, () => null)
168  await setProblem($, null)
169  await update($, message, () => ({ ...mail, to: [], cc: [], body: '', thread: '', attachments: [] }))
170  await setBusy($, 'mail')
171  const text = await call($, config, READ_TOOL, { uri: mail.uri }).catch(() => undefined)
172  await setBusy($, null)
173  const raw = text === undefined ? undefined : jsonObjectsOf(text)[0]
174  if (!raw) {
175    await update($, message, current => (current?.uri === mail.uri ? { ...current, body: mail.preview, thread: mail.preview } : current))
176    return setProblem($, { key: 'mailFailed' })
177  }
178  await update($, message, current => (current?.uri === mail.uri ? messageOf(raw, mail.uri) : current))
179}
180
181async function closeMail($: EngineInterface) {
182  await update($, message, () => null)
183  await update($, preview, () => null)
184  await update($, isThreadShown, () => false)
185}
186
187async function toggleThread($: EngineInterface) {
188  await update($, isThreadShown, value => !value)
189}
190
191async function chooseFilter($: EngineInterface, next: MailFilter) {
192  await update($, filter, () => next)
193}
194
195async function readAttachment($: EngineInterface, config: Config, attachment: MailAttachment) {
196  return contentOf(await callBlocks($, config, READ_TOOL, { uri: attachment.uri }))
197}
198
199async function viewAttachment($: EngineInterface, config: Config, attachment: MailAttachment) {
200  await setProblem($, null)
201  await setBusy($, 'attachment')
202  const content = await readAttachment($, config, attachment).catch(() => undefined)
203  await setBusy($, null)
204  if (!content) return setProblem($, { key: 'attachmentFailed', values: { name: attachment.name } })
205  const mimeType = 'data' in content ? content.mimeType || attachment.contentType : ''
206  await update($, preview, (): MailPreview => ({
207    name: attachment.name,
208    text: 'text' in content ? content.text : undefined,
209    image: 'data' in content && isImage(mimeType) ? { data: content.data, mimeType } : undefined,
210  }))
211}
212
213async function closePreview($: EngineInterface) {
214  await update($, preview, () => null)
215}
216
217async function tempFolderOf($: EngineInterface) {
218  return (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
219}
220
221async function saveRootOf($: EngineInterface, config: Config, current: Locale) {
222  if (config.saveFolder) return config.saveFolder
223  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? (await tempFolderOf($))
224  return joinPath(joinPath(home, DOWNLOADS), translate(current, 'saveFolderName'))
225}
226
227async function writeBytes($: EngineInterface, base64: string, target: string) {
228  const encoded = joinPath(await tempFolderOf($), `mail-pane-${safeNameOf(target).slice(-LAYOUT.tempNameLength)}.b64`)
229  await $.fs.write(encoded, base64)
230  const isWindows = (await $.env.get('OS')) === WINDOWS
231  const argv = isWindows ? ['certutil', '-f', '-decode', encoded, target] : ['sh', '-c', DECODE_SCRIPT, encoded, target]
232  const done = await $.process.run(argv, { timeoutMs: LAYOUT.decodeTimeoutMs })
233  if (done.exitCode !== 0) throw new Error(target)
234}
235
236async function saveMail($: EngineInterface, config: Config, mail: MailMessage): Promise<MailSaved> {
237  const known = await read($, saved)
238  if (known?.uri === mail.uri) return known
239  const current = await localeFor($)
240  const t = (key: MessageKey, values?: Record<string, string>) => translate(current, key, values)
241  const subject = safeNameOf(mail.subject) || t('noSubject')
242  const folder = joinPath(await saveRootOf($, config, current), `${dayOf(mail.receivedMs)} ${subject}`)
243  await setBusy($, 'save')
244  const mailFile = t('mailFileName')
245  await $.fs.write(
246    joinPath(folder, mailFile),
247    t('mailFile', {
248      subject: mail.subject || t('noSubject'),
249      who: `${mail.fromName} <${mail.from}>`,
250      to: mail.to.join(', '),
251      cc: mail.cc.join(', '),
252      date: new Date(mail.receivedMs).toISOString(),
253      body: mail.thread,
254    }),
255  )
256  const files = [mailFile]
257  const failed: string[] = []
258  for (const attachment of mail.attachments) {
259    const name = safeNameOf(attachment.name) || attachment.uri.slice(-LAYOUT.tempNameLength)
260    const written = await readAttachment($, config, attachment)
261      .then(async content => {
262        if (!content) throw new Error(name)
263        if ('data' in content) {
264          await writeBytes($, content.data, joinPath(folder, name))
265          return name
266        }
267        const textName = textFileNameOf(name, attachment.contentType)
268        await $.fs.write(joinPath(folder, textName), content.text)
269        return textName
270      })
271      .catch(() => undefined)
272    if (written) files.push(written)
273    else failed.push(attachment.name)
274  }
275  await setBusy($, null)
276  const result = { uri: mail.uri, folder, files }
277  await update($, saved, () => result)
278  await setProblem($, failed.length > 0 ? { key: 'saveFailed', values: { names: failed.join(', ') } } : null)
279  return result
280}
281
282async function passToChat($: EngineInterface, config: Config, mail: MailMessage, surface: Surface) {
283  const current = await localeFor($)
284  const kept = await saveMail($, config, mail).catch(() => undefined)
285  const text = translate(current, 'chatText', {
286    who: `${mail.fromName} <${mail.from}>`,
287    when: relativeTimeOf(current, mail.receivedMs, await $.clock.now()),
288    subject: mail.subject,
289    body: mail.body,
290    folder: kept?.folder ?? '',
291    files: (kept?.files ?? []).map(name => `- ${name}`).join('\n'),
292  })
293  const filled = await $.prompt.fill({ text, mode: 'insert' }).catch(() => undefined)
294  if (filled?.isFilled) return
295  const copied = await $.ui.copy({ text, surface }).catch(() => undefined)
296  if (copied?.isCopied) {
297    await $.ui.toast(translate(current, 'copied'))
298    return
299  }
300  await setProblem($, { key: 'chatFailed' })
301}
302
303async function chooseTheme($: EngineInterface, name: string) {
304  if (!isTheme(name)) return
305  await update($, theme, () => name)
306  await $.store.set(STORE.theme, name)
307}
308
309async function loadTheme($: EngineInterface) {
310  const kept = await $.store.get(STORE.theme)
311  if (isTheme(kept)) await update($, theme, () => kept)
312}
313
314export const register: Register = (on, options) => {
315  const config = configOf(options)
316  const openOnStart = options.openOnStart !== false
317  let refresher: { cancel: () => void } | undefined
318  locale = undefined
319
320  on('session.start', async ($, e, next) => {
321    const started = await next(e)
322    await setBusy($, null)
323    const current = await localeFor($)
324    await $.command.register({ name: COMMAND, description: translate(current, 'commandDescription') }).catch(() => {})
325    await $.tool.register({ name: OPEN_TOOL, description: OPEN_DESCRIPTION }).catch(() => {})
326    await loadTheme($).catch(() => {})
327    void loadMails($, config).catch(() => {})
328    refresher?.cancel()
329    refresher = $.clock.every(config.refreshMs, () => void loadMails($, config).catch(() => {}))
330    if (openOnStart) void openPane($).catch(() => {})
331    return started
332  })
333
334  on('session.end', async ($, e, next) => {
335    refresher?.cancel()
336    refresher = undefined
337    return next(e)
338  })
339
340  on('command.run', { command: COMMAND }, async $ => {
341    void loadMails($, config).catch(() => {})
342    return { text: await openPane($) }
343  })
344
345  on('tool.call', { tool: OPEN_CALLED }, async $ => {
346    void loadMails($, config).catch(() => {})
347    return { result: await openPane($) }
348  })
349
350  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
351    const { Box, Button, Code, Link, Markdown, Text } = $.ui.resolve(e)
352    const current = await localeFor($)
353    const t = (key: MessageKey, values?: Record<string, string>) => translate(current, key, values)
354    const heading = (key: MessageKey) => (
355      <Text bold dimColor>
356        {t(key).toLocaleUpperCase(current)}
357      </Text>
358    )
359    const [list, more, words, chosen, opened, isWhole, shownFile, kept, loading, issue, painted, nowMs] = await Promise.all([
360      read($, mails),
361      read($, nextOffset),
362      read($, query),
363      read($, filter),
364      read($, message),
365      read($, isThreadShown),
366      read($, preview),
367      read($, saved),
368      read($, busy),
369      read($, problem),
370      read($, theme),
371      read($, now),
372    ])
373    const themeName = isTheme(painted) ? painted : DEFAULT_THEME
374    const accent = THEMES[themeName]
375    const unread = list.filter(mail => !mail.isRead)
376    const shown = chosen === 'unread' ? unread : list
377    const when = (receivedMs: number) => relativeTimeOf(current, receivedMs, nowMs || receivedMs)
378    const subtitle = t('bannerLine', {
379      source: config.domain || t('inbox'),
380      unread: t('unreadCount', { count: String(unread.length) }),
381    })
382    const problemLine = issue && <Text color={PALETTE.problem}>{t(issue.key, issue.values)}</Text>
383
384    const banner = () => {
385      if (e.surface === 'terminal') {
386        return (
387          <Box flexDirection="column">
388            <Text bold color={accent}>
389              {t('title')}
390            </Text>
391            <Text dimColor>{subtitle}</Text>
392          </Box>
393        )
394      }
395      const { Svg } = $.ui.resolve(e)
396      return <Svg source={bannerSvgOf(t('title'), subtitle, accent)} alt={`${t('title')} · ${subtitle}`} />
397    }
398
399    const image = (file: MailPreview) => {
400      if (!file.image) return null
401      const source = imageSvgOf(file.image.data, file.image.mimeType)
402      if (e.surface === 'terminal' || source.length > LAYOUT.svgCharacters) return <Text dimColor>{t('tooBig')}</Text>
403      const { Svg } = $.ui.resolve(e)
404      return <Svg source={source} alt={file.name} />
405    }
406
407    const searchField = () => {
408      if (e.surface === 'mobile') return null
409      const { Input } = $.ui.resolve(e)
410      return (
411        <Input
412          key="search"
413          label={t('searchLabel')}
414          placeholder={t('searchPlaceholder')}
415          submitLabel={t('searchSubmit')}
416          onSubmit={value => void search($, config, value)}
417        />
418      )
419    }
420
421    const row = (mail: MailSummary) => {
422      const details = [when(mail.receivedMs), mail.hasAttachments ? t('attachment') : ''].filter(Boolean).join(' · ')
423      return (
424        <Button key={`mail:${mail.uri}`} plain onPress={() => openMail($, config, mail)}>
425          {mail.isRead ? <Text>{`${ICONS.read} `}</Text> : <Text color={accent}>{`${ICONS.unread} `}</Text>}
426          <Text bold={!mail.isRead}>{mail.fromName}</Text>
427          <Text>{`  ${mail.subject}`}</Text>
428          <Text dimColor>{`  ${details}`}</Text>
429        </Button>
430      )
431    }
432
433    const themes = (
434      <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
435        <Text dimColor>{t('theme')}</Text>
436        {(Object.keys(THEMES) as ThemeName[]).map(name => (
437          <Button key={`theme:${name}`} plain onPress={() => chooseTheme($, name)}>
438            <Text color={THEMES[name]}>{name === themeName ? ICONS.themeChosen : ICONS.themeOther}</Text>
439          </Button>
440        ))}
441      </Box>
442    )
443
444    if (opened && shownFile) {
445      const text = (shownFile.text ?? '').slice(0, LAYOUT.threadCharacters)
446      return (
447        <Box flexDirection="column" gap={LAYOUT.sectionGap}>
448          <Button key="closePreview" label={`${ICONS.back} ${t('closePreview')}`} onPress={() => closePreview($)} />
449          <Text bold>{shownFile.name}</Text>
450          {shownFile.image
451            ? image(shownFile)
452            : text
453              ? MARKDOWN_NAME.test(shownFile.name)
454                ? <Markdown key="file" text={text} />
455                : <Code key="file" source={text} path={shownFile.name} />
456              : <Text dimColor>{t('noPreview')}</Text>}
457        </Box>
458      )
459    }
460
461    if (opened) {
462      const isReading = loading === 'mail'
463      const text = (isWhole ? opened.thread : opened.body).slice(0, LAYOUT.threadCharacters)
464      const isSaved = kept?.uri === opened.uri
465      return (
466        <Box flexDirection="column" gap={LAYOUT.sectionGap}>
467          <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={LAYOUT.buttonGap}>
468            <Button key="back" label={`${ICONS.back} ${t('back')}`} onPress={() => closeMail($)} />
469            {!isReading && (
470              <Button key="toChat" onPress={() => passToChat($, config, opened, e.surface)}>
471                <Text bold color={accent}>
472                  {t('toChat')}
473                </Text>
474              </Button>
475            )}
476            {!isReading && (
477              <Button key="save" label={loading === 'save' ? t('saving') : t('save')} onPress={() => saveMail($, config, opened)} />
478            )}
479            {opened.webLink && <Link href={opened.webLink} label={t('outlook')} />}
480          </Box>
481          {problemLine}
482          {isSaved && <Text dimColor>{t('savedIn', { folder: kept.folder })}</Text>}
483          <Box flexDirection="column">
484            <Text bold>{opened.subject}</Text>
485            <Text dimColor>{t('fromLine', { who: `${opened.fromName} <${opened.from}>`, when: when(opened.receivedMs) })}</Text>
486            {opened.to.length > 0 && <Text dimColor>{t('toLine', { who: opened.to.join(', ') })}</Text>}
487            {opened.cc.length > 0 && <Text dimColor>{t('ccLine', { who: opened.cc.join(', ') })}</Text>}
488          </Box>
489          {opened.attachments.length > 0 && (
490            <Box flexDirection="column">
491              {heading('attachmentsHeading')}
492              {opened.attachments.map(one => (
493                <Box key={`attachment:${one.uri}`} flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
494                  <Button key={`view:${one.uri}`} plain onPress={() => viewAttachment($, config, one)}>
495                    <Text color={accent}>{t('view')}</Text>
496                  </Button>
497                  <Text>{one.name}</Text>
498                  <Text dimColor>{sizeOf(current, one.size)}</Text>
499                </Box>
500              ))}
501              {loading === 'attachment' && <Text dimColor>{t('loading')}</Text>}
502            </Box>
503          )}
504          {isReading ? <Text dimColor>{t('loading')}</Text> : <Markdown key="body" text={text || t('noBody')} />}
505          {!isReading && opened.thread !== opened.body && (
506            <Button key="thread" plain onPress={() => toggleThread($)}>
507              <Text color={accent}>{isWhole ? t('hideThread') : t('showThread')}</Text>
508            </Button>
509          )}
510          {themes}
511        </Box>
512      )
513    }
514
515    return (
516      <Box flexDirection="column" gap={LAYOUT.sectionGap}>
517        {banner()}
518        {searchField()}
519        {words && (
520          <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
521            <Text dimColor>{t('resultsFor', { query: words })}</Text>
522            <Button key="clear" plain onPress={() => search($, config, '')}>
523              <Text color={accent}>{t('clear')}</Text>
524            </Button>
525          </Box>
526        )}
527        <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={LAYOUT.buttonGap}>
528          <Button
529            key="filter:all"
530            label={t('all')}
531            variant={chosen === 'all' ? 'primary' : 'secondary'}
532            onPress={() => chooseFilter($, 'all')}
533          />
534          <Button
535            key="filter:unread"
536            label={t('unreadCount', { count: String(unread.length) })}
537            variant={chosen === 'unread' ? 'primary' : 'secondary'}
538            onPress={() => chooseFilter($, 'unread')}
539          />
540          <Button key="refresh" plain onPress={() => loadMails($, config)}>
541            <Text color={accent}>{t('refresh')}</Text>
542          </Button>
543        </Box>
544        {loading === 'list' && <Text dimColor>{t('loading')}</Text>}
545        {problemLine}
546        {shown.length > 0 ? (
547          <Box flexDirection="column">{shown.map(row)}</Box>
548        ) : (
549          loading !== 'list' && <Text dimColor>{t('empty')}</Text>
550        )}
551        {more !== null && chosen === 'all' && (
552          <Button key="more" label={loading === 'more' ? t('loading') : t('more')} onPress={() => loadMails($, config, true)} />
553        )}
554        {themes}
555      </Box>
556    )
557  })
558}
559
hooks/art.ts 25 lines
1import { LAYOUT } from './layout'
2import { PALETTE } from './palette'
3
4const SVG = 'http://www.w3.org/2000/svg'
5
6const escaped = (text: string) =>
7  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
8
9export function imageSvgOf(base64: string, mimeType: string) {
10  const { previewWidth: width, previewHeight: height } = LAYOUT
11  return `<svg xmlns="${SVG}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><image href="data:${escaped(mimeType)};base64,${base64}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/></svg>`
12}
13
14export function bannerSvgOf(title: string, subtitle: string, accent: string) {
15  const look = LAYOUT.banner
16  const ink = PALETTE.bannerInk
17  const right = look.envelopeX + look.envelopeWidth
18  const middle = look.envelopeX + look.envelopeWidth / 2
19  const fold = look.envelopeY + look.envelopeHeight / 2
20  const envelope = `<rect x="${look.envelopeX}" y="${look.envelopeY}" width="${look.envelopeWidth}" height="${look.envelopeHeight}" rx="${look.envelopeCorner}" fill="none" stroke="${ink}" stroke-opacity="${look.fade}" stroke-width="${look.envelopeStroke}"/><path d="M${look.envelopeX} ${look.envelopeY} L${middle} ${fold} L${right} ${look.envelopeY}" fill="none" stroke="${ink}" stroke-opacity="${look.fade}" stroke-width="${look.envelopeStroke}" stroke-linejoin="round"/>`
21  const heading = `<text x="${look.titleX}" y="${look.titleY}" font-family="${look.font}" font-size="${look.titleSize}" font-weight="800" fill="${ink}">${escaped(title)}</text>`
22  const lead = `<text x="${look.subtitleX}" y="${look.subtitleY}" font-family="${look.font}" font-size="${look.subtitleSize}" fill="${ink}" fill-opacity="${look.fade}">${escaped(subtitle)}</text>`
23  return `<svg xmlns="${SVG}" width="${look.width}" height="${look.height}" viewBox="0 0 ${look.width} ${look.height}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${accent}"/><stop offset="1" stop-color="${PALETTE.bannerBase}"/></linearGradient></defs><rect width="${look.width}" height="${look.height}" rx="${look.corner}" fill="url(#g)"/>${envelope}${heading}${lead}</svg>`
24}
25
hooks/icons.ts 8 lines
1export const ICONS = {
2  unread: '●',
3  read: ' ',
4  back: '←',
5  themeChosen: '◉',
6  themeOther: '●',
7}
8
hooks/layout.ts 33 lines
1export const LAYOUT = {
2  sectionGap: 1,
3  buttonGap: 1,
4  pageSize: 15,
5  refreshMinutes: 5,
6  toastsAtOnce: 3,
7  threadCharacters: 40_000,
8  svgCharacters: 130_000,
9  previewWidth: 800,
10  previewHeight: 600,
11  tempNameLength: 60,
12  decodeTimeoutMs: 60_000,
13  banner: {
14    width: 1000,
15    height: 220,
16    corner: 28,
17    titleX: 56,
18    titleY: 118,
19    titleSize: 88,
20    subtitleX: 60,
21    subtitleY: 172,
22    subtitleSize: 32,
23    fade: 0.75,
24    font: 'Segoe UI, system-ui, sans-serif',
25    envelopeX: 790,
26    envelopeY: 60,
27    envelopeWidth: 150,
28    envelopeHeight: 100,
29    envelopeStroke: 8,
30    envelopeCorner: 10,
31  },
32}
33
hooks/locales.ts 120 lines
1const en = {
2  title: 'Mail',
3  commandDescription: 'Open the mail pane beside the chat',
4  commandOpened: 'Mail pane opened.',
5  inbox: 'Inbox',
6  unreadCount: '{count} unread',
7  bannerLine: '{source} · {unread}',
8  searchLabel: 'Search',
9  searchPlaceholder: 'Words in the subject, the body or an attachment',
10  searchSubmit: 'Search',
11  all: 'All',
12  refresh: 'Refresh',
13  more: 'Show more',
14  loading: 'Loading…',
15  empty: 'No mail here.',
16  resultsFor: 'Results for {query}',
17  clear: 'Clear',
18  back: 'Back',
19  toChat: 'Pass to the chat',
20  outlook: 'Open in Outlook',
21  fromLine: 'From {who} · {when}',
22  toLine: 'To {who}',
23  ccLine: 'Cc {who}',
24  attachment: 'attachment',
25  attachmentsHeading: 'Attachments',
26  showThread: 'Show the whole thread',
27  hideThread: 'Hide the thread',
28  noBody: 'This mail has no text.',
29  newMail: 'New mail from {who}: {subject}',
30  chatText: 'Mail from {who}, {when}\nSubject: {subject}\n\n{body}\n\nThe mail and its attachments are saved in {folder}:\n{files}',
31  mailFile: '# {subject}\n\nFrom: {who}\nTo: {to}\nCc: {cc}\nDate: {date}\n\n---\n\n{body}\n',
32  mailFileName: 'mail.md',
33  saveFolderName: 'Mail',
34  noSubject: 'No subject',
35  view: 'View',
36  save: 'Save',
37  saving: 'Saving…',
38  savedIn: 'Saved in {folder}',
39  closePreview: 'Back to the mail',
40  tooBig: 'This image is too big to show here. Save the mail to open it.',
41  noPreview: 'This file cannot be shown here. Save the mail to open it.',
42  attachmentFailed: 'The attachment {name} cannot be read here: open it in Outlook.',
43  saveFailed: 'These could not be saved: {names}.',
44  copied: 'The mail was copied: paste it in the chat.',
45  connectorFailed: 'The Microsoft 365 connector did not answer. Check that it is connected in this session.',
46  mailFailed: 'That mail could not be read.',
47  chatFailed: 'The mail could not be passed to the chat.',
48  theme: 'Theme',
49}
50
51export type MessageKey = keyof typeof en
52
53const es: Record<MessageKey, string> = {
54  title: 'Correo',
55  commandDescription: 'Abre el panel de correo junto al chat',
56  commandOpened: 'Panel de correo abierto.',
57  inbox: 'Bandeja de entrada',
58  unreadCount: '{count} sin leer',
59  bannerLine: '{source} · {unread}',
60  searchLabel: 'Buscar',
61  searchPlaceholder: 'Palabras del asunto, del texto o de un adjunto',
62  searchSubmit: 'Buscar',
63  all: 'Todos',
64  refresh: 'Actualizar',
65  more: 'Ver más',
66  loading: 'Cargando…',
67  empty: 'No hay correos aquí.',
68  resultsFor: 'Resultados de {query}',
69  clear: 'Quitar',
70  back: 'Volver',
71  toChat: 'Pasar al chat',
72  outlook: 'Abrir en Outlook',
73  fromLine: 'De {who} · {when}',
74  toLine: 'Para {who}',
75  ccLine: 'CC {who}',
76  attachment: 'adjunto',
77  attachmentsHeading: 'Adjuntos',
78  showThread: 'Ver el hilo completo',
79  hideThread: 'Ocultar el hilo',
80  noBody: 'Este correo no tiene texto.',
81  newMail: 'Correo nuevo de {who}: {subject}',
82  chatText: 'Correo de {who}, {when}\nAsunto: {subject}\n\n{body}\n\nGuardé el correo y sus adjuntos en {folder}:\n{files}',
83  mailFile: '# {subject}\n\nDe: {who}\nPara: {to}\nCC: {cc}\nFecha: {date}\n\n---\n\n{body}\n',
84  mailFileName: 'correo.md',
85  saveFolderName: 'Correo',
86  noSubject: 'Sin asunto',
87  view: 'Ver',
88  save: 'Guardar',
89  saving: 'Guardando…',
90  savedIn: 'Guardado en {folder}',
91  closePreview: 'Volver al correo',
92  tooBig: 'Esta imagen es muy grande para verla aquí. Guarda el correo para abrirla.',
93  noPreview: 'Este archivo no se puede mostrar aquí. Guarda el correo para abrirlo.',
94  attachmentFailed: 'El adjunto {name} no se puede leer aquí: ábrelo en Outlook.',
95  saveFailed: 'No se pudieron guardar: {names}.',
96  copied: 'Se copió el correo: pégalo en el chat.',
97  connectorFailed: 'El conector de Microsoft 365 no respondió. Revisa que esté conectado en esta sesión.',
98  mailFailed: 'No se pudo leer ese correo.',
99  chatFailed: 'No se pudo pasar el correo al chat.',
100  theme: 'Tema',
101}
102
103const LOCALES = {
104  en: { names: ['en', 'english', 'inglés'], messages: en },
105  es: { names: ['es', 'español', 'espanol', 'spanish', 'castellano'], messages: es },
106}
107
108export type Locale = keyof typeof LOCALES
109
110const BASE_LOCALE: Locale = 'en'
111
112export function localeOf(language: string | undefined): Locale {
113  const name = (language ?? '').trim().toLowerCase().split(/[-_\s]/)[0] ?? ''
114  const found = (Object.keys(LOCALES) as Locale[]).find(locale => LOCALES[locale].names.includes(name))
115  return found ?? BASE_LOCALE
116}
117
118export const translate = (locale: Locale, key: MessageKey, values: Record<string, string> = {}) =>
119  LOCALES[locale].messages[key].replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
120
hooks/mail.ts 243 lines
1import type { MailAttachment, MailMessage, MailSummary } from '../types'
2
3const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
4const QUOTE_MARKERS = [
5  /<div[^>]*id="divRplyFwdMsg"/i,
6  /<div[^>]*id="appendonsend"/i,
7  /<div[^>]*class="[^"]*gmail_quote/i,
8  /<blockquote/i,
9]
10const DROPPED = /<(style|script|head|title)[^>]*>[\s\S]*?<\/\1>/gi
11const LINK = /<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi
12const BOLD = /<(b|strong)\b[^>]*>([\s\S]*?)<\/\1>/gi
13const ITEM = /<li\b[^>]*>/gi
14const BREAK = /<br\s*\/?>/gi
15const BLOCK_END = /<\/(p|div|tr|h[1-6]|li|ul|ol|table)>/gi
16const RULE = /<hr\b[^>]*>/gi
17const TAG = /<[^>]+>/g
18const ENTITY = /&(#x[0-9a-f]+|#\d+|[a-z]+);/gi
19const LIST_SPLIT = /[,;\n]+/
20const SAFE_LINK = /^(https?:|mailto:)/i
21const MAILTO = /^mailto:/i
22const LOOSE_ITEM = /^(- .*)\n\n(?=- )/gm
23const SOFT_BREAK = /([^\n])\n(?!\n|- )/g
24const HARD_BREAK = '  \n'
25const UNSAFE_NAME = /[<>:"/\\|?*\u0000-\u001f]/g
26const NAME_LENGTH = 80
27const DAY_LENGTH = 10
28const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
29  ['year', 31_536_000_000],
30  ['month', 2_592_000_000],
31  ['week', 604_800_000],
32  ['day', 86_400_000],
33  ['hour', 3_600_000],
34  ['minute', 60_000],
35]
36const CARRIAGE_RETURN = String.fromCharCode(13)
37const SAVED_NOTICE = /exceeds maximum allowed tokens\. Output has been saved to (.+)\.$/m
38const TEXT_TYPE = /^text\/|[/+](json|xml)$/i
39const TEXT_EXTENSION = '.txt'
40
41type Raw = Record<string, unknown>
42
43const textOf = (value: unknown) => (typeof value === 'string' ? value : '')
44
45export function jsonObjectsOf(text: string): Raw[] {
46  const found: Raw[] = []
47  let depth = 0
48  let start = -1
49  let isInString = false
50  let isEscaped = false
51  for (let at = 0; at < text.length; at++) {
52    const char = text[at]
53    if (isInString) {
54      if (isEscaped) isEscaped = false
55      else if (char === '\\') isEscaped = true
56      else if (char === '"') isInString = false
57      continue
58    }
59    if (char === '"') isInString = true
60    else if (char === '{') {
61      if (depth === 0) start = at
62      depth++
63    } else if (char === '}' && depth > 0) {
64      depth--
65      if (depth === 0) {
66        const parsed = parsedOf(text.slice(start, at + 1))
67        if (parsed) found.push(parsed)
68      }
69    }
70  }
71  return found
72}
73
74function parsedOf(json: string): Raw | undefined {
75  try {
76    const value: unknown = JSON.parse(json)
77    return value && typeof value === 'object' ? (value as Raw) : undefined
78  } catch {
79    return undefined
80  }
81}
82
83export function summaryOf(raw: Raw): MailSummary | undefined {
84  const uri = textOf(raw.uri)
85  if (!uri) return undefined
86  return {
87    uri,
88    subject: textOf(raw.subject),
89    from: textOf(raw.sender),
90    fromName: textOf(raw.senderName) || textOf(raw.sender),
91    receivedMs: Date.parse(textOf(raw.receivedDateTime)) || 0,
92    preview: textOf(raw.summary).replaceAll(CARRIAGE_RETURN, '').trim(),
93    isRead: raw.isRead !== false,
94    hasAttachments: raw.hasAttachments === true,
95    webLink: textOf(raw.webLink) || undefined,
96  }
97}
98
99export function pageOf(text: string) {
100  const objects = jsonObjectsOf(text)
101  const mails = objects.flatMap(raw => summaryOf(raw) ?? []).sort((a, b) => b.receivedMs - a.receivedMs)
102  const paging = objects.find(raw => typeof raw.nextOffset === 'number')
103  return { mails, nextOffset: paging ? (paging.nextOffset as number) : null }
104}
105
106const decoded = (text: string) =>
107  text.replace(ENTITY, (whole, name: string) => {
108    if (name.startsWith('#x') || name.startsWith('#X')) return String.fromCodePoint(parseInt(name.slice(2), 16))
109    if (name.startsWith('#')) return String.fromCodePoint(Number(name.slice(1)))
110    return ENTITIES[name.toLowerCase()] ?? whole
111  })
112
113export function markdownOf(html: string) {
114  const text = html
115    .replace(DROPPED, '')
116    .replace(LINK, (_whole, href: string, label: string) => {
117      const words = label.replace(TAG, '').trim()
118      const target = decoded(href)
119      const address = target.replace(MAILTO, '')
120      return SAFE_LINK.test(target) && words && words !== target && words !== address ? `[${words}](${target})` : words || address
121    })
122    .replace(BOLD, (_whole, _tag: string, inner: string) => {
123      const words = inner.replace(TAG, '').trim()
124      return words ? `**${words}**` : ''
125    })
126    .replace(ITEM, '\n- ')
127    .replace(BREAK, '\n')
128    .replace(RULE, '\n---\n')
129    .replace(BLOCK_END, '\n')
130    .replace(TAG, '')
131  return decoded(text)
132    .split('\n')
133    .map(line => line.replaceAll(CARRIAGE_RETURN, '').trimEnd())
134    .join('\n')
135    .replace(/\n{3,}/g, '\n\n')
136    .replace(LOOSE_ITEM, '$1\n')
137    .replace(SOFT_BREAK, `$1${HARD_BREAK}`)
138    .trim()
139}
140
141export function newestPartOf(html: string) {
142  const cuts = QUOTE_MARKERS.map(marker => html.search(marker)).filter(at => at > 0)
143  return cuts.length > 0 ? html.slice(0, Math.min(...cuts)) : html
144}
145
146const peopleOf = (value: unknown) =>
147  Array.isArray(value)
148    ? value.flatMap(person => {
149        const one = person as Raw
150        const name = textOf(one.name)
151        const address = textOf(one.address)
152        return name || address ? [name && address && name !== address ? `${name} <${address}>` : name || address] : []
153      })
154    : []
155
156export function messageOf(raw: Raw, uri: string): MailMessage {
157  const body = (raw.body ?? {}) as Raw
158  const content = textOf(body.content)
159  const isHtml = textOf(body.contentType).toLowerCase() === 'html'
160  const sender = (raw.sender ?? {}) as Raw
161  const thread = isHtml ? markdownOf(content) : content.replaceAll(CARRIAGE_RETURN, '').trim()
162  const newest = isHtml ? markdownOf(newestPartOf(content)) : thread
163  const attachments: MailAttachment[] = Array.isArray(raw.attachments)
164    ? raw.attachments.flatMap(item => {
165        const one = item as Raw
166        return one.isInline === true || !textOf(one.name) || !textOf(one.uri)
167          ? []
168          : [{ uri: textOf(one.uri), name: textOf(one.name), size: Number(one.size) || 0, contentType: textOf(one.contentType) }]
169      })
170    : []
171  return {
172    uri,
173    subject: textOf(raw.subject),
174    from: textOf(sender.address),
175    fromName: textOf(sender.name) || textOf(sender.address),
176    to: peopleOf(raw.toRecipients),
177    cc: peopleOf(raw.ccRecipients),
178    receivedMs: Date.parse(textOf(raw.receivedDateTime)) || 0,
179    body: newest,
180    thread,
181    attachments,
182    webLink: textOf(raw.webLink) || undefined,
183  }
184}
185
186export type AttachmentContent = { data: string; mimeType: string } | { text: string }
187
188export const savedPathOf = (text: string) => SAVED_NOTICE.exec(text)?.[1]
189
190export function contentOf(blocks: readonly Record<string, unknown>[]): AttachmentContent | undefined {
191  for (const block of blocks) {
192    const resource = (block.resource ?? {}) as Raw
193    const source = (block.source ?? {}) as Raw
194    const data = textOf(block.data) || textOf(resource.blob) || textOf(source.data)
195    const mimeType = textOf(block.mimeType) || textOf(resource.mimeType) || textOf(source.media_type)
196    if (data) return { data, mimeType }
197  }
198  const text = blocks.map(block => textOf(block.text) || textOf(((block.resource ?? {}) as Raw).text)).join('')
199  const notice = parsedOf(text.trim())
200  return !text || (notice && 'note' in notice && 'attachment' in notice) ? undefined : { text }
201}
202
203export const textFileNameOf = (name: string, contentType: string) =>
204  TEXT_TYPE.test(contentType) ? name : `${name}${TEXT_EXTENSION}`
205
206export const isImage = (mimeType: string) => mimeType.toLowerCase().startsWith('image/')
207
208export const safeNameOf = (text: string) =>
209  text
210    .replace(UNSAFE_NAME, ' ')
211    .replace(/\s+/g, ' ')
212    .trim()
213    .slice(0, NAME_LENGTH)
214    .replace(/[. ]+$/, '')
215
216export const dayOf = (receivedMs: number) => new Date(receivedMs).toISOString().slice(0, DAY_LENGTH)
217
218export const joinPath = (dir: string, name: string) =>
219  `${dir.replace(/[\\/]+$/, '')}${dir.includes('\\') ? '\\' : '/'}${name}`
220
221export const watchListOf = (text: string) =>
222  text
223    .split(LIST_SPLIT)
224    .map(entry => entry.trim().toLowerCase())
225    .filter(Boolean)
226
227export function isWatched(mail: MailSummary, watch: readonly string[]) {
228  if (watch.length === 0) return true
229  const address = mail.from.toLowerCase()
230  const name = mail.fromName.toLowerCase()
231  return watch.some(entry => address === entry || address.endsWith(`@${entry}`) || name.includes(entry))
232}
233
234export function relativeTimeOf(locale: string, thenMs: number, nowMs: number) {
235  const gap = thenMs - nowMs
236  const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
237  const unit = UNITS.find(([, size]) => Math.abs(gap) >= size)
238  return unit ? format.format(Math.round(gap / unit[1]), unit[0]) : format.format(0, 'minute')
239}
240
241export const sizeOf = (locale: string, bytes: number) =>
242  new Intl.NumberFormat(locale, { style: 'unit', unit: 'kilobyte', maximumFractionDigits: 0 }).format(Math.max(1, bytes / 1024))
243
hooks/palette.ts 23 lines
1export const THEMES = {
2  blue: '#4f8ff7',
3  cyan: '#39c5cf',
4  green: '#3fb950',
5  yellow: '#d4a72c',
6  orange: '#f0883e',
7  red: '#f85149',
8  pink: '#e0457b',
9  purple: '#a371f7',
10}
11
12export type ThemeName = keyof typeof THEMES
13
14export const DEFAULT_THEME: ThemeName = 'blue'
15
16export const isTheme = (value: unknown): value is ThemeName => typeof value === 'string' && value in THEMES
17
18export const PALETTE = {
19  problem: '#e5a50a',
20  bannerBase: '#18181b',
21  bannerInk: '#ffffff',
22}
23
types/index.d.ts 77 lines
1export type MailSummary = {
2  uri: string
3  subject: string
4  from: string
5  fromName: string
6  receivedMs: number
7  preview: string
8  isRead: boolean
9  hasAttachments: boolean
10  webLink?: string
11}
12
13export type MailAttachment = {
14  uri: string
15  name: string
16  size: number
17  contentType: string
18}
19
20export type MailMessage = {
21  uri: string
22  subject: string
23  from: string
24  fromName: string
25  to: string[]
26  cc: string[]
27  receivedMs: number
28  body: string
29  thread: string
30  attachments: MailAttachment[]
31  webLink?: string
32}
33
34export type MailPreview = {
35  name: string
36  text?: string
37  image?: { data: string; mimeType: string }
38}
39
40export type MailSaved = {
41  uri: string
42  folder: string
43  files: string[]
44}
45
46export type MailFilter = 'all' | 'unread'
47
48export type MailBusy = 'list' | 'more' | 'mail' | 'attachment' | 'save'
49
50export type MailProblem = {
51  key: 'connectorFailed' | 'mailFailed' | 'chatFailed' | 'attachmentFailed' | 'saveFailed'
52  values?: Record<string, string>
53}
54
55declare module 'claude-code' {
56  interface McpToolInputs {
57    'mcp__mail-pane__open': Record<string, never>
58  }
59
60  interface PluginState {
61    'mail-pane': {
62      mails: MailSummary[]
63      nextOffset: number | null
64      query: string
65      filter: MailFilter
66      message: MailMessage | null
67      isThreadShown: boolean
68      preview: MailPreview | null
69      saved: MailSaved | null
70      busy: MailBusy | null
71      problem: MailProblem | null
72      theme: string
73      now: number
74    }
75  }
76}
77