@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.
@@ -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
- let actionResult = { success: true, details: '' };
41
-
42
- if (action === 'none') {
43
- // Just mark as reviewed
44
- await req.supabase!
45
- .from('emails')
46
- .update({ action_taken: 'none' })
47
- .eq('id', emailId);
48
- } else if (account.provider === 'gmail') {
49
- const gmailService = getGmailService();
50
-
51
- if (action === 'delete') {
52
- await gmailService.trashMessage(account, email.external_id);
53
- } else if (action === 'archive') {
54
- await gmailService.archiveMessage(account, email.external_id);
55
- } else if (action === 'draft') {
56
- const content = draftContent || email.ai_analysis?.draft_response || '';
57
- if (content) {
58
- const draftId = await gmailService.createDraft(account, email.external_id, content);
59
- actionResult.details = `Draft created: ${draftId}`;
60
- }
61
- } else if (action === 'flag') {
62
- await gmailService.addLabel(account, email.external_id, ['STARRED']);
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
- await req.supabase!
66
- .from('emails')
67
- .update({ action_taken: action })
68
- .eq('id', emailId);
69
- } else if (account.provider === 'outlook') {
70
- const microsoftService = getMicrosoftService();
71
-
72
- if (action === 'delete') {
73
- await microsoftService.trashMessage(account, email.external_id);
74
- } else if (action === 'archive') {
75
- await microsoftService.archiveMessage(account, email.external_id);
76
- } else if (action === 'draft') {
77
- const content = draftContent || email.ai_analysis?.draft_response || '';
78
- if (content) {
79
- const draftId = await microsoftService.createDraft(account, email.external_id, content);
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
- await req.supabase!
85
- .from('emails')
86
- .update({ action_taken: action })
87
- .eq('id', emailId);
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
- logger.info('Action executed', { emailId, action, userId });
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(actionResult);
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
 
@@ -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('date', { ascending: false })
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
@@ -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, error: any, emailId?: string) {
50
- await this.log('error', state, { error: error.message || error }, emailId);
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
- let url = `https://graph.microsoft.com/v1.0/me/messages?$top=${top}&$skip=${skip}&$orderby=receivedDateTime desc&$select=id,conversationId,subject,from,toRecipients,receivedDateTime,body,bodyPreview,importance`;
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
  }