@realtimex/email-automator 2.3.8 → 2.4.5
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/api/src/middleware/validation.ts +11 -4
- package/api/src/routes/actions.ts +84 -49
- package/api/src/routes/emails.ts +8 -1
- package/api/src/routes/rules.ts +8 -3
- package/api/src/services/eventLogger.ts +10 -2
- package/api/src/services/gmail.ts +128 -0
- package/api/src/services/intelligence.ts +1 -1
- package/api/src/services/microsoft.ts +3 -1
- package/api/src/services/processor.ts +89 -58
- package/api/src/services/supabase.ts +2 -1
- package/dist/api/src/middleware/validation.js +11 -4
- package/dist/api/src/routes/actions.js +87 -51
- package/dist/api/src/routes/emails.js +6 -2
- package/dist/api/src/routes/rules.js +7 -3
- package/dist/api/src/services/eventLogger.js +12 -2
- package/dist/api/src/services/gmail.js +99 -0
- package/dist/api/src/services/intelligence.js +1 -1
- package/dist/api/src/services/microsoft.js +3 -1
- package/dist/api/src/services/processor.js +77 -49
- package/dist/assets/index-C3PlbplS.css +1 -0
- package/dist/assets/index-DfGa9R7j.js +97 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/supabase/functions/api-v1-emails/index.ts +9 -1
- package/supabase/migrations/20260118000000_multi_actions_rules.sql +17 -0
- package/dist/assets/index-BQ1uMdFh.js +0 -97
- package/dist/assets/index-Dzi17fx5.css +0 -1
|
@@ -69,11 +69,14 @@ export const schemas = {
|
|
|
69
69
|
accountId: z.string().uuid('Invalid account ID'),
|
|
70
70
|
}),
|
|
71
71
|
|
|
72
|
-
// Action schemas
|
|
72
|
+
// Action schemas - supports both single action (legacy) and actions array
|
|
73
73
|
executeAction: z.object({
|
|
74
74
|
emailId: z.string().uuid('Invalid email ID'),
|
|
75
|
-
action: z.enum(['delete', 'archive', 'draft', 'flag', 'none']),
|
|
75
|
+
action: z.enum(['delete', 'archive', 'draft', 'flag', 'read', 'star', 'none']).optional(),
|
|
76
|
+
actions: z.array(z.enum(['delete', 'archive', 'draft', 'flag', 'read', 'star', 'none'])).optional(),
|
|
76
77
|
draftContent: z.string().optional(),
|
|
78
|
+
}).refine(data => data.action || (data.actions && data.actions.length > 0), {
|
|
79
|
+
message: 'Either action or actions must be provided',
|
|
77
80
|
}),
|
|
78
81
|
|
|
79
82
|
// Migration schemas
|
|
@@ -83,19 +86,23 @@ export const schemas = {
|
|
|
83
86
|
accessToken: z.string().optional(),
|
|
84
87
|
}),
|
|
85
88
|
|
|
86
|
-
// Rule schemas
|
|
89
|
+
// Rule schemas - supports both single action (legacy) and actions array
|
|
87
90
|
createRule: z.object({
|
|
88
91
|
name: z.string().min(1).max(100),
|
|
89
92
|
condition: z.record(z.unknown()),
|
|
90
|
-
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']),
|
|
93
|
+
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
|
|
94
|
+
actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
|
|
91
95
|
instructions: z.string().optional(),
|
|
92
96
|
is_enabled: z.boolean().default(true),
|
|
97
|
+
}).refine(data => data.action || (data.actions && data.actions.length > 0), {
|
|
98
|
+
message: 'Either action or actions must be provided',
|
|
93
99
|
}),
|
|
94
100
|
|
|
95
101
|
updateRule: z.object({
|
|
96
102
|
name: z.string().min(1).max(100).optional(),
|
|
97
103
|
condition: z.record(z.unknown()).optional(),
|
|
98
104
|
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
|
|
105
|
+
actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
|
|
99
106
|
instructions: z.string().optional(),
|
|
100
107
|
is_enabled: z.boolean().optional(),
|
|
101
108
|
}),
|
|
@@ -11,15 +11,24 @@ import { createLogger } from '../utils/logger.js';
|
|
|
11
11
|
const router = Router();
|
|
12
12
|
const logger = createLogger('ActionRoutes');
|
|
13
13
|
|
|
14
|
-
// Execute action on email
|
|
14
|
+
// Execute action(s) on email - supports both single action and array of actions
|
|
15
15
|
router.post('/execute',
|
|
16
16
|
apiRateLimit,
|
|
17
17
|
authMiddleware,
|
|
18
18
|
validateBody(schemas.executeAction),
|
|
19
19
|
asyncHandler(async (req, res) => {
|
|
20
|
-
const { emailId, action, draftContent } = req.body;
|
|
20
|
+
const { emailId, action, actions, draftContent } = req.body;
|
|
21
21
|
const userId = req.user!.id;
|
|
22
22
|
|
|
23
|
+
// Support both single action (legacy) and actions array
|
|
24
|
+
const actionsToExecute: string[] = actions && actions.length > 0
|
|
25
|
+
? actions
|
|
26
|
+
: (action ? [action] : []);
|
|
27
|
+
|
|
28
|
+
if (actionsToExecute.length === 0) {
|
|
29
|
+
return res.status(400).json({ error: 'No actions specified' });
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
// Fetch email with account info
|
|
24
33
|
const { data: email, error } = await req.supabase!
|
|
25
34
|
.from('emails')
|
|
@@ -37,59 +46,85 @@ router.post('/execute',
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
const account = email.email_accounts;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
const actionResults: { action: string; success: boolean; details?: string }[] = [];
|
|
50
|
+
|
|
51
|
+
for (const currentAction of actionsToExecute) {
|
|
52
|
+
try {
|
|
53
|
+
let details = '';
|
|
54
|
+
|
|
55
|
+
if (currentAction === 'none') {
|
|
56
|
+
// Just mark as reviewed, no provider action needed
|
|
57
|
+
} else if (account.provider === 'gmail') {
|
|
58
|
+
const gmailService = getGmailService();
|
|
59
|
+
|
|
60
|
+
if (currentAction === 'delete') {
|
|
61
|
+
await gmailService.trashMessage(account, email.external_id);
|
|
62
|
+
} else if (currentAction === 'archive') {
|
|
63
|
+
await gmailService.archiveMessage(account, email.external_id);
|
|
64
|
+
} else if (currentAction === 'draft') {
|
|
65
|
+
const content = draftContent || email.ai_analysis?.draft_response || '';
|
|
66
|
+
if (content) {
|
|
67
|
+
const draftId = await gmailService.createDraft(account, email.external_id, content);
|
|
68
|
+
details = `Draft created: ${draftId}`;
|
|
69
|
+
}
|
|
70
|
+
} else if (currentAction === 'flag') {
|
|
71
|
+
await gmailService.addLabel(account, email.external_id, ['STARRED']);
|
|
72
|
+
} else if (currentAction === 'read') {
|
|
73
|
+
await gmailService.markAsRead(account, email.external_id);
|
|
74
|
+
} else if (currentAction === 'star') {
|
|
75
|
+
await gmailService.starMessage(account, email.external_id);
|
|
76
|
+
}
|
|
77
|
+
} else if (account.provider === 'outlook') {
|
|
78
|
+
const microsoftService = getMicrosoftService();
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
actionResult.details = `Draft created: ${draftId}`;
|
|
80
|
+
if (currentAction === 'delete') {
|
|
81
|
+
await microsoftService.trashMessage(account, email.external_id);
|
|
82
|
+
} else if (currentAction === 'archive') {
|
|
83
|
+
await microsoftService.archiveMessage(account, email.external_id);
|
|
84
|
+
} else if (currentAction === 'draft') {
|
|
85
|
+
const content = draftContent || email.ai_analysis?.draft_response || '';
|
|
86
|
+
if (content) {
|
|
87
|
+
const draftId = await microsoftService.createDraft(account, email.external_id, content);
|
|
88
|
+
details = `Draft created: ${draftId}`;
|
|
89
|
+
}
|
|
90
|
+
} else if (currentAction === 'read') {
|
|
91
|
+
await microsoftService.markAsRead(account, email.external_id);
|
|
92
|
+
} else if (currentAction === 'star' || currentAction === 'flag') {
|
|
93
|
+
await microsoftService.flagMessage(account, email.external_id);
|
|
94
|
+
}
|
|
81
95
|
}
|
|
82
|
-
}
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
// Record this action using atomic append
|
|
98
|
+
await req.supabase!.rpc('append_email_action', {
|
|
99
|
+
p_email_id: emailId,
|
|
100
|
+
p_action: currentAction
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
actionResults.push({ action: currentAction, success: true, details: details || undefined });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.error('Action failed', err, { emailId, action: currentAction });
|
|
106
|
+
actionResults.push({
|
|
107
|
+
action: currentAction,
|
|
108
|
+
success: false,
|
|
109
|
+
details: err instanceof Error ? err.message : 'Unknown error'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
|
|
90
|
-
|
|
114
|
+
// Update legacy column with first action for backward compatibility
|
|
115
|
+
await req.supabase!
|
|
116
|
+
.from('emails')
|
|
117
|
+
.update({ action_taken: actionsToExecute[0] })
|
|
118
|
+
.eq('id', emailId);
|
|
119
|
+
|
|
120
|
+
logger.info('Actions executed', { emailId, actions: actionsToExecute, userId });
|
|
91
121
|
|
|
92
|
-
res.json(
|
|
122
|
+
res.json({
|
|
123
|
+
success: actionResults.every(r => r.success),
|
|
124
|
+
results: actionResults,
|
|
125
|
+
// Legacy field for backward compatibility
|
|
126
|
+
details: actionResults.map(r => r.details).filter(Boolean).join('; ')
|
|
127
|
+
});
|
|
93
128
|
})
|
|
94
129
|
);
|
|
95
130
|
|
package/api/src/routes/emails.ts
CHANGED
|
@@ -19,8 +19,15 @@ router.get('/',
|
|
|
19
19
|
account_id,
|
|
20
20
|
action_taken,
|
|
21
21
|
search,
|
|
22
|
+
sort_by = 'date',
|
|
23
|
+
sort_order = 'desc'
|
|
22
24
|
} = req.query;
|
|
23
25
|
|
|
26
|
+
// Validate sort params
|
|
27
|
+
const validSortFields = ['date', 'created_at'];
|
|
28
|
+
const sortField = validSortFields.includes(sort_by as string) ? sort_by as string : 'date';
|
|
29
|
+
const isAscending = sort_order === 'asc';
|
|
30
|
+
|
|
24
31
|
let query = req.supabase!
|
|
25
32
|
.from('emails')
|
|
26
33
|
.select(`
|
|
@@ -28,7 +35,7 @@ router.get('/',
|
|
|
28
35
|
email_accounts!inner(id, user_id, email_address, provider)
|
|
29
36
|
`, { count: 'exact' })
|
|
30
37
|
.eq('email_accounts.user_id', req.user!.id)
|
|
31
|
-
.order(
|
|
38
|
+
.order(sortField, { ascending: isAscending })
|
|
32
39
|
.range(
|
|
33
40
|
parseInt(offset as string, 10),
|
|
34
41
|
parseInt(offset as string, 10) + parseInt(limit as string, 10) - 1
|
package/api/src/routes/rules.ts
CHANGED
|
@@ -31,7 +31,11 @@ router.post('/',
|
|
|
31
31
|
authMiddleware,
|
|
32
32
|
validateBody(schemas.createRule),
|
|
33
33
|
asyncHandler(async (req, res) => {
|
|
34
|
-
const { name, condition, action, is_enabled, instructions, attachments } = req.body;
|
|
34
|
+
const { name, condition, action, actions, is_enabled, instructions, attachments } = req.body;
|
|
35
|
+
|
|
36
|
+
// Use actions array if provided, otherwise use single action for backward compatibility
|
|
37
|
+
const ruleActions = actions && actions.length > 0 ? actions : (action ? [action] : []);
|
|
38
|
+
const primaryAction = ruleActions[0] || 'archive'; // For legacy column
|
|
35
39
|
|
|
36
40
|
const { data, error } = await req.supabase!
|
|
37
41
|
.from('rules')
|
|
@@ -39,7 +43,8 @@ router.post('/',
|
|
|
39
43
|
user_id: req.user!.id,
|
|
40
44
|
name,
|
|
41
45
|
condition,
|
|
42
|
-
action,
|
|
46
|
+
action: primaryAction, // Legacy column
|
|
47
|
+
actions: ruleActions, // New multi-action column
|
|
43
48
|
is_enabled,
|
|
44
49
|
instructions,
|
|
45
50
|
attachments,
|
|
@@ -49,7 +54,7 @@ router.post('/',
|
|
|
49
54
|
|
|
50
55
|
if (error) throw error;
|
|
51
56
|
|
|
52
|
-
logger.info('Rule created', { ruleId: data.id, userId: req.user!.id });
|
|
57
|
+
logger.info('Rule created', { ruleId: data.id, actions: ruleActions, userId: req.user!.id });
|
|
53
58
|
|
|
54
59
|
res.status(201).json({ rule: data });
|
|
55
60
|
})
|
|
@@ -46,7 +46,15 @@ export class EventLogger {
|
|
|
46
46
|
await this.log('action', state, { action, reason }, emailId);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async error(state: string,
|
|
50
|
-
|
|
49
|
+
async error(state: string, errorOrDetails: any, emailId?: string) {
|
|
50
|
+
let details;
|
|
51
|
+
if (errorOrDetails instanceof Error) {
|
|
52
|
+
details = { error: errorOrDetails.message };
|
|
53
|
+
} else if (typeof errorOrDetails === 'object' && errorOrDetails !== null) {
|
|
54
|
+
details = errorOrDetails;
|
|
55
|
+
} else {
|
|
56
|
+
details = { error: String(errorOrDetails) };
|
|
57
|
+
}
|
|
58
|
+
await this.log('error', state, details, emailId);
|
|
51
59
|
}
|
|
52
60
|
}
|
|
@@ -21,6 +21,7 @@ export interface GmailMessage {
|
|
|
21
21
|
sender: string;
|
|
22
22
|
recipient: string;
|
|
23
23
|
date: string;
|
|
24
|
+
internalDate: string; // Gmail's internal timestamp (ms since epoch) - use this for checkpointing
|
|
24
25
|
body: string;
|
|
25
26
|
snippet: string;
|
|
26
27
|
headers: {
|
|
@@ -224,6 +225,132 @@ export class GmailService {
|
|
|
224
225
|
};
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Fetch messages in OLDEST-FIRST order using "Fetch IDs → Sort → Hydrate" strategy.
|
|
230
|
+
*
|
|
231
|
+
* Gmail API always returns newest first and doesn't support sorting.
|
|
232
|
+
* To process oldest emails first (critical for checkpoint-based sync), we:
|
|
233
|
+
* 1. Fetch ALL message IDs matching the query (lightweight, paginated)
|
|
234
|
+
* 2. Sort by internalDate ascending (oldest first)
|
|
235
|
+
* 3. Take first N messages (limit)
|
|
236
|
+
* 4. Hydrate only those N messages with full details
|
|
237
|
+
*
|
|
238
|
+
* This ensures we never skip emails when using max_emails pagination.
|
|
239
|
+
*/
|
|
240
|
+
async fetchMessagesOldestFirst(
|
|
241
|
+
account: EmailAccount,
|
|
242
|
+
options: { limit: number; query?: string; maxIdsToFetch?: number }
|
|
243
|
+
): Promise<{ messages: GmailMessage[]; hasMore: boolean }> {
|
|
244
|
+
const { limit, query, maxIdsToFetch = 1000 } = options;
|
|
245
|
+
|
|
246
|
+
// Step 1: Fetch all message IDs (lightweight)
|
|
247
|
+
const allIds = await this.fetchAllMessageIds(account, query, maxIdsToFetch);
|
|
248
|
+
|
|
249
|
+
if (allIds.length === 0) {
|
|
250
|
+
return { messages: [], hasMore: false };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
logger.debug('Fetched message IDs', { count: allIds.length, query });
|
|
254
|
+
|
|
255
|
+
// Step 2: Sort by internalDate ascending (oldest first)
|
|
256
|
+
allIds.sort((a, b) => parseInt(a.internalDate) - parseInt(b.internalDate));
|
|
257
|
+
|
|
258
|
+
// Step 3: Take first N IDs
|
|
259
|
+
const idsToHydrate = allIds.slice(0, limit);
|
|
260
|
+
const hasMore = allIds.length > limit;
|
|
261
|
+
|
|
262
|
+
// Step 4: Hydrate those specific messages
|
|
263
|
+
const messages = await this.hydrateMessages(account, idsToHydrate.map(m => m.id));
|
|
264
|
+
|
|
265
|
+
// Re-sort hydrated messages by internalDate (maintain order)
|
|
266
|
+
messages.sort((a, b) => parseInt(a.internalDate) - parseInt(b.internalDate));
|
|
267
|
+
|
|
268
|
+
return { messages, hasMore };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Fetch all message IDs matching a query (lightweight, paginated).
|
|
273
|
+
* Uses minimal fields for speed: only id and internalDate.
|
|
274
|
+
*/
|
|
275
|
+
private async fetchAllMessageIds(
|
|
276
|
+
account: EmailAccount,
|
|
277
|
+
query: string | undefined,
|
|
278
|
+
maxIds: number
|
|
279
|
+
): Promise<{ id: string; internalDate: string }[]> {
|
|
280
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
281
|
+
const results: { id: string; internalDate: string }[] = [];
|
|
282
|
+
let pageToken: string | undefined;
|
|
283
|
+
|
|
284
|
+
do {
|
|
285
|
+
const response = await gmail.users.messages.list({
|
|
286
|
+
userId: 'me',
|
|
287
|
+
q: query,
|
|
288
|
+
pageToken,
|
|
289
|
+
maxResults: 500, // Max allowed per page
|
|
290
|
+
// Note: messages.list only returns id and threadId, not internalDate
|
|
291
|
+
// We need to fetch internalDate separately with minimal format
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const messageRefs = response.data.messages || [];
|
|
295
|
+
|
|
296
|
+
// Fetch internalDate for each message (using metadata format for speed)
|
|
297
|
+
for (const ref of messageRefs) {
|
|
298
|
+
if (!ref.id || results.length >= maxIds) break;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const msg = await gmail.users.messages.get({
|
|
302
|
+
userId: 'me',
|
|
303
|
+
id: ref.id,
|
|
304
|
+
format: 'minimal', // Only returns id, threadId, labelIds, snippet, internalDate
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (msg.data.id && msg.data.internalDate) {
|
|
308
|
+
results.push({
|
|
309
|
+
id: msg.data.id,
|
|
310
|
+
internalDate: msg.data.internalDate,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
logger.warn('Failed to fetch message metadata', { messageId: ref.id });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
pageToken = response.data.nextPageToken ?? undefined;
|
|
319
|
+
} while (pageToken && results.length < maxIds);
|
|
320
|
+
|
|
321
|
+
return results;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Hydrate specific messages by ID (fetch full details).
|
|
326
|
+
*/
|
|
327
|
+
private async hydrateMessages(
|
|
328
|
+
account: EmailAccount,
|
|
329
|
+
messageIds: string[]
|
|
330
|
+
): Promise<GmailMessage[]> {
|
|
331
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
332
|
+
const messages: GmailMessage[] = [];
|
|
333
|
+
|
|
334
|
+
for (const id of messageIds) {
|
|
335
|
+
try {
|
|
336
|
+
const detail = await gmail.users.messages.get({
|
|
337
|
+
userId: 'me',
|
|
338
|
+
id,
|
|
339
|
+
format: 'full',
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const parsed = this.parseMessage(detail.data);
|
|
343
|
+
if (parsed) {
|
|
344
|
+
messages.push(parsed);
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger.warn('Failed to hydrate message', { messageId: id, error });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return messages;
|
|
352
|
+
}
|
|
353
|
+
|
|
227
354
|
private parseMessage(message: gmail_v1.Schema$Message): GmailMessage | null {
|
|
228
355
|
if (!message.id || !message.threadId) return null;
|
|
229
356
|
|
|
@@ -250,6 +377,7 @@ export class GmailService {
|
|
|
250
377
|
sender: getHeader('From'),
|
|
251
378
|
recipient: getHeader('To'),
|
|
252
379
|
date: getHeader('Date'),
|
|
380
|
+
internalDate: message.internalDate || '', // Gmail's internal timestamp (ms since epoch)
|
|
253
381
|
body,
|
|
254
382
|
snippet: message.snippet || '',
|
|
255
383
|
headers: {
|
|
@@ -167,7 +167,7 @@ REQUIRED JSON STRUCTURE:
|
|
|
167
167
|
{ role: 'system', content: systemPrompt },
|
|
168
168
|
{ role: 'user', content: cleanedContent || '[Empty email body]' },
|
|
169
169
|
],
|
|
170
|
-
response_format: { type: 'json_object' },
|
|
170
|
+
// response_format: { type: 'json_object' }, // Removed for compatibility
|
|
171
171
|
temperature: 0.1,
|
|
172
172
|
});
|
|
173
173
|
|
|
@@ -160,7 +160,9 @@ export class MicrosoftService {
|
|
|
160
160
|
const accessToken = account.access_token || '';
|
|
161
161
|
const { top = 20, skip = 0, filter } = options;
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// IMPORTANT: Use ascending order to fetch OLDEST emails first
|
|
164
|
+
// This ensures checkpoint-based pagination works correctly and doesn't skip emails
|
|
165
|
+
let url = `https://graph.microsoft.com/v1.0/me/messages?$top=${top}&$skip=${skip}&$orderby=receivedDateTime asc&$select=id,conversationId,subject,from,toRecipients,receivedDateTime,body,bodyPreview,importance`;
|
|
164
166
|
if (filter) {
|
|
165
167
|
url += `&$filter=${encodeURIComponent(filter)}`;
|
|
166
168
|
}
|