@realtimex/email-automator 2.11.2 → 2.12.0

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.
Files changed (47) hide show
  1. package/api/src/routes/index.ts +2 -0
  2. package/api/src/routes/rulePacks.ts +204 -0
  3. package/api/src/services/RulePackService.ts +354 -0
  4. package/api/src/services/SDKService.ts +18 -7
  5. package/api/src/services/gmail.ts +68 -0
  6. package/api/src/services/intelligence.ts +160 -22
  7. package/api/src/services/microsoft.ts +99 -0
  8. package/api/src/services/processor.ts +146 -12
  9. package/api/src/services/rulePacks/developer.ts +179 -0
  10. package/api/src/services/rulePacks/executive.ts +143 -0
  11. package/api/src/services/rulePacks/index.ts +63 -0
  12. package/api/src/services/rulePacks/operations.ts +183 -0
  13. package/api/src/services/rulePacks/sales.ts +160 -0
  14. package/api/src/services/rulePacks/types.ts +116 -0
  15. package/api/src/services/rulePacks/universal.ts +83 -0
  16. package/api/src/services/supabase.ts +40 -0
  17. package/dist/api/src/routes/index.js +2 -0
  18. package/dist/api/src/routes/rulePacks.js +179 -0
  19. package/dist/api/src/services/RulePackService.js +296 -0
  20. package/dist/api/src/services/SDKService.js +18 -7
  21. package/dist/api/src/services/gmail.js +56 -0
  22. package/dist/api/src/services/intelligence.js +153 -21
  23. package/dist/api/src/services/microsoft.js +79 -0
  24. package/dist/api/src/services/processor.js +133 -12
  25. package/dist/api/src/services/rulePacks/developer.js +176 -0
  26. package/dist/api/src/services/rulePacks/executive.js +140 -0
  27. package/dist/api/src/services/rulePacks/index.js +58 -0
  28. package/dist/api/src/services/rulePacks/operations.js +180 -0
  29. package/dist/api/src/services/rulePacks/sales.js +157 -0
  30. package/dist/api/src/services/rulePacks/types.js +7 -0
  31. package/dist/api/src/services/rulePacks/universal.js +80 -0
  32. package/dist/assets/index-B5rXh3y8.css +1 -0
  33. package/dist/assets/index-B62ViZum.js +105 -0
  34. package/dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/supabase/migrations/20260131000000_add_rule_packs.sql +176 -0
  37. package/supabase/migrations/20260131000001_set_zero_config_defaults.sql +51 -0
  38. package/supabase/migrations/20260131095000_backfill_user_settings.sql +36 -0
  39. package/supabase/migrations/20260131100000_rule_templates_table.sql +154 -0
  40. package/supabase/migrations/20260131110000_auto_init_user_data.sql +90 -0
  41. package/supabase/migrations/20260131120000_backfill_universal_pack.sql +84 -0
  42. package/supabase/migrations/20260131130000_simplify_rules_with_categories.sql +87 -0
  43. package/supabase/migrations/20260131140000_fix_action_constraint.sql +11 -0
  44. package/supabase/migrations/20260131150000_fix_trigger_error_handling.sql +71 -0
  45. package/supabase/migrations/20260131160000_enable_intelligent_rename_by_default.sql +14 -0
  46. package/dist/assets/index-BuWrl4UD.js +0 -105
  47. package/dist/assets/index-CtDzSy0n.css +0 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Rule Pack Routes - Zero-Configuration UX
