zele 0.3.14 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/api-utils.d.ts +3 -0
- package/dist/api-utils.js +6 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/calendar-time.js +6 -0
- package/dist/calendar-time.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +42 -2
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +1 -1
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +1 -1
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +2 -2
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -0
- package/dist/commands/filter.js +59 -0
- package/dist/commands/filter.js.map +1 -0
- package/dist/commands/mail-actions.js +12 -2
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +176 -93
- package/dist/commands/mail.js.map +1 -1
- package/dist/db.js +24 -1
- package/dist/db.js.map +1 -1
- package/dist/gmail-client.d.ts +28 -0
- package/dist/gmail-client.js +168 -13
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.js +34 -9
- package/dist/mail-tui.js.map +1 -1
- package/dist/output.d.ts +2 -0
- package/dist/output.js +4 -0
- package/dist/output.js.map +1 -1
- package/package.json +8 -3
- package/skills/zele/SKILL.md +112 -0
- package/src/api-utils.ts +7 -0
- package/src/app.log +9 -0
- package/src/auth.ts +1 -1
- package/src/calendar-time.test.ts +35 -0
- package/src/calendar-time.ts +5 -0
- package/src/cli.ts +6 -1
- package/src/commands/attachment.ts +47 -2
- package/src/commands/auth-cmd.ts +1 -1
- package/src/commands/calendar.ts +1 -1
- package/src/commands/draft.ts +2 -2
- package/src/commands/filter.ts +68 -0
- package/src/commands/mail-actions.ts +14 -2
- package/src/commands/mail.ts +186 -98
- package/src/db.ts +26 -1
- package/src/gmail-client.ts +202 -20
- package/src/mail-tui.test.ts +170 -0
- package/src/mail-tui.tsx +56 -9
- package/src/output.ts +8 -1
- package/src/opentui-react.d.ts +0 -9
package/src/gmail-client.ts
CHANGED
|
@@ -78,10 +78,16 @@ export interface ThreadListItem {
|
|
|
78
78
|
snippet: string
|
|
79
79
|
subject: string
|
|
80
80
|
from: Sender
|
|
81
|
+
to: Sender[]
|
|
82
|
+
cc: Sender[]
|
|
81
83
|
date: string
|
|
82
84
|
labelIds: string[]
|
|
83
85
|
unread: boolean
|
|
86
|
+
starred: boolean
|
|
84
87
|
messageCount: number
|
|
88
|
+
inReplyTo: string | null
|
|
89
|
+
hasAttachments: boolean
|
|
90
|
+
listUnsubscribe: string | null
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
export interface ThreadListResult {
|
|
@@ -173,6 +179,15 @@ function sanitizeSnippet(snippet: string): string {
|
|
|
173
179
|
.trim()
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Sanitize header values to prevent CRLF injection attacks.
|
|
184
|
+
* The mimetext library does not sanitize custom header values, so newlines
|
|
185
|
+
* in In-Reply-To or References could inject arbitrary headers.
|
|
186
|
+
*/
|
|
187
|
+
function sanitizeHeaderValue(value: string): string {
|
|
188
|
+
return value.replace(/[\r\n]/g, ' ').trim()
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
function encodeBase64Url(data: string | Buffer) {
|
|
177
192
|
const buf = typeof data === 'string' ? Buffer.from(data) : data
|
|
178
193
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
@@ -205,6 +220,24 @@ function gmailBoundary<T>(email: string, fn: () => Promise<T>) {
|
|
|
205
220
|
})
|
|
206
221
|
}
|
|
207
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Known folder names that map to Gmail system folders/labels.
|
|
225
|
+
* Used to validate custom label names and prevent query injection.
|
|
226
|
+
*/
|
|
227
|
+
const KNOWN_FOLDERS = new Set([
|
|
228
|
+
'inbox',
|
|
229
|
+
'sent',
|
|
230
|
+
'trash',
|
|
231
|
+
'bin',
|
|
232
|
+
'spam',
|
|
233
|
+
'drafts',
|
|
234
|
+
'draft',
|
|
235
|
+
'starred',
|
|
236
|
+
'archive',
|
|
237
|
+
'snoozed',
|
|
238
|
+
'all',
|
|
239
|
+
])
|
|
240
|
+
|
|
208
241
|
export class GmailClient {
|
|
209
242
|
private gmail: gmail_v1.Gmail
|
|
210
243
|
private labelIdCache: Record<string, string> = {}
|
|
@@ -779,7 +812,8 @@ export class GmailClient {
|
|
|
779
812
|
)
|
|
780
813
|
if (messageIds instanceof Error) return messageIds
|
|
781
814
|
if (messageIds.length === 0) return
|
|
782
|
-
await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
|
|
815
|
+
const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
|
|
816
|
+
if (mod instanceof Error) return mod
|
|
783
817
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
784
818
|
}
|
|
785
819
|
|
|
@@ -789,7 +823,8 @@ export class GmailClient {
|
|
|
789
823
|
)
|
|
790
824
|
if (messageIds instanceof Error) return messageIds
|
|
791
825
|
if (messageIds.length === 0) return
|
|
792
|
-
await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
|
|
826
|
+
const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
|
|
827
|
+
if (mod instanceof Error) return mod
|
|
793
828
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
794
829
|
}
|
|
795
830
|
|
|
@@ -797,7 +832,8 @@ export class GmailClient {
|
|
|
797
832
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
798
833
|
if (messageIds instanceof Error) return messageIds
|
|
799
834
|
if (messageIds.length === 0) return
|
|
800
|
-
await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
|
|
835
|
+
const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
|
|
836
|
+
if (mod instanceof Error) return mod
|
|
801
837
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
802
838
|
}
|
|
803
839
|
|
|
@@ -807,7 +843,8 @@ export class GmailClient {
|
|
|
807
843
|
)
|
|
808
844
|
if (messageIds instanceof Error) return messageIds
|
|
809
845
|
if (messageIds.length === 0) return
|
|
810
|
-
await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
|
|
846
|
+
const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
|
|
847
|
+
if (mod instanceof Error) return mod
|
|
811
848
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
812
849
|
}
|
|
813
850
|
|
|
@@ -834,10 +871,11 @@ export class GmailClient {
|
|
|
834
871
|
if (messageIds instanceof Error) return messageIds
|
|
835
872
|
if (messageIds.length === 0) return
|
|
836
873
|
|
|
837
|
-
await this.batchModifyMessages(messageIds, {
|
|
874
|
+
const mod = await this.batchModifyMessages(messageIds, {
|
|
838
875
|
addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
|
|
839
876
|
removeLabelIds: resolvedRemove,
|
|
840
877
|
})
|
|
878
|
+
if (mod instanceof Error) return mod
|
|
841
879
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
842
880
|
}
|
|
843
881
|
|
|
@@ -865,7 +903,28 @@ export class GmailClient {
|
|
|
865
903
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
866
904
|
if (messageIds instanceof Error) return messageIds
|
|
867
905
|
if (messageIds.length === 0) return
|
|
868
|
-
await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
|
|
906
|
+
const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
|
|
907
|
+
if (mod instanceof Error) return mod
|
|
908
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async markAsSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
912
|
+
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
913
|
+
if (messageIds instanceof Error) return messageIds
|
|
914
|
+
if (messageIds.length === 0) return
|
|
915
|
+
const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['SPAM'], removeLabelIds: ['INBOX'] })
|
|
916
|
+
if (mod instanceof Error) return mod
|
|
917
|
+
await this.invalidateAfterThreadMutation(threadIds)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async unmarkSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
|
|
921
|
+
const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
|
|
922
|
+
labelIds.includes('SPAM'),
|
|
923
|
+
)
|
|
924
|
+
if (messageIds instanceof Error) return messageIds
|
|
925
|
+
if (messageIds.length === 0) return
|
|
926
|
+
const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['SPAM'], addLabelIds: ['INBOX'] })
|
|
927
|
+
if (mod instanceof Error) return mod
|
|
869
928
|
await this.invalidateAfterThreadMutation(threadIds)
|
|
870
929
|
}
|
|
871
930
|
|
|
@@ -892,10 +951,11 @@ export class GmailClient {
|
|
|
892
951
|
const threadIds = res.threads.map((t) => t.id)
|
|
893
952
|
const messageIds = await this.getMessageIdsForThreads(threadIds)
|
|
894
953
|
if (messageIds instanceof Error) return messageIds
|
|
895
|
-
await this.batchModifyMessages(messageIds, {
|
|
954
|
+
const mod = await this.batchModifyMessages(messageIds, {
|
|
896
955
|
addLabelIds: ['TRASH'],
|
|
897
956
|
removeLabelIds: ['SPAM', 'INBOX'],
|
|
898
957
|
})
|
|
958
|
+
if (mod instanceof Error) return mod
|
|
899
959
|
|
|
900
960
|
totalDeleted += threadIds.length
|
|
901
961
|
pageToken = res.nextPageToken ?? undefined
|
|
@@ -994,6 +1054,61 @@ export class GmailClient {
|
|
|
994
1054
|
await this.invalidateLabels()
|
|
995
1055
|
}
|
|
996
1056
|
|
|
1057
|
+
// =========================================================================
|
|
1058
|
+
// Filters
|
|
1059
|
+
// =========================================================================
|
|
1060
|
+
|
|
1061
|
+
async listFilters(): Promise<{ parsed: gmail_v1.Schema$Filter[] } | AuthError | ApiError> {
|
|
1062
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1063
|
+
withRetry(() => this.gmail.users.settings.filters.list({ userId: 'me' })),
|
|
1064
|
+
)
|
|
1065
|
+
if (res instanceof Error) return res
|
|
1066
|
+
return { parsed: res.data.filter ?? [] }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async createFilter(opts: {
|
|
1070
|
+
from?: string
|
|
1071
|
+
query?: string
|
|
1072
|
+
addLabelIds?: string[]
|
|
1073
|
+
removeLabelIds?: string[]
|
|
1074
|
+
}): Promise<gmail_v1.Schema$Filter | AuthError | ApiError> {
|
|
1075
|
+
const criteria: gmail_v1.Schema$FilterCriteria = {}
|
|
1076
|
+
if (opts.from) criteria.from = opts.from
|
|
1077
|
+
if (opts.query) criteria.query = opts.query
|
|
1078
|
+
|
|
1079
|
+
const action: gmail_v1.Schema$FilterAction = {}
|
|
1080
|
+
if (opts.addLabelIds?.length) action.addLabelIds = opts.addLabelIds
|
|
1081
|
+
if (opts.removeLabelIds?.length) action.removeLabelIds = opts.removeLabelIds
|
|
1082
|
+
|
|
1083
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1084
|
+
withRetry(() =>
|
|
1085
|
+
this.gmail.users.settings.filters.create({
|
|
1086
|
+
userId: 'me',
|
|
1087
|
+
requestBody: { criteria, action },
|
|
1088
|
+
}),
|
|
1089
|
+
),
|
|
1090
|
+
)
|
|
1091
|
+
if (res instanceof Error) return res
|
|
1092
|
+
return res.data
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async deleteFilter(filterId: string): Promise<void | AuthError | ApiError> {
|
|
1096
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1097
|
+
withRetry(() =>
|
|
1098
|
+
this.gmail.users.settings.filters.delete({
|
|
1099
|
+
userId: 'me',
|
|
1100
|
+
id: filterId,
|
|
1101
|
+
}),
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
if (res instanceof Error) return res
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
|
|
1108
|
+
async resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError> {
|
|
1109
|
+
return this.resolveLabelId(nameOrId)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
997
1112
|
// =========================================================================
|
|
998
1113
|
// Label counts (unread counts per folder/label)
|
|
999
1114
|
// =========================================================================
|
|
@@ -1233,16 +1348,47 @@ export class GmailClient {
|
|
|
1233
1348
|
}
|
|
1234
1349
|
}
|
|
1235
1350
|
|
|
1351
|
+
// Parse recipients from latest message
|
|
1352
|
+
const toHeader = getHeader('to') ?? ''
|
|
1353
|
+
const ccHeaders = headers
|
|
1354
|
+
.filter((h) => h.name?.toLowerCase() === 'cc')
|
|
1355
|
+
.map((h) => h.value ?? '')
|
|
1356
|
+
.filter((v) => v.length > 0)
|
|
1357
|
+
|
|
1358
|
+
// Check if any non-draft message in the thread is a reply (has In-Reply-To header)
|
|
1359
|
+
const inReplyTo =
|
|
1360
|
+
nonDraftMessages
|
|
1361
|
+
.map(
|
|
1362
|
+
(m) =>
|
|
1363
|
+
m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
|
|
1364
|
+
null,
|
|
1365
|
+
)
|
|
1366
|
+
.find((v) => v !== null) ?? null
|
|
1367
|
+
|
|
1368
|
+
// Check if any message has attachments (non-inline)
|
|
1369
|
+
const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload))
|
|
1370
|
+
|
|
1371
|
+
// List-Unsubscribe from latest message
|
|
1372
|
+
const listUnsubscribe = getHeader('list-unsubscribe') ?? null
|
|
1373
|
+
|
|
1236
1374
|
return {
|
|
1237
1375
|
id: raw.id ?? '',
|
|
1238
1376
|
historyId: raw.historyId ?? null,
|
|
1239
1377
|
snippet: sanitizeSnippet(latest?.snippet ?? ''),
|
|
1240
1378
|
subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
|
|
1241
1379
|
from: displayFrom,
|
|
1380
|
+
to: toHeader ? parseAddressList(toHeader) : [],
|
|
1381
|
+
cc: ccHeaders.length > 0
|
|
1382
|
+
? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
|
|
1383
|
+
: [],
|
|
1242
1384
|
date: getHeader('date') ?? '',
|
|
1243
1385
|
labelIds: allLabels,
|
|
1244
1386
|
unread: allLabels.includes('UNREAD'),
|
|
1387
|
+
starred: allLabels.includes('STARRED'),
|
|
1245
1388
|
messageCount: nonDraftMessages.length,
|
|
1389
|
+
inReplyTo,
|
|
1390
|
+
hasAttachments,
|
|
1391
|
+
listUnsubscribe,
|
|
1246
1392
|
}
|
|
1247
1393
|
}
|
|
1248
1394
|
|
|
@@ -1366,6 +1512,24 @@ export class GmailClient {
|
|
|
1366
1512
|
return results
|
|
1367
1513
|
}
|
|
1368
1514
|
|
|
1515
|
+
/** Quick check: does the message payload tree contain any non-inline attachments?
|
|
1516
|
+
* Checks the root part itself (some messages have attachment metadata there)
|
|
1517
|
+
* then recurses into child parts. */
|
|
1518
|
+
private hasNonInlineAttachments(part: gmail_v1.Schema$MessagePart | undefined): boolean {
|
|
1519
|
+
if (!part) return false
|
|
1520
|
+
if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
|
|
1521
|
+
const disposition =
|
|
1522
|
+
part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
|
|
1523
|
+
const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
|
|
1524
|
+
const isInline = disposition.toLowerCase().includes('inline')
|
|
1525
|
+
if (!isInline || !hasContentId) return true
|
|
1526
|
+
}
|
|
1527
|
+
for (const child of part.parts ?? []) {
|
|
1528
|
+
if (this.hasNonInlineAttachments(child)) return true
|
|
1529
|
+
}
|
|
1530
|
+
return false
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1369
1533
|
async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
|
|
1370
1534
|
const profile = await this.getProfile()
|
|
1371
1535
|
if (profile instanceof Error) return profile
|
|
@@ -1615,7 +1779,7 @@ export class GmailClient {
|
|
|
1615
1779
|
})
|
|
1616
1780
|
|
|
1617
1781
|
if (inReplyTo) {
|
|
1618
|
-
msg.setHeader('In-Reply-To', inReplyTo)
|
|
1782
|
+
msg.setHeader('In-Reply-To', sanitizeHeaderValue(inReplyTo))
|
|
1619
1783
|
}
|
|
1620
1784
|
|
|
1621
1785
|
if (references) {
|
|
@@ -1623,6 +1787,7 @@ export class GmailClient {
|
|
|
1623
1787
|
.split(' ')
|
|
1624
1788
|
.filter(Boolean)
|
|
1625
1789
|
.map((ref) => {
|
|
1790
|
+
ref = sanitizeHeaderValue(ref)
|
|
1626
1791
|
if (!ref.startsWith('<')) ref = `<${ref}`
|
|
1627
1792
|
if (!ref.endsWith('>')) ref = `${ref}>`
|
|
1628
1793
|
return ref
|
|
@@ -1707,7 +1872,21 @@ export class GmailClient {
|
|
|
1707
1872
|
|
|
1708
1873
|
// For non-inbox folders, use Gmail search syntax.
|
|
1709
1874
|
// Caller-provided labelIds are preserved as additional filters.
|
|
1710
|
-
|
|
1875
|
+
|
|
1876
|
+
// Normalize folder name to lowercase for consistent matching
|
|
1877
|
+
const normalizedFolder = folder.toLowerCase()
|
|
1878
|
+
|
|
1879
|
+
// Validate custom label names to prevent query injection.
|
|
1880
|
+
// Gmail query operators like "OR", "from:", parentheses, etc. could manipulate search results.
|
|
1881
|
+
// Known folders are handled by the switch cases below; custom labels must be safe characters only.
|
|
1882
|
+
// Slashes are allowed for nested labels (e.g., "work/projects").
|
|
1883
|
+
if (!KNOWN_FOLDERS.has(normalizedFolder) && !/^[\w\/-]+$/.test(normalizedFolder)) {
|
|
1884
|
+
throw new Error(
|
|
1885
|
+
`Invalid folder/label name: "${folder}". Use alphanumeric characters, underscores, hyphens, and slashes only.`,
|
|
1886
|
+
)
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
switch (normalizedFolder) {
|
|
1711
1890
|
case 'sent':
|
|
1712
1891
|
q = `in:sent ${q}`.trim()
|
|
1713
1892
|
break
|
|
@@ -1735,8 +1914,8 @@ export class GmailClient {
|
|
|
1735
1914
|
q = `in:anywhere ${q}`.trim()
|
|
1736
1915
|
break
|
|
1737
1916
|
default:
|
|
1738
|
-
// Treat as a label name
|
|
1739
|
-
q = `label:${
|
|
1917
|
+
// Treat as a label name (use normalized for consistency)
|
|
1918
|
+
q = `label:${normalizedFolder} ${q}`.trim()
|
|
1740
1919
|
break
|
|
1741
1920
|
}
|
|
1742
1921
|
|
|
@@ -1779,22 +1958,25 @@ export class GmailClient {
|
|
|
1779
1958
|
private async batchModifyMessages(
|
|
1780
1959
|
messageIds: string[],
|
|
1781
1960
|
body: { addLabelIds?: string[]; removeLabelIds?: string[] },
|
|
1782
|
-
) {
|
|
1961
|
+
): Promise<void | AuthError | ApiError> {
|
|
1783
1962
|
if (messageIds.length === 0) return
|
|
1784
1963
|
|
|
1785
1964
|
// Gmail batchModify accepts up to 1000 IDs
|
|
1786
1965
|
const chunkSize = 1000
|
|
1787
1966
|
for (let i = 0; i < messageIds.length; i += chunkSize) {
|
|
1788
1967
|
const chunk = messageIds.slice(i, i + chunkSize)
|
|
1789
|
-
await
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1968
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
1969
|
+
withRetry(() =>
|
|
1970
|
+
this.gmail.users.messages.batchModify({
|
|
1971
|
+
userId: 'me',
|
|
1972
|
+
requestBody: {
|
|
1973
|
+
ids: chunk,
|
|
1974
|
+
...body,
|
|
1975
|
+
},
|
|
1976
|
+
}),
|
|
1977
|
+
),
|
|
1797
1978
|
)
|
|
1979
|
+
if (res instanceof Error) return res
|
|
1798
1980
|
}
|
|
1799
1981
|
}
|
|
1800
1982
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// E2E test for the mail TUI mailbox folder switching.
|
|
2
|
+
// Uses tuistory to launch the TUI via termcast dev and verify folder filter actions.
|
|
3
|
+
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { test, expect, afterEach } from 'vitest'
|
|
6
|
+
import { launchTerminal, type TerminalSession } from 'tuistory'
|
|
7
|
+
|
|
8
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..')
|
|
9
|
+
|
|
10
|
+
let session: TerminalSession
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
session?.close()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Navigate down in the actions panel until the cursor line contains the target text.
|
|
18
|
+
* Returns true if found, false if we hit maxSteps without finding it.
|
|
19
|
+
*/
|
|
20
|
+
async function navigateToAction(
|
|
21
|
+
session: TerminalSession,
|
|
22
|
+
target: string,
|
|
23
|
+
maxSteps = 25,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
26
|
+
const text = await session.text({ trimEnd: true })
|
|
27
|
+
// tuistory renders the cursor line with › prefix
|
|
28
|
+
const lines = text.split('\n')
|
|
29
|
+
const cursorLine = lines.find((l) => l.includes('›') && l.includes(target))
|
|
30
|
+
if (cursorLine) return true
|
|
31
|
+
await session.press('down')
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract just the visible portion of the actions panel overlay from a terminal snapshot.
|
|
38
|
+
* Strips the background list content and returns only action items.
|
|
39
|
+
*/
|
|
40
|
+
function extractActionsPanel(text: string): string {
|
|
41
|
+
const lines = text.split('\n')
|
|
42
|
+
const panelLines: string[] = []
|
|
43
|
+
let inPanel = false
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.includes('Actions') && line.includes('esc')) inPanel = true
|
|
46
|
+
if (inPanel) {
|
|
47
|
+
// Extract the content between the box-drawing borders
|
|
48
|
+
const match = line.match(/│\s*(.*?)\s*│/)
|
|
49
|
+
if (match) {
|
|
50
|
+
panelLines.push(match[1]!.trimEnd())
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (inPanel && line.includes('╰')) break
|
|
54
|
+
}
|
|
55
|
+
return panelLines.filter((l) => l.length > 0).join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('mailbox folder filter switches between folders', async () => {
|
|
59
|
+
session = await launchTerminal({
|
|
60
|
+
command: 'termcast',
|
|
61
|
+
args: ['dev'],
|
|
62
|
+
cols: 120,
|
|
63
|
+
rows: 36,
|
|
64
|
+
cwd: PROJECT_ROOT,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Wait for the TUI to render with inbox
|
|
68
|
+
await session.waitForText('Search', { timeout: 20000 })
|
|
69
|
+
const initialScreen = await session.text({ trimEnd: true })
|
|
70
|
+
expect(initialScreen).toContain('Search Inbox...')
|
|
71
|
+
|
|
72
|
+
// Open actions panel and navigate to "Sent" action
|
|
73
|
+
await session.press(['ctrl', 'k'])
|
|
74
|
+
await session.waitForText('Actions', { timeout: 5000 })
|
|
75
|
+
|
|
76
|
+
const foundSent = await navigateToAction(session, 'Sent')
|
|
77
|
+
expect(foundSent).toBe(true)
|
|
78
|
+
|
|
79
|
+
// Snapshot the actions panel with cursor on Sent
|
|
80
|
+
const actionsWithSent = extractActionsPanel(await session.text({ trimEnd: true }))
|
|
81
|
+
expect(actionsWithSent).toMatchInlineSnapshot(`
|
|
82
|
+
"Actions esc
|
|
83
|
+
> Search actions...
|
|
84
|
+
Copy Thread ID
|
|
85
|
+
Copy Subject
|
|
86
|
+
Copy Sender Email
|
|
87
|
+
⌧ Trash ⌃BACKSPACE
|
|
88
|
+
Mailbox ▀
|
|
89
|
+
✓ Inbox
|
|
90
|
+
›○ Sent
|
|
91
|
+
○ Starred
|
|
92
|
+
○ Drafts
|
|
93
|
+
↵ select ↑↓ navigate"
|
|
94
|
+
`)
|
|
95
|
+
|
|
96
|
+
await session.press('enter')
|
|
97
|
+
|
|
98
|
+
// Verify the screen updated to Sent folder
|
|
99
|
+
await session.waitForText('Search Sent', { timeout: 15000 })
|
|
100
|
+
const sentScreen = await session.text({ trimEnd: true })
|
|
101
|
+
expect(sentScreen).toContain('Search Sent...')
|
|
102
|
+
|
|
103
|
+
// Now switch back to Inbox via actions panel
|
|
104
|
+
await session.press(['ctrl', 'k'])
|
|
105
|
+
await session.waitForText('Actions', { timeout: 5000 })
|
|
106
|
+
|
|
107
|
+
const foundInbox = await navigateToAction(session, 'Inbox')
|
|
108
|
+
expect(foundInbox).toBe(true)
|
|
109
|
+
|
|
110
|
+
// Snapshot the actions panel with cursor on Inbox (should show checkmark on Sent now)
|
|
111
|
+
const actionsWithInbox = extractActionsPanel(await session.text({ trimEnd: true }))
|
|
112
|
+
expect(actionsWithInbox).toMatchInlineSnapshot(`
|
|
113
|
+
"Actions esc
|
|
114
|
+
> Search actions...
|
|
115
|
+
Copy Thread ID
|
|
116
|
+
Copy Subject
|
|
117
|
+
Copy Sender Email
|
|
118
|
+
⌧ Trash ⌃BACKSPACE
|
|
119
|
+
Mailbox ▀
|
|
120
|
+
›○ Inbox
|
|
121
|
+
✓ Sent
|
|
122
|
+
○ Starred
|
|
123
|
+
○ Drafts
|
|
124
|
+
↵ select ↑↓ navigate"
|
|
125
|
+
`)
|
|
126
|
+
|
|
127
|
+
await session.press('enter')
|
|
128
|
+
|
|
129
|
+
// Verify switched back to Inbox
|
|
130
|
+
await session.waitForText('Search Inbox', { timeout: 15000 })
|
|
131
|
+
const inboxScreen = await session.text({ trimEnd: true })
|
|
132
|
+
expect(inboxScreen).toContain('Search Inbox...')
|
|
133
|
+
}, 60000)
|
|
134
|
+
|
|
135
|
+
test('actions panel lists mailbox folder options', async () => {
|
|
136
|
+
session = await launchTerminal({
|
|
137
|
+
command: 'termcast',
|
|
138
|
+
args: ['dev'],
|
|
139
|
+
cols: 120,
|
|
140
|
+
rows: 36,
|
|
141
|
+
cwd: PROJECT_ROOT,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
await session.waitForText('Search', { timeout: 20000 })
|
|
145
|
+
|
|
146
|
+
// Open actions panel
|
|
147
|
+
await session.press(['ctrl', 'k'])
|
|
148
|
+
await session.waitForText('Actions', { timeout: 5000 })
|
|
149
|
+
|
|
150
|
+
// Navigate down to Mailbox section
|
|
151
|
+
const foundStarred = await navigateToAction(session, 'Starred')
|
|
152
|
+
expect(foundStarred).toBe(true)
|
|
153
|
+
|
|
154
|
+
// Snapshot the Mailbox section visible in the actions panel
|
|
155
|
+
const actionsText = extractActionsPanel(await session.text({ trimEnd: true }))
|
|
156
|
+
expect(actionsText).toMatchInlineSnapshot(`
|
|
157
|
+
"Actions esc
|
|
158
|
+
> Search actions...
|
|
159
|
+
Copy Thread ID
|
|
160
|
+
Copy Subject
|
|
161
|
+
Copy Sender Email
|
|
162
|
+
⌧ Trash ⌃BACKSPACE
|
|
163
|
+
Mailbox ▀
|
|
164
|
+
✓ Inbox
|
|
165
|
+
○ Sent
|
|
166
|
+
›○ Starred
|
|
167
|
+
○ Drafts
|
|
168
|
+
↵ select ↑↓ navigate"
|
|
169
|
+
`)
|
|
170
|
+
}, 30000)
|
package/src/mail-tui.tsx
CHANGED
|
@@ -78,6 +78,17 @@ const ACCOUNT_COLORS = [
|
|
|
78
78
|
const ADD_ACCOUNT = '__add_account__'
|
|
79
79
|
const MANAGE_ACCOUNTS = '__manage_accounts__'
|
|
80
80
|
|
|
81
|
+
const FOLDER_OPTIONS = [
|
|
82
|
+
{ id: 'inbox', label: 'Inbox', icon: Icon.Envelope },
|
|
83
|
+
{ id: 'sent', label: 'Sent', icon: Icon.PaperAirplane },
|
|
84
|
+
{ id: 'starred', label: 'Starred', icon: Icon.Star },
|
|
85
|
+
{ id: 'drafts', label: 'Drafts', icon: Icon.Pencil },
|
|
86
|
+
{ id: 'archive', label: 'Archive', icon: Icon.Tray },
|
|
87
|
+
{ id: 'spam', label: 'Spam', icon: Icon.ExclamationMark },
|
|
88
|
+
{ id: 'trash', label: 'Trash', icon: Icon.Trash },
|
|
89
|
+
{ id: 'all', label: 'All Mail', icon: Icon.List },
|
|
90
|
+
] as const
|
|
91
|
+
|
|
81
92
|
// ---------------------------------------------------------------------------
|
|
82
93
|
// Helpers
|
|
83
94
|
// ---------------------------------------------------------------------------
|
|
@@ -399,10 +410,19 @@ function ComposeForm({ mode, initialAccount, accounts, onSent }: ComposeFormProp
|
|
|
399
410
|
? 'Reply All'
|
|
400
411
|
: 'Reply'
|
|
401
412
|
|
|
402
|
-
const bodyPlaceholder =
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
413
|
+
const bodyPlaceholder = mode.type === 'forward'
|
|
414
|
+
? `Add a message (optional)...
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
Best,
|
|
419
|
+
Name`
|
|
420
|
+
: `Type your reply...
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
Best,
|
|
425
|
+
Name`
|
|
406
426
|
|
|
407
427
|
const handleSubmit = async (values: { to?: string; body?: string }) => {
|
|
408
428
|
// Validate based on mode
|
|
@@ -700,6 +720,11 @@ export default function Command() {
|
|
|
700
720
|
'all',
|
|
701
721
|
{ cacheNamespace: CACHE_NAMESPACE },
|
|
702
722
|
)
|
|
723
|
+
const [activeFolder, setActiveFolder] = useCachedState(
|
|
724
|
+
'activeFolder',
|
|
725
|
+
'inbox' as string,
|
|
726
|
+
{ cacheNamespace: CACHE_NAMESPACE },
|
|
727
|
+
)
|
|
703
728
|
const [searchText, setSearchText] = useState('')
|
|
704
729
|
const [isShowingDetail, setIsShowingDetail] = useCachedState(
|
|
705
730
|
'isShowingDetail',
|
|
@@ -732,7 +757,7 @@ export default function Command() {
|
|
|
732
757
|
pagination,
|
|
733
758
|
revalidate,
|
|
734
759
|
} = useCachedPromise(
|
|
735
|
-
(query: string, account: string) => {
|
|
760
|
+
(query: string, account: string, folder: string) => {
|
|
736
761
|
return async ({ cursor }: { page: number; cursor?: MailCursor }) => {
|
|
737
762
|
const accountFilter = account === 'all' ? undefined : [account]
|
|
738
763
|
const clients = await getClients(accountFilter)
|
|
@@ -744,6 +769,7 @@ export default function Command() {
|
|
|
744
769
|
const { email, client } = clients[0]!
|
|
745
770
|
const result = await client.listThreads({
|
|
746
771
|
query: query || undefined,
|
|
772
|
+
folder,
|
|
747
773
|
maxResults: pageSize,
|
|
748
774
|
pageToken: pageToken || undefined,
|
|
749
775
|
})
|
|
@@ -784,6 +810,7 @@ export default function Command() {
|
|
|
784
810
|
|
|
785
811
|
const result = await client.listThreads({
|
|
786
812
|
query: query || undefined,
|
|
813
|
+
folder,
|
|
787
814
|
maxResults: pageSize,
|
|
788
815
|
pageToken: previousByAccount[email] ?? undefined,
|
|
789
816
|
})
|
|
@@ -833,7 +860,7 @@ export default function Command() {
|
|
|
833
860
|
}
|
|
834
861
|
}
|
|
835
862
|
},
|
|
836
|
-
[searchText, selectedAccount, pageSize],
|
|
863
|
+
[searchText, selectedAccount, activeFolder, pageSize],
|
|
837
864
|
{ keepPreviousData: true },
|
|
838
865
|
)
|
|
839
866
|
|
|
@@ -933,7 +960,7 @@ export default function Command() {
|
|
|
933
960
|
isLoading={isLoading || accounts.isLoading || isMutating}
|
|
934
961
|
isShowingDetail={isShowingDetail}
|
|
935
962
|
spacingMode={LIST_SPACING_MODE}
|
|
936
|
-
searchBarPlaceholder=
|
|
963
|
+
searchBarPlaceholder={`Search ${FOLDER_OPTIONS.find((f) => f.id === activeFolder)?.label ?? 'emails'}...`}
|
|
937
964
|
onSearchTextChange={setSearchText}
|
|
938
965
|
throttle
|
|
939
966
|
pagination={pagination ? { ...pagination, pageSize } : undefined}
|
|
@@ -990,7 +1017,7 @@ export default function Command() {
|
|
|
990
1017
|
// Detail panel: latest message body as markdown
|
|
991
1018
|
const detail = isShowingDetail ? (
|
|
992
1019
|
<List.Item.Detail
|
|
993
|
-
markdown={
|
|
1020
|
+
markdown={`\n\n${thread.snippet}`}
|
|
994
1021
|
metadata={
|
|
995
1022
|
<List.Item.Detail.Metadata>
|
|
996
1023
|
<List.Item.Detail.Metadata.Label
|
|
@@ -1107,6 +1134,16 @@ export default function Command() {
|
|
|
1107
1134
|
onAction={() => setSelectedThreads([])}
|
|
1108
1135
|
/>
|
|
1109
1136
|
</ActionPanel.Section>
|
|
1137
|
+
<ActionPanel.Section title='Mailbox'>
|
|
1138
|
+
{FOLDER_OPTIONS.map((f) => (
|
|
1139
|
+
<Action
|
|
1140
|
+
key={f.id}
|
|
1141
|
+
title={f.label}
|
|
1142
|
+
icon={activeFolder === f.id ? Icon.CheckCircle : Icon.Circle}
|
|
1143
|
+
onAction={() => setActiveFolder(f.id)}
|
|
1144
|
+
/>
|
|
1145
|
+
))}
|
|
1146
|
+
</ActionPanel.Section>
|
|
1110
1147
|
<ActionPanel.Section>
|
|
1111
1148
|
<Action
|
|
1112
1149
|
title='Refresh'
|
|
@@ -1157,7 +1194,7 @@ export default function Command() {
|
|
|
1157
1194
|
<Action
|
|
1158
1195
|
title={thread.unread ? 'Mark as Read' : 'Mark as Unread'}
|
|
1159
1196
|
icon={thread.unread ? Icon.Eye : Icon.EyeDisabled}
|
|
1160
|
-
shortcut={{ modifiers: ['ctrl'], key: 'u' }}
|
|
1197
|
+
// shortcut={{ modifiers: ['ctrl'], key: 'u' }}
|
|
1161
1198
|
onAction={() => withMutation(async () => {
|
|
1162
1199
|
const { client } = await getClient([thread.account])
|
|
1163
1200
|
const result = thread.unread
|
|
@@ -1285,6 +1322,16 @@ export default function Command() {
|
|
|
1285
1322
|
})}
|
|
1286
1323
|
/>
|
|
1287
1324
|
</ActionPanel.Section>
|
|
1325
|
+
<ActionPanel.Section title='Mailbox'>
|
|
1326
|
+
{FOLDER_OPTIONS.map((f) => (
|
|
1327
|
+
<Action
|
|
1328
|
+
key={f.id}
|
|
1329
|
+
title={f.label}
|
|
1330
|
+
icon={activeFolder === f.id ? Icon.CheckCircle : Icon.Circle}
|
|
1331
|
+
onAction={() => setActiveFolder(f.id)}
|
|
1332
|
+
/>
|
|
1333
|
+
))}
|
|
1334
|
+
</ActionPanel.Section>
|
|
1288
1335
|
<ActionPanel.Section>
|
|
1289
1336
|
<Action
|
|
1290
1337
|
title='Refresh'
|