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…

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.
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.
| Option | What it is |
|---|---|
domain | Only mail sent from this domain or address (example.com). Empty shows the newest mail of the inbox. |
watch | Addresses or names, separated by commas, whose new mail raises a toast. Empty: every new mail of the domain does. |
refreshMinutes | How often the pane looks for new mail. 5 by default. |
connector | The connector's name as /mcp lists it. claude.ai Microsoft 365 by default. |
saveFolder | Where each mail is saved with its attachments, one folder per mail. Empty: a Mail folder in Downloads. |
openOnStart | Open the pane when a session starts. On by default. |
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.
hooks/register.tsx 559 lines1import { 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}
559hooks/art.ts 25 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
25hooks/icons.ts 8 lines1export const ICONS = {
2 unread: '●',
3 read: ' ',
4 back: '←',
5 themeChosen: '◉',
6 themeOther: '●',
7}
8hooks/layout.ts 33 lines1export 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}
33hooks/locales.ts 120 lines1const 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)
120hooks/mail.ts 243 lines1import 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))
243hooks/palette.ts 23 lines1export 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}
23types/index.d.ts 77 lines1export 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