3
+ *
4
+ * Endpoints for managing rule pack installations and user role selection
5
+ */
6
+ import { Router } from 'express';
7
+ import { createLogger } from '../utils/logger.js';
8
+ import { RulePackService } from '../services/RulePackService.js';
9
+ import { getAllPacks, getPackById, getPacksForRole } from '../services/rulePacks/index.js';
10
+ const router = Router();
11
+ const logger = createLogger('RulePackRoutes');
12
+ /**
13
+ * GET /api/rule-packs
14
+ * List all available rule packs
15
+ */
16
+ router.get('/', async (req, res) => {
17
+ try {
18
+ const packs = getAllPacks();
19
+ res.json({
20
+ success: true,
21
+ packs: packs.map(pack => ({
22
+ id: pack.id,
23
+ name: pack.name,
24
+ description: pack.description,
25
+ icon: pack.icon,
26
+ ruleCount: pack.rules.length,
27
+ targetRoles: pack.target_roles
28
+ }))
29
+ });
30
+ }
31
+ catch (error) {
32
+ logger.error('Failed to list packs', error);
33
+ res.status(500).json({ success: false, error: error.message });
34
+ }
35
+ });
36
+ /**
37
+ * GET /api/rule-packs/:packId
38
+ * Get details of a specific pack
39
+ */
40
+ router.get('/:packId', async (req, res) => {
41
+ try {
42
+ const packId = req.params.packId;
43
+ const pack = getPackById(packId);
44
+ if (!pack) {
45
+ return res.status(404).json({ success: false, error: 'Pack not found' });
46
+ }
47
+ res.json({ success: true, pack });
48
+ }
49
+ catch (error) {
50
+ logger.error('Failed to get pack', error);
51
+ res.status(500).json({ success: false, error: error.message });
52
+ }
53
+ });
54
+ /**
55
+ * POST /api/rule-packs/install
56
+ * Install a rule pack for the authenticated user
57
+ * Body: { packId: string }
58
+ */
59
+ router.post('/install', async (req, res) => {
60
+ try {
61
+ const { packId } = req.body;
62
+ const supabase = req.supabase;
63
+ const userId = req.userId;
64
+ if (!packId) {
65
+ return res.status(400).json({ success: false, error: 'packId is required' });
66
+ }
67
+ if (!userId) {
68
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
69
+ }
70
+ const service = new RulePackService(supabase);
71
+ const result = await service.installPack(userId, packId);
72
+ res.json(result);
73
+ }
74
+ catch (error) {
75
+ logger.error('Failed to install pack', error);
76
+ res.status(500).json({ success: false, error: error.message });
77
+ }
78
+ });
79
+ /**
80
+ * POST /api/rule-packs/uninstall
81
+ * Uninstall a rule pack
82
+ * Body: { packId: string }
83
+ */
84
+ router.post('/uninstall', async (req, res) => {
85
+ try {
86
+ const { packId } = req.body;
87
+ const supabase = req.supabase;
88
+ const userId = req.userId;
89
+ if (!packId) {
90
+ return res.status(400).json({ success: false, error: 'packId is required' });
91
+ }
92
+ if (!userId) {
93
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
94
+ }
95
+ const service = new RulePackService(supabase);
96
+ const result = await service.uninstallPack(userId, packId);
97
+ res.json(result);
98
+ }
99
+ catch (error) {
100
+ logger.error('Failed to uninstall pack', error);
101
+ res.status(500).json({ success: false, error: error.message });
102
+ }
103
+ });
104
+ /**
105
+ * GET /api/rule-packs/installed
106
+ * Get user's installed packs
107
+ */
108
+ router.get('/installed', async (req, res) => {
109
+ try {
110
+ const supabase = req.supabase;
111
+ const userId = req.userId;
112
+ if (!userId) {
113
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
114
+ }
115
+ const service = new RulePackService(supabase);
116
+ const installations = await service.getInstalledPacks(userId);
117
+ res.json({ success: true, installations });
118
+ }
119
+ catch (error) {
120
+ logger.error('Failed to get installed packs', error);
121
+ res.status(500).json({ success: false, error: error.message });
122
+ }
123
+ });
124
+ /**
125
+ * POST /api/rule-packs/set-role
126
+ * Set user role and install appropriate packs
127
+ * Body: { role: UserRole }
128
+ */
129
+ router.post('/set-role', async (req, res) => {
130
+ try {
131
+ const { role } = req.body;
132
+ const supabase = req.supabase;
133
+ const userId = req.userId;
134
+ if (!role) {
135
+ return res.status(400).json({ success: false, error: 'role is required' });
136
+ }
137
+ if (!userId) {
138
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
139
+ }
140
+ const validRoles = ['executive', 'developer', 'sales', 'operations', 'marketing', 'other'];
141
+ if (!validRoles.includes(role)) {
142
+ return res.status(400).json({ success: false, error: 'Invalid role' });
143
+ }
144
+ const service = new RulePackService(supabase);
145
+ const result = await service.updateUserRole(userId, role, 'onboarding');
146
+ logger.info(`User ${userId} set role to ${role}, installed ${result.packsInstalled.length} packs`);
147
+ res.json(result);
148
+ }
149
+ catch (error) {
150
+ logger.error('Failed to set user role', error);
151
+ res.status(500).json({ success: false, error: error.message });
152
+ }
153
+ });
154
+ /**
155
+ * GET /api/rule-packs/for-role/:role
156
+ * Get recommended packs for a specific role
157
+ */
158
+ router.get('/for-role/:role', async (req, res) => {
159
+ try {
160
+ const { role } = req.params;
161
+ const packs = getPacksForRole(role);
162
+ res.json({
163
+ success: true,
164
+ role,
165
+ packs: packs.map(pack => ({
166
+ id: pack.id,
167
+ name: pack.name,
168
+ description: pack.description,
169
+ icon: pack.icon,
170
+ ruleCount: pack.rules.length
171
+ }))
172
+ });
173
+ }
174
+ catch (error) {
175
+ logger.error('Failed to get packs for role', error);
176
+ res.status(500).json({ success: false, error: error.message });
177
+ }
178
+ });
179
+ export default router;
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Rule Pack Service - Zero-Configuration Email Automation
3
+ *
4
+ * Handles installation, management, and tracking of rule packs for users.
5
+ */
6
+ import { createLogger } from '../utils/logger.js';
7
+ import { getPackById, getPacksForRole } from './rulePacks/index.js';
8
+ const logger = createLogger('RulePackService');
9
+ export class RulePackService {
10
+ supabase;
11
+ constructor(supabase) {
12
+ this.supabase = supabase;
13
+ }
14
+ /**
15
+ * Install a rule pack for a user
16
+ * Creates all rules from the pack and tracks installation
17
+ */
18
+ async installPack(userId, packId) {
19
+ try {
20
+ const pack = getPackById(packId);
21
+ if (!pack) {
22
+ return { success: false, rulesCreated: 0, error: `Pack '${packId}' not found` };
23
+ }
24
+ logger.info(`Installing pack '${packId}' for user ${userId}`);
25
+ // Check if pack is already installed
26
+ const { data: existing } = await this.supabase
27
+ .from('pack_installations')
28
+ .select('*')
29
+ .eq('user_id', userId)
30
+ .eq('pack_id', packId)
31
+ .is('uninstalled_at', null)
32
+ .single();
33
+ if (existing) {
34
+ logger.info(`Pack '${packId}' already installed for user ${userId}`);
35
+ return { success: true, rulesCreated: 0 }; // Already installed, no-op
36
+ }
37
+ // Create all rules from the pack
38
+ let rulesCreated = 0;
39
+ for (const template of pack.rules) {
40
+ // Check if rule already exists (by rule_template_id)
41
+ const { data: existingRule } = await this.supabase
42
+ .from('rules')
43
+ .select('id')
44
+ .eq('user_id', userId)
45
+ .eq('rule_template_id', template.id)
46
+ .single();
47
+ if (existingRule) {
48
+ logger.debug(`Rule '${template.id}' already exists, skipping`);
49
+ continue;
50
+ }
51
+ // Create rule from template
52
+ const { error: ruleError } = await this.supabase
53
+ .from('rules')
54
+ .insert({
55
+ user_id: userId,
56
+ name: template.name,
57
+ description: template.description,
58
+ intent: template.intent,
59
+ priority: template.priority,
60
+ condition: template.condition,
61
+ actions: template.actions,
62
+ instructions: template.instructions,
63
+ is_enabled: template.is_enabled_by_default,
64
+ pack: packId,
65
+ rule_template_id: template.id,
66
+ is_system_managed: true
67
+ });
68
+ if (ruleError) {
69
+ logger.error(`Failed to create rule '${template.id}'`, ruleError);
70
+ continue; // Continue with other rules even if one fails
71
+ }
72
+ rulesCreated++;
73
+ }
74
+ // Track pack installation
75
+ const { error: installError } = await this.supabase
76
+ .from('pack_installations')
77
+ .insert({
78
+ user_id: userId,
79
+ pack_id: packId,
80
+ source: 'manual'
81
+ });
82
+ if (installError) {
83
+ logger.error(`Failed to track pack installation`, installError);
84
+ // Don't fail the whole operation if tracking fails
85
+ }
86
+ logger.info(`Successfully installed pack '${packId}' with ${rulesCreated} rules`);
87
+ return { success: true, rulesCreated };
88
+ }
89
+ catch (error) {
90
+ logger.error(`Error installing pack '${packId}'`, error);
91
+ return { success: false, rulesCreated: 0, error: error.message };
92
+ }
93
+ }
94
+ /**
95
+ * Uninstall a rule pack for a user
96
+ * Disables all rules from the pack and marks it as uninstalled
97
+ */
98
+ async uninstallPack(userId, packId) {
99
+ try {
100
+ logger.info(`Uninstalling pack '${packId}' for user ${userId}`);
101
+ // Disable all rules from this pack
102
+ const { error: disableError } = await this.supabase
103
+ .from('rules')
104
+ .update({ is_enabled: false })
105
+ .eq('user_id', userId)
106
+ .eq('pack', packId);
107
+ if (disableError) {
108
+ logger.error(`Failed to disable pack rules`, disableError);
109
+ return { success: false, error: disableError.message };
110
+ }
111
+ // Mark pack as uninstalled
112
+ const { error: uninstallError } = await this.supabase
113
+ .from('pack_installations')
114
+ .update({ uninstalled_at: new Date().toISOString() })
115
+ .eq('user_id', userId)
116
+ .eq('pack_id', packId)
117
+ .is('uninstalled_at', null);
118
+ if (uninstallError) {
119
+ logger.error(`Failed to mark pack as uninstalled`, uninstallError);
120
+ return { success: false, error: uninstallError.message };
121
+ }
122
+ logger.info(`Successfully uninstalled pack '${packId}'`);
123
+ return { success: true };
124
+ }
125
+ catch (error) {
126
+ logger.error(`Error uninstalling pack '${packId}'`, error);
127
+ return { success: false, error: error.message };
128
+ }
129
+ }
130
+ /**
131
+ * Get all installed packs for a user
132
+ */
133
+ async getInstalledPacks(userId) {
134
+ const { data, error } = await this.supabase
135
+ .from('pack_installations')
136
+ .select('*')
137
+ .eq('user_id', userId)
138
+ .is('uninstalled_at', null)
139
+ .order('installed_at', { ascending: false });
140
+ if (error) {
141
+ logger.error('Failed to fetch installed packs', error);
142
+ return [];
143
+ }
144
+ return data || [];
145
+ }
146
+ /**
147
+ * Update user role and install appropriate packs
148
+ * This is the main entry point for onboarding
149
+ */
150
+ async updateUserRole(userId, role, source = 'onboarding') {
151
+ try {
152
+ logger.info(`Updating user role to '${role}' for user ${userId}`);
153
+ // 1. Update user settings
154
+ const { error: settingsError } = await this.supabase
155
+ .from('user_settings')
156
+ .update({
157
+ user_role: role,
158
+ onboarding_completed: true
159
+ })
160
+ .eq('user_id', userId);
161
+ if (settingsError) {
162
+ logger.error('Failed to update user settings', settingsError);
163
+ return { success: false, packsInstalled: [], error: settingsError.message };
164
+ }
165
+ // 2. Get packs for this role
166
+ const packs = getPacksForRole(role);
167
+ const packsInstalled = [];
168
+ // 3. Install each pack
169
+ for (const pack of packs) {
170
+ const result = await this.installPackWithSource(userId, pack.id, source);
171
+ if (result.success) {
172
+ packsInstalled.push(pack.id);
173
+ }
174
+ }
175
+ logger.info(`Successfully set up ${packsInstalled.length} packs for role '${role}'`);
176
+ return { success: true, packsInstalled };
177
+ }
178
+ catch (error) {
179
+ logger.error(`Error updating user role`, error);
180
+ return { success: false, packsInstalled: [], error: error.message };
181
+ }
182
+ }
183
+ /**
184
+ * Install a pack with a specific source tracking
185
+ */
186
+ async installPackWithSource(userId, packId, source) {
187
+ const pack = getPackById(packId);
188
+ if (!pack) {
189
+ return { success: false, rulesCreated: 0, error: `Pack '${packId}' not found` };
190
+ }
191
+ // Check if already installed
192
+ const { data: existing } = await this.supabase
193
+ .from('pack_installations')
194
+ .select('*')
195
+ .eq('user_id', userId)
196
+ .eq('pack_id', packId)
197
+ .is('uninstalled_at', null)
198
+ .single();
199
+ if (existing) {
200
+ return { success: true, rulesCreated: 0 }; // Already installed
201
+ }
202
+ // Create rules
203
+ let rulesCreated = 0;
204
+ for (const template of pack.rules) {
205
+ const { data: existingRule } = await this.supabase
206
+ .from('rules')
207
+ .select('id')
208
+ .eq('user_id', userId)
209
+ .eq('rule_template_id', template.id)
210
+ .single();
211
+ if (existingRule)
212
+ continue;
213
+ const { error } = await this.supabase
214
+ .from('rules')
215
+ .insert({
216
+ user_id: userId,
217
+ name: template.name,
218
+ description: template.description,
219
+ intent: template.intent,
220
+ priority: template.priority,
221
+ condition: template.condition,
222
+ actions: template.actions,
223
+ instructions: template.instructions,
224
+ is_enabled: template.is_enabled_by_default,
225
+ pack: packId,
226
+ rule_template_id: template.id,
227
+ is_system_managed: true
228
+ });
229
+ if (!error)
230
+ rulesCreated++;
231
+ }
232
+ // Track installation
233
+ await this.supabase
234
+ .from('pack_installations')
235
+ .insert({
236
+ user_id: userId,
237
+ pack_id: packId,
238
+ source
239
+ });
240
+ return { success: true, rulesCreated };
241
+ }
242
+ /**
243
+ * Get pack metrics (how many users have it installed, etc.)
244
+ */
245
+ async getPackMetrics(packId) {
246
+ const { data: all } = await this.supabase
247
+ .from('pack_installations')
248
+ .select('*')
249
+ .eq('pack_id', packId);
250
+ const total = all?.length || 0;
251
+ const active = all?.filter(p => !p.uninstalled_at).length || 0;
252
+ const uninstalled = all?.filter(p => p.uninstalled_at).length || 0;
253
+ return {
254
+ totalInstallations: total,
255
+ activeInstallations: active,
256
+ uninstallations: uninstalled
257
+ };
258
+ }
259
+ /**
260
+ * Auto-install universal pack for new users
261
+ * Called during signup or first sync
262
+ */
263
+ async ensureUniversalPack(userId) {
264
+ try {
265
+ // Check if user already has universal pack
266
+ const { data: existing } = await this.supabase
267
+ .from('pack_installations')
268
+ .select('*')
269
+ .eq('user_id', userId)
270
+ .eq('pack_id', 'universal')
271
+ .is('uninstalled_at', null)
272
+ .single();
273
+ if (existing) {
274
+ return { success: true, installed: false }; // Already has it
275
+ }
276
+ // Install universal pack
277
+ const result = await this.installPackWithSource(userId, 'universal', 'auto');
278
+ logger.info(`Auto-installed Universal Pack for new user ${userId}`);
279
+ return { success: result.success, installed: true };
280
+ }
281
+ catch (error) {
282
+ logger.error('Failed to ensure universal pack', error);
283
+ return { success: false, installed: false };
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Get singleton instance (convenience)
289
+ */
290
+ let defaultInstance = null;
291
+ export function getRulePackService(supabase) {
292
+ if (!defaultInstance) {
293
+ defaultInstance = new RulePackService(supabase);
294
+ }
295
+ return defaultInstance;
296
+ }
@@ -133,9 +133,10 @@ export class SDKService {
133
133
  const preferredModel = preferredProvider.models.find(m => m.id === this.DEFAULT_LLM_MODEL) || preferredProvider.models[0];
134
134
  this.defaultChatProvider = {
135
135
  provider: preferredProvider.provider,
136
- model: preferredModel.id
136
+ model: preferredModel.id,
137
+ isDefaultFallback: false
137
138
  };
138
- logger.info(`Using preferred default chat provider: ${this.defaultChatProvider.provider}/${this.defaultChatProvider.model}`);
139
+ logger.info(`Using preferred chat provider: ${this.defaultChatProvider.provider}/${this.defaultChatProvider.model}`);
139
140
  return this.defaultChatProvider;
140
141
  }
141
142
  // 2. Fallback to the first provider with available models
@@ -143,7 +144,8 @@ export class SDKService {
143
144
  if (p.models && p.models.length > 0) {
144
145
  this.defaultChatProvider = {
145
146
  provider: p.provider,
146
- model: p.models[0].id
147
+ model: p.models[0].id,
148
+ isDefaultFallback: true
147
149
  };
148
150
  logger.info(`Defaulting to first available chat provider: ${this.defaultChatProvider.provider}/${this.defaultChatProvider.model}`);
149
151
  return this.defaultChatProvider;
@@ -183,9 +185,10 @@ export class SDKService {
183
185
  if (p.models && p.models.length > 0) {
184
186
  this.defaultEmbedProvider = {
185
187
  provider: p.provider,
186
- model: p.models[0].id
188
+ model: p.models[0].id,
189
+ isDefaultFallback: true
187
190
  };
188
- logger.info(`Default embed provider: ${this.defaultEmbedProvider.provider}/${this.defaultEmbedProvider.model}`);
191
+ logger.info(`Selected embed provider: ${this.defaultEmbedProvider.provider}/${this.defaultEmbedProvider.model}`);
189
192
  return this.defaultEmbedProvider;
190
193
  }
191
194
  }
@@ -206,7 +209,11 @@ export class SDKService {
206
209
  static async resolveChatProvider(settings) {
207
210
  // If both provider and model are set in settings, use them
208
211
  if (settings.llm_provider && settings.llm_model) {
209
- return { provider: settings.llm_provider, model: settings.llm_model };
212
+ return {
213
+ provider: settings.llm_provider,
214
+ model: settings.llm_model,
215
+ isDefaultFallback: false
216
+ };
210
217
  }
211
218
  // Try to get from SDK discovery first
212
219
  return await this.getDefaultChatProvider();
@@ -217,7 +224,11 @@ export class SDKService {
217
224
  static async resolveEmbedProvider(settings) {
218
225
  // If both provider and model are set in settings, use them
219
226
  if (settings.embedding_provider && settings.embedding_model) {
220
- return { provider: settings.embedding_provider, model: settings.embedding_model };
227
+ return {
228
+ provider: settings.embedding_provider,
229
+ model: settings.embedding_model,
230
+ isDefaultFallback: false
231
+ };
221
232
  }
222
233
  // Try to get from SDK discovery first
223
234
  return await this.getDefaultEmbedProvider();
@@ -333,6 +333,62 @@ export class GmailService {
333
333
  await this.addLabel(account, messageId, ['STARRED']);
334
334
  logger.debug('Message starred', { messageId });
335
335
  }
336
+ /**
337
+ * Get or create a label by name (supports nested labels like "Finance/Receipts")
338
+ * Returns the label ID
339
+ */
340
+ async getOrCreateLabel(account, labelPath) {
341
+ const gmail = await this.getAuthenticatedClient(account);
342
+ // List existing labels
343
+ const { data: labelsData } = await gmail.users.labels.list({ userId: 'me' });
344
+ const existingLabels = labelsData.labels || [];
345
+ // Check if label already exists
346
+ const existingLabel = existingLabels.find(l => l.name === labelPath);
347
+ if (existingLabel?.id) {
348
+ logger.debug('Label already exists', { labelPath, labelId: existingLabel.id });
349
+ return existingLabel.id;
350
+ }
351
+ // Create nested labels if needed (e.g., "Finance/Receipts" creates "Finance" then "Finance/Receipts")
352
+ const parts = labelPath.split('/');
353
+ let currentPath = '';
354
+ let parentId;
355
+ for (const part of parts) {
356
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
357
+ // Check if this level exists
358
+ const existing = existingLabels.find(l => l.name === currentPath);
359
+ if (existing?.id) {
360
+ parentId = existing.id;
361
+ continue;
362
+ }
363
+ // Create this level
364
+ const { data: newLabel } = await gmail.users.labels.create({
365
+ userId: 'me',
366
+ requestBody: {
367
+ name: currentPath,
368
+ labelListVisibility: 'labelShow',
369
+ messageListVisibility: 'show'
370
+ }
371
+ });
372
+ if (!newLabel.id) {
373
+ throw new Error(`Failed to create label: ${currentPath}`);
374
+ }
375
+ logger.info('Created label', { labelPath: currentPath, labelId: newLabel.id });
376
+ parentId = newLabel.id;
377
+ }
378
+ if (!parentId) {
379
+ throw new Error(`Failed to get or create label: ${labelPath}`);
380
+ }
381
+ return parentId;
382
+ }
383
+ /**
384
+ * Apply a label to a message by label name (creates label if needed)
385
+ * Supports nested labels like "Finance/Receipts"
386
+ */
387
+ async applyLabelByName(account, messageId, labelName) {
388
+ const labelId = await this.getOrCreateLabel(account, labelName);
389
+ await this.addLabel(account, messageId, [labelId]);
390
+ logger.debug('Applied label to message', { messageId, labelName, labelId });
391
+ }
336
392
  async fetchAttachment(supabase, path) {
337
393
  const { data, error } = await supabase.storage
338
394
  .from('rule-attachments')