fusesell 1.3.42__py3-none-any.whl

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 (35) hide show
  1. fusesell-1.3.42.dist-info/METADATA +873 -0
  2. fusesell-1.3.42.dist-info/RECORD +35 -0
  3. fusesell-1.3.42.dist-info/WHEEL +5 -0
  4. fusesell-1.3.42.dist-info/entry_points.txt +2 -0
  5. fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
  6. fusesell-1.3.42.dist-info/top_level.txt +2 -0
  7. fusesell.py +20 -0
  8. fusesell_local/__init__.py +37 -0
  9. fusesell_local/api.py +343 -0
  10. fusesell_local/cli.py +1480 -0
  11. fusesell_local/config/__init__.py +11 -0
  12. fusesell_local/config/default_email_templates.json +34 -0
  13. fusesell_local/config/default_prompts.json +19 -0
  14. fusesell_local/config/default_scoring_criteria.json +154 -0
  15. fusesell_local/config/prompts.py +245 -0
  16. fusesell_local/config/settings.py +277 -0
  17. fusesell_local/pipeline.py +978 -0
  18. fusesell_local/stages/__init__.py +19 -0
  19. fusesell_local/stages/base_stage.py +603 -0
  20. fusesell_local/stages/data_acquisition.py +1820 -0
  21. fusesell_local/stages/data_preparation.py +1238 -0
  22. fusesell_local/stages/follow_up.py +1728 -0
  23. fusesell_local/stages/initial_outreach.py +2972 -0
  24. fusesell_local/stages/lead_scoring.py +1452 -0
  25. fusesell_local/utils/__init__.py +36 -0
  26. fusesell_local/utils/agent_context.py +552 -0
  27. fusesell_local/utils/auto_setup.py +361 -0
  28. fusesell_local/utils/birthday_email_manager.py +467 -0
  29. fusesell_local/utils/data_manager.py +4857 -0
  30. fusesell_local/utils/event_scheduler.py +959 -0
  31. fusesell_local/utils/llm_client.py +342 -0
  32. fusesell_local/utils/logger.py +203 -0
  33. fusesell_local/utils/output_helpers.py +2443 -0
  34. fusesell_local/utils/timezone_detector.py +914 -0
  35. fusesell_local/utils/validators.py +436 -0
@@ -0,0 +1,959 @@
1
+ """
2
+ Event Scheduler - Database-based event scheduling system
3
+ Creates scheduled events in database for external app to handle
4
+ """
5
+
6
+ import logging
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Dict, Any, Optional, List, Union
9
+ import pytz
10
+ import json
11
+ import sqlite3
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+
16
+ class EventScheduler:
17
+ """
18
+ Database-based event scheduling system.
19
+ Creates scheduled events that external apps can process.
20
+ """
21
+
22
+ def __init__(self, data_dir: str = "./fusesell_data"):
23
+ """
24
+ Initialize the event scheduler.
25
+
26
+ Args:
27
+ data_dir: Directory for data storage
28
+ """
29
+ self.data_dir = Path(data_dir)
30
+ self.data_dir.mkdir(exist_ok=True)
31
+
32
+ self.logger = logging.getLogger(__name__)
33
+
34
+ # Database path
35
+ self.main_db_path = self.data_dir / "fusesell.db"
36
+
37
+ # Initialize scheduled events database
38
+ self._initialize_scheduled_events_db()
39
+
40
+ # Initialize scheduling rules database
41
+ self._initialize_scheduling_rules_db()
42
+
43
+ def _initialize_scheduled_events_db(self):
44
+ """Initialize database table for scheduled events."""
45
+ try:
46
+ conn = sqlite3.connect(self.main_db_path)
47
+ cursor = conn.cursor()
48
+
49
+ cursor.execute("""
50
+ CREATE TABLE IF NOT EXISTS scheduled_events (
51
+ id TEXT PRIMARY KEY,
52
+ event_id TEXT UNIQUE NOT NULL,
53
+ event_type TEXT NOT NULL,
54
+ scheduled_time TIMESTAMP NOT NULL,
55
+ status TEXT DEFAULT 'pending',
56
+ org_id TEXT NOT NULL,
57
+ team_id TEXT,
58
+ draft_id TEXT,
59
+ recipient_address TEXT NOT NULL,
60
+ recipient_name TEXT,
61
+ customer_timezone TEXT,
62
+ event_data TEXT,
63
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
64
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
+ executed_at TIMESTAMP,
66
+ error_message TEXT
67
+ )
68
+ """)
69
+
70
+ # Create index for efficient querying
71
+ cursor.execute("""
72
+ CREATE INDEX IF NOT EXISTS idx_scheduled_events_time_status
73
+ ON scheduled_events(scheduled_time, status)
74
+ """)
75
+
76
+ cursor.execute("""
77
+ CREATE INDEX IF NOT EXISTS idx_scheduled_events_org_team
78
+ ON scheduled_events(org_id, team_id)
79
+ """)
80
+
81
+ cursor.execute("""
82
+ CREATE TABLE IF NOT EXISTS reminder_task (
83
+ id TEXT PRIMARY KEY,
84
+ status TEXT NOT NULL,
85
+ task TEXT NOT NULL,
86
+ cron TEXT NOT NULL,
87
+ cron_ts INTEGER,
88
+ room_id TEXT,
89
+ tags TEXT,
90
+ customextra TEXT,
91
+ org_id TEXT,
92
+ customer_id TEXT,
93
+ task_id TEXT,
94
+ import_uuid TEXT,
95
+ scheduled_time TIMESTAMP,
96
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
97
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
98
+ executed_at TIMESTAMP,
99
+ error_message TEXT
100
+ )
101
+ """)
102
+
103
+ cursor.execute("""
104
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_status
105
+ ON reminder_task(status)
106
+ """)
107
+ cursor.execute("""
108
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_org_id
109
+ ON reminder_task(org_id)
110
+ """)
111
+ cursor.execute("""
112
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_task_id
113
+ ON reminder_task(task_id)
114
+ """)
115
+ cursor.execute("""
116
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_cron
117
+ ON reminder_task(cron)
118
+ """)
119
+
120
+ cursor.execute("PRAGMA table_info(reminder_task)")
121
+ columns = {row[1] for row in cursor.fetchall()}
122
+ if 'cron_ts' not in columns:
123
+ cursor.execute("ALTER TABLE reminder_task ADD COLUMN cron_ts INTEGER")
124
+
125
+ conn.commit()
126
+ conn.close()
127
+
128
+ self.logger.info("Scheduled events database initialized")
129
+
130
+ except Exception as e:
131
+ self.logger.error(f"Failed to initialize scheduled events DB: {str(e)}")
132
+ raise
133
+
134
+ def _initialize_scheduling_rules_db(self):
135
+ """Initialize database table for scheduling rules."""
136
+ try:
137
+ conn = sqlite3.connect(self.main_db_path)
138
+ cursor = conn.cursor()
139
+
140
+ cursor.execute("""
141
+ CREATE TABLE IF NOT EXISTS scheduling_rules (
142
+ id TEXT PRIMARY KEY,
143
+ org_id TEXT NOT NULL,
144
+ team_id TEXT,
145
+ rule_name TEXT NOT NULL,
146
+ is_active BOOLEAN DEFAULT 1,
147
+ business_hours_start TEXT DEFAULT '08:00',
148
+ business_hours_end TEXT DEFAULT '20:00',
149
+ default_delay_hours INTEGER DEFAULT 2,
150
+ timezone TEXT DEFAULT 'Asia/Bangkok',
151
+ follow_up_delay_hours INTEGER DEFAULT 120,
152
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
153
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
154
+ UNIQUE(org_id, team_id, rule_name)
155
+ )
156
+ """)
157
+
158
+ # Create default rule if none exists
159
+ cursor.execute("""
160
+ INSERT OR IGNORE INTO scheduling_rules
161
+ (id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
162
+ default_delay_hours, timezone, follow_up_delay_hours)
163
+ VALUES (?, 'default', 'default', 'default_rule', '08:00', '20:00', 2, 'Asia/Bangkok', 120)
164
+ """, (f"uuid:{str(uuid.uuid4())}",))
165
+
166
+ conn.commit()
167
+ conn.close()
168
+
169
+ self.logger.info("Scheduling rules database initialized")
170
+
171
+ except Exception as e:
172
+ self.logger.error(f"Failed to initialize scheduling rules DB: {str(e)}")
173
+ raise
174
+
175
+ def _format_datetime(self, value: Union[str, datetime, None]) -> str:
176
+ """
177
+ Normalize datetime-like values to ISO 8601 strings.
178
+
179
+ Args:
180
+ value: Datetime, ISO string, or None.
181
+
182
+ Returns:
183
+ ISO 8601 formatted string.
184
+ """
185
+ if isinstance(value, datetime):
186
+ return value.replace(second=0, microsecond=0).isoformat()
187
+ if value is None:
188
+ return datetime.utcnow().replace(second=0, microsecond=0).isoformat()
189
+
190
+ value_str = str(value).strip()
191
+ if not value_str:
192
+ return datetime.utcnow().replace(second=0, microsecond=0).isoformat()
193
+
194
+ try:
195
+ parsed = datetime.fromisoformat(value_str)
196
+ return parsed.replace(second=0, microsecond=0).isoformat()
197
+ except ValueError:
198
+ return value_str
199
+
200
+ def _to_unix_timestamp(self, value: Union[str, datetime, None]) -> Optional[int]:
201
+ """
202
+ Convert a datetime-like value to a Unix timestamp (seconds).
203
+ """
204
+ iso_value = self._format_datetime(value)
205
+ try:
206
+ parsed = datetime.fromisoformat(iso_value)
207
+ except ValueError:
208
+ return None
209
+ if parsed.tzinfo is None:
210
+ parsed = parsed.replace(tzinfo=timezone.utc)
211
+ return int(parsed.timestamp())
212
+
213
+ def _build_reminder_payload(
214
+ self,
215
+ base_context: Dict[str, Any],
216
+ *,
217
+ event_id: str,
218
+ send_time: datetime,
219
+ email_type: str,
220
+ org_id: str,
221
+ recipient_address: str,
222
+ recipient_name: str,
223
+ draft_id: str,
224
+ customer_timezone: str,
225
+ follow_up_iteration: Optional[int] = None
226
+ ) -> Optional[Dict[str, Any]]:
227
+ """
228
+ Construct reminder_task payload mirroring server implementation.
229
+
230
+ Args:
231
+ base_context: Context data supplied by caller.
232
+ event_id: Scheduled event identifier.
233
+ send_time: Planned send time (UTC).
234
+ email_type: 'initial' or 'follow_up'.
235
+ org_id: Organization identifier.
236
+ recipient_address: Recipient email.
237
+ recipient_name: Recipient name.
238
+ draft_id: Draft identifier.
239
+ customer_timezone: Customer timezone.
240
+ follow_up_iteration: Optional follow-up iteration counter.
241
+
242
+ Returns:
243
+ Reminder payload dictionary or None if insufficient data.
244
+ """
245
+ if not base_context:
246
+ return None
247
+
248
+ context = dict(base_context)
249
+ customextra_raw = context.pop('customextra', {}) or {}
250
+ if isinstance(customextra_raw, str):
251
+ try:
252
+ customextra = json.loads(customextra_raw)
253
+ except (json.JSONDecodeError, TypeError):
254
+ customextra = {}
255
+ elif isinstance(customextra_raw, dict):
256
+ customextra = dict(customextra_raw)
257
+ else:
258
+ customextra = {}
259
+
260
+ status = context.pop('status', 'published') or 'published'
261
+ cron_value = context.pop('cron', None)
262
+ scheduled_time_value = context.pop('scheduled_time', None)
263
+ room_id = context.pop('room_id', context.pop('room', None))
264
+ tags = context.pop('tags', None)
265
+ task_label = context.pop('task', None)
266
+ org_id_override = context.pop('org_id', None) or org_id
267
+ customer_id = context.pop('customer_id', None) or customextra.get('customer_id')
268
+ task_id = context.pop('task_id', None) or context.pop('execution_id', None) or customextra.get('task_id')
269
+ customer_name = context.pop('customer_name', None)
270
+ language = context.pop('language', None)
271
+ team_id = context.pop('team_id', None)
272
+ team_name = context.pop('team_name', None)
273
+ staff_name = context.pop('staff_name', None)
274
+ import_uuid = context.pop('import_uuid', None) or customextra.get('import_uuid')
275
+
276
+ customextra.setdefault('reminder_content', 'draft_send' if email_type == 'initial' else 'follow_up')
277
+ customextra.setdefault('org_id', org_id_override)
278
+ customextra.setdefault('customer_id', customer_id)
279
+ customextra.setdefault('task_id', task_id)
280
+ customextra.setdefault('event_id', event_id)
281
+ customextra.setdefault('email_type', email_type)
282
+ customextra.setdefault('recipient_address', recipient_address)
283
+ customextra.setdefault('recipient_name', recipient_name)
284
+ customextra.setdefault('draft_id', draft_id)
285
+ customextra.setdefault('customer_timezone', customer_timezone)
286
+ customextra.setdefault('scheduled_time_utc', self._format_datetime(send_time))
287
+
288
+ if team_id and 'team_id' not in customextra:
289
+ customextra['team_id'] = team_id
290
+ if team_name and 'team_name' not in customextra:
291
+ customextra['team_name'] = team_name
292
+ if language and 'language' not in customextra:
293
+ customextra['language'] = language
294
+ if staff_name and 'staff_name' not in customextra:
295
+ customextra['staff_name'] = staff_name
296
+ if customer_name and 'customer_name' not in customextra:
297
+ customextra['customer_name'] = customer_name
298
+
299
+ iteration = follow_up_iteration or context.pop('current_follow_up_time', None)
300
+ if iteration is not None and 'current_follow_up_time' not in customextra:
301
+ customextra['current_follow_up_time'] = iteration
302
+
303
+ if not import_uuid:
304
+ import_uuid = f"{customextra.get('org_id', '')}_{customextra.get('customer_id', '')}_{customextra.get('task_id', '')}_{event_id}"
305
+ customextra.setdefault('import_uuid', import_uuid)
306
+
307
+ if not tags:
308
+ tags = ['fusesell', 'init-outreach' if email_type == 'initial' else 'follow-up']
309
+
310
+ if not task_label:
311
+ readable_type = "Initial Outreach" if email_type == 'initial' else "Follow-up"
312
+ identifier = customextra.get('customer_name') or customextra.get('customer_id') or customer_id or 'customer'
313
+ tracking_id = customextra.get('task_id') or task_id or draft_id
314
+ task_label = f"FuseSell {readable_type} {identifier} - {tracking_id}"
315
+
316
+ cron_value = self._format_datetime(cron_value or send_time)
317
+ scheduled_time_str = self._format_datetime(scheduled_time_value or send_time)
318
+ cron_ts = self._to_unix_timestamp(cron_value)
319
+
320
+ return {
321
+ 'status': status,
322
+ 'task': task_label,
323
+ 'cron': cron_value,
324
+ 'cron_ts': cron_ts,
325
+ 'room_id': room_id,
326
+ 'tags': tags,
327
+ 'customextra': customextra,
328
+ 'org_id': customextra.get('org_id'),
329
+ 'customer_id': customextra.get('customer_id'),
330
+ 'task_id': customextra.get('task_id'),
331
+ 'import_uuid': customextra.get('import_uuid'),
332
+ 'scheduled_time': scheduled_time_str
333
+ }
334
+
335
+ def _insert_reminder_task(self, payload: Optional[Dict[str, Any]]) -> Optional[str]:
336
+ """
337
+ Insert reminder_task record into local database.
338
+
339
+ Args:
340
+ payload: Reminder payload produced by _build_reminder_payload.
341
+
342
+ Returns:
343
+ Reminder task identifier or None on failure.
344
+ """
345
+ if not payload:
346
+ return None
347
+
348
+ try:
349
+ reminder_id = payload.get('id') or f"uuid:{str(uuid.uuid4())}"
350
+
351
+ tags_value = payload.get('tags')
352
+ if isinstance(tags_value, (list, tuple)):
353
+ tags_str = json.dumps(list(tags_value))
354
+ elif isinstance(tags_value, str):
355
+ tags_str = tags_value
356
+ else:
357
+ tags_str = json.dumps([])
358
+
359
+ customextra_value = payload.get('customextra') or {}
360
+ if isinstance(customextra_value, dict):
361
+ customextra_str = json.dumps(customextra_value)
362
+ elif isinstance(customextra_value, str):
363
+ customextra_str = customextra_value
364
+ else:
365
+ customextra_str = json.dumps({})
366
+
367
+ conn = sqlite3.connect(self.main_db_path)
368
+ cursor = conn.cursor()
369
+
370
+ cron_ts = payload.get('cron_ts')
371
+ if cron_ts is None:
372
+ cron_ts = self._to_unix_timestamp(payload.get('cron'))
373
+
374
+ cursor.execute("""
375
+ INSERT INTO reminder_task
376
+ (id, status, task, cron, cron_ts, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
378
+ """, (
379
+ reminder_id,
380
+ payload.get('status', 'published'),
381
+ payload.get('task') or 'FuseSell Reminder',
382
+ self._format_datetime(payload.get('cron')),
383
+ cron_ts,
384
+ payload.get('room_id'),
385
+ tags_str,
386
+ customextra_str,
387
+ payload.get('org_id'),
388
+ payload.get('customer_id'),
389
+ payload.get('task_id'),
390
+ payload.get('import_uuid'),
391
+ self._format_datetime(payload.get('scheduled_time'))
392
+ ))
393
+
394
+ conn.commit()
395
+ conn.close()
396
+
397
+ self.logger.debug(f"Created reminder_task record {reminder_id}")
398
+ return reminder_id
399
+
400
+ except Exception as exc:
401
+ self.logger.error(f"Failed to create reminder_task record: {str(exc)}")
402
+ return None
403
+
404
+ def schedule_email_event(self, draft_id: str, recipient_address: str, recipient_name: str,
405
+ org_id: str, team_id: str = None, customer_timezone: str = None,
406
+ email_type: str = 'initial', send_immediately: bool = False,
407
+ reminder_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
408
+ """
409
+ Schedule an email event in the database for external app to handle.
410
+
411
+ Args:
412
+ draft_id: ID of the email draft to send
413
+ recipient_address: Email address of recipient
414
+ recipient_name: Name of recipient
415
+ org_id: Organization ID
416
+ team_id: Team ID (optional)
417
+ customer_timezone: Customer's timezone (optional)
418
+ email_type: Type of email ('initial' or 'follow_up')
419
+ send_immediately: If True, schedule for immediate sending
420
+ reminder_context: Optional metadata for reminder_task mirroring server behaviour
421
+
422
+ Returns:
423
+ Event creation result with event ID and scheduled time
424
+ """
425
+ try:
426
+ # Get scheduling rule for the team
427
+ rule = self._get_scheduling_rule(org_id, team_id)
428
+
429
+ # Determine customer timezone
430
+ if not customer_timezone:
431
+ customer_timezone = rule.get('timezone', 'Asia/Bangkok')
432
+
433
+ # Calculate optimal send time
434
+ if send_immediately:
435
+ send_time = datetime.utcnow()
436
+ else:
437
+ send_time = self._calculate_send_time(rule, customer_timezone)
438
+
439
+ # Create event ID
440
+ event_id = f"uuid:{str(uuid.uuid4())}"
441
+ event_type = "email"
442
+ related_draft_id = draft_id
443
+
444
+ # Prepare event data
445
+ event_data = {
446
+ 'draft_id': draft_id,
447
+ 'email_type': email_type,
448
+ 'org_id': org_id,
449
+ 'team_id': team_id,
450
+ 'customer_timezone': customer_timezone,
451
+ 'send_immediately': send_immediately
452
+ }
453
+
454
+ # Insert scheduled event into database
455
+ conn = sqlite3.connect(self.main_db_path)
456
+ cursor = conn.cursor()
457
+
458
+ cursor.execute("""
459
+ INSERT INTO scheduled_events
460
+ (id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
461
+ recipient_address, recipient_name, customer_timezone, event_data)
462
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
463
+ """, (
464
+ f"uuid:{str(uuid.uuid4())}", event_id, 'email_send', send_time, org_id, team_id, draft_id,
465
+ recipient_address, recipient_name, customer_timezone, json.dumps(event_data)
466
+ ))
467
+
468
+ conn.commit()
469
+ conn.close()
470
+
471
+ reminder_task_id = None
472
+ if reminder_context:
473
+ reminder_payload = self._build_reminder_payload(
474
+ dict(reminder_context),
475
+ event_id=event_id,
476
+ send_time=send_time,
477
+ email_type=email_type,
478
+ org_id=org_id,
479
+ recipient_address=recipient_address,
480
+ recipient_name=recipient_name,
481
+ draft_id=draft_id,
482
+ customer_timezone=customer_timezone
483
+ )
484
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
485
+ reminder_task_id = self._insert_reminder_task(reminder_payload)
486
+
487
+ # Log the scheduling
488
+ self.logger.info(f"Scheduled email event {event_id} for {send_time} (draft: {draft_id})")
489
+
490
+ # Schedule follow-up if this is an initial email
491
+ follow_up_event_id = None
492
+ follow_up_reminder_id = None
493
+ follow_up_scheduled_time = None
494
+ if email_type == 'initial' and not send_immediately:
495
+ follow_up_context = None
496
+ if reminder_context:
497
+ follow_up_context = dict(reminder_context)
498
+ follow_up_extra = dict(follow_up_context.get('customextra', {}) or {})
499
+ follow_up_extra['reminder_content'] = 'follow_up'
500
+ follow_up_extra.setdefault('current_follow_up_time', 1)
501
+ follow_up_context['customextra'] = follow_up_extra
502
+ follow_up_context['tags'] = follow_up_context.get('tags') or ['fusesell', 'follow-up']
503
+
504
+ follow_up_result = self._schedule_follow_up_event(
505
+ draft_id,
506
+ recipient_address,
507
+ recipient_name,
508
+ org_id,
509
+ team_id,
510
+ customer_timezone,
511
+ reminder_context=follow_up_context
512
+ )
513
+
514
+ if follow_up_result.get('success'):
515
+ follow_up_event_id = follow_up_result.get('event_id')
516
+ follow_up_reminder_id = follow_up_result.get('reminder_task_id')
517
+ follow_up_scheduled_time = follow_up_result.get('scheduled_time')
518
+ else:
519
+ self.logger.warning(
520
+ "Follow-up scheduling failed for event %s: %s",
521
+ event_id,
522
+ follow_up_result.get('error', 'unknown error')
523
+ )
524
+
525
+ return {
526
+ 'success': True,
527
+ 'event_id': event_id,
528
+ 'scheduled_time': send_time.isoformat(),
529
+ 'recipient_address': recipient_address,
530
+ 'recipient_name': recipient_name,
531
+ 'draft_id': draft_id,
532
+ 'email_type': email_type,
533
+ 'reminder_task_id': reminder_task_id,
534
+ 'follow_up_event_id': follow_up_event_id,
535
+ 'follow_up_reminder_task_id': follow_up_reminder_id,
536
+ 'follow_up_scheduled_time': follow_up_scheduled_time
537
+ }
538
+
539
+ except Exception as e:
540
+ self.logger.error(f"Failed to schedule email event: {str(e)}")
541
+ return {
542
+ 'success': False,
543
+ 'error': str(e)
544
+ }
545
+
546
+ def _schedule_follow_up_event(self, original_draft_id: str, recipient_address: str,
547
+ recipient_name: str, org_id: str, team_id: str = None,
548
+ customer_timezone: str = None,
549
+ reminder_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
550
+ """
551
+ Schedule follow-up email event after initial email.
552
+
553
+ Args:
554
+ original_draft_id: ID of the original draft
555
+ recipient_address: Email address of recipient
556
+ recipient_name: Name of recipient
557
+ org_id: Organization ID
558
+ team_id: Team ID (optional)
559
+ customer_timezone: Customer's timezone (optional)
560
+ reminder_context: Optional metadata for reminder_task rows
561
+
562
+ Returns:
563
+ Follow-up event creation result
564
+ """
565
+ try:
566
+ # Get scheduling rule
567
+ rule = self._get_scheduling_rule(org_id, team_id)
568
+
569
+ # Calculate follow-up time (default: 5 days after initial send)
570
+ follow_up_delay = rule.get('follow_up_delay_hours', 120) # 120 hours = 5 days
571
+ follow_up_time = datetime.utcnow() + timedelta(hours=follow_up_delay)
572
+
573
+ # Create follow-up event ID
574
+ followup_event_id = f"uuid:{str(uuid.uuid4())}"
575
+ followup_event_type = "followup"
576
+ related_original_draft_id = original_draft_id
577
+
578
+ # Prepare event data
579
+ event_data = {
580
+ 'original_draft_id': original_draft_id,
581
+ 'email_type': 'follow_up',
582
+ 'org_id': org_id,
583
+ 'team_id': team_id,
584
+ 'customer_timezone': customer_timezone or rule.get('timezone', 'Asia/Bangkok')
585
+ }
586
+
587
+ # Insert follow-up event into database
588
+ conn = sqlite3.connect(self.main_db_path)
589
+ cursor = conn.cursor()
590
+
591
+ cursor.execute("""
592
+ INSERT INTO scheduled_events
593
+ (id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
594
+ recipient_address, recipient_name, customer_timezone, event_data)
595
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
596
+ """, (
597
+ f"uuid:{str(uuid.uuid4())}", followup_event_id, 'email_follow_up', follow_up_time, org_id, team_id,
598
+ original_draft_id, recipient_address, recipient_name,
599
+ customer_timezone, json.dumps(event_data)
600
+ ))
601
+
602
+ conn.commit()
603
+ conn.close()
604
+
605
+ reminder_task_id = None
606
+ if reminder_context:
607
+ reminder_payload = self._build_reminder_payload(
608
+ dict(reminder_context),
609
+ event_id=followup_event_id,
610
+ send_time=follow_up_time,
611
+ email_type='follow_up',
612
+ org_id=org_id,
613
+ recipient_address=recipient_address,
614
+ recipient_name=recipient_name,
615
+ draft_id=original_draft_id,
616
+ customer_timezone=event_data['customer_timezone']
617
+ )
618
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
619
+ reminder_task_id = self._insert_reminder_task(reminder_payload)
620
+
621
+ self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
622
+
623
+ return {
624
+ 'success': True,
625
+ 'event_id': followup_event_id,
626
+ 'scheduled_time': follow_up_time.isoformat(),
627
+ 'reminder_task_id': reminder_task_id
628
+ }
629
+
630
+ except Exception as e:
631
+ self.logger.error(f"Failed to schedule follow-up event: {str(e)}")
632
+ return {
633
+ 'success': False,
634
+ 'error': str(e),
635
+ 'reminder_task_id': None
636
+ }
637
+
638
+ def _get_scheduling_rule(self, org_id: str, team_id: str = None) -> Dict[str, Any]:
639
+ """
640
+ Get scheduling rule for organization/team.
641
+
642
+ Args:
643
+ org_id: Organization ID
644
+ team_id: Team ID (optional)
645
+
646
+ Returns:
647
+ Scheduling rule dictionary
648
+ """
649
+ try:
650
+ conn = sqlite3.connect(self.main_db_path)
651
+ cursor = conn.cursor()
652
+
653
+ # Try to get team-specific settings from team_settings table first
654
+ if team_id:
655
+ cursor.execute("""
656
+ SELECT gs_team_schedule_time
657
+ FROM team_settings
658
+ WHERE team_id = ?
659
+ """, (team_id,))
660
+
661
+ row = cursor.fetchone()
662
+ if row and row[0]:
663
+ try:
664
+ schedule_settings = json.loads(row[0])
665
+ if schedule_settings:
666
+ self.logger.debug(f"Using team settings for scheduling: {team_id}")
667
+ conn.close()
668
+ # Convert team settings to scheduling rule format
669
+ return {
670
+ 'business_hours_start': schedule_settings.get('business_hours_start', '08:00'),
671
+ 'business_hours_end': schedule_settings.get('business_hours_end', '20:00'),
672
+ 'default_delay_hours': schedule_settings.get('default_delay_hours', 2),
673
+ 'timezone': schedule_settings.get('timezone', 'Asia/Bangkok'),
674
+ 'follow_up_delay_hours': schedule_settings.get('follow_up_delay_hours', 120),
675
+ 'avoid_weekends': schedule_settings.get('avoid_weekends', True)
676
+ }
677
+ except (json.JSONDecodeError, TypeError) as e:
678
+ self.logger.warning(f"Failed to parse team schedule settings: {e}")
679
+
680
+ # Fall back to scheduling_rules table for team-specific rule
681
+ cursor.execute("""
682
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
683
+ timezone, follow_up_delay_hours
684
+ FROM scheduling_rules
685
+ WHERE org_id = ? AND team_id = ? AND is_active = 1
686
+ ORDER BY updated_at DESC LIMIT 1
687
+ """, (org_id, team_id))
688
+
689
+ row = cursor.fetchone()
690
+ if row:
691
+ conn.close()
692
+ return {
693
+ 'business_hours_start': row[0],
694
+ 'business_hours_end': row[1],
695
+ 'default_delay_hours': row[2],
696
+ 'timezone': row[3],
697
+ 'follow_up_delay_hours': row[4]
698
+ }
699
+
700
+ # Fall back to org-specific rule
701
+ cursor.execute("""
702
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
703
+ timezone, follow_up_delay_hours
704
+ FROM scheduling_rules
705
+ WHERE org_id = ? AND is_active = 1
706
+ ORDER BY updated_at DESC LIMIT 1
707
+ """, (org_id,))
708
+
709
+ row = cursor.fetchone()
710
+ if row:
711
+ conn.close()
712
+ return {
713
+ 'business_hours_start': row[0],
714
+ 'business_hours_end': row[1],
715
+ 'default_delay_hours': row[2],
716
+ 'timezone': row[3],
717
+ 'follow_up_delay_hours': row[4]
718
+ }
719
+
720
+ # Fall back to default rule
721
+ cursor.execute("""
722
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
723
+ timezone, follow_up_delay_hours
724
+ FROM scheduling_rules
725
+ WHERE org_id = 'default' AND is_active = 1
726
+ LIMIT 1
727
+ """)
728
+
729
+ row = cursor.fetchone()
730
+ conn.close()
731
+
732
+ if row:
733
+ return {
734
+ 'business_hours_start': row[0],
735
+ 'business_hours_end': row[1],
736
+ 'default_delay_hours': row[2],
737
+ 'timezone': row[3],
738
+ 'follow_up_delay_hours': row[4]
739
+ }
740
+
741
+ # Ultimate fallback
742
+ return {
743
+ 'business_hours_start': '08:00',
744
+ 'business_hours_end': '20:00',
745
+ 'default_delay_hours': 2,
746
+ 'timezone': 'Asia/Bangkok',
747
+ 'follow_up_delay_hours': 120
748
+ }
749
+
750
+ except Exception as e:
751
+ self.logger.error(f"Failed to get scheduling rule: {str(e)}")
752
+ # Return default rule
753
+ return {
754
+ 'business_hours_start': '08:00',
755
+ 'business_hours_end': '20:00',
756
+ 'default_delay_hours': 2,
757
+ 'timezone': 'Asia/Bangkok',
758
+ 'follow_up_delay_hours': 120
759
+ }
760
+
761
+ def _calculate_send_time(self, rule: Dict[str, Any], customer_timezone: str) -> datetime:
762
+ """
763
+ Calculate optimal send time based on scheduling rule and customer timezone.
764
+
765
+ Args:
766
+ rule: Scheduling rule dictionary
767
+ customer_timezone: Customer's timezone
768
+
769
+ Returns:
770
+ Optimal send time in UTC
771
+ """
772
+ try:
773
+ # Validate timezone
774
+ try:
775
+ customer_tz = pytz.timezone(customer_timezone)
776
+ except pytz.exceptions.UnknownTimeZoneError:
777
+ self.logger.warning(f"Unknown timezone '{customer_timezone}', using default")
778
+ customer_tz = pytz.timezone(rule.get('timezone', 'Asia/Bangkok'))
779
+ customer_timezone = rule.get('timezone', 'Asia/Bangkok')
780
+
781
+ # Get current time in customer timezone
782
+ now_customer = datetime.now(customer_tz)
783
+
784
+ # Parse business hours
785
+ start_hour, start_minute = map(int, rule['business_hours_start'].split(':'))
786
+ end_hour, end_minute = map(int, rule['business_hours_end'].split(':'))
787
+
788
+ # Calculate proposed send time (now + delay)
789
+ delay_hours = rule['default_delay_hours']
790
+ proposed_time = now_customer + timedelta(hours=delay_hours)
791
+
792
+ # Check if proposed time is within business hours
793
+ business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
794
+ business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
795
+
796
+ # Skip weekends (Saturday=5, Sunday=6)
797
+ while proposed_time.weekday() >= 5:
798
+ proposed_time += timedelta(days=1)
799
+ business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
800
+ business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
801
+
802
+ if business_start <= proposed_time <= business_end:
803
+ # Within business hours, use proposed time
804
+ send_time_customer = proposed_time
805
+ else:
806
+ # Outside business hours, schedule for next business day at start time
807
+ if proposed_time < business_start:
808
+ # Too early, schedule for today's business start
809
+ send_time_customer = business_start
810
+ else:
811
+ # Too late, schedule for tomorrow's business start
812
+ next_day = proposed_time + timedelta(days=1)
813
+ # Skip weekends
814
+ while next_day.weekday() >= 5:
815
+ next_day += timedelta(days=1)
816
+ send_time_customer = next_day.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
817
+
818
+ # Convert to UTC for storage
819
+ send_time_utc = send_time_customer.astimezone(pytz.UTC)
820
+
821
+ self.logger.info(f"Calculated send time: {send_time_customer} ({customer_timezone}) -> {send_time_utc} (UTC)")
822
+
823
+ return send_time_utc.replace(tzinfo=None) # Store as naive datetime in UTC
824
+
825
+ except Exception as e:
826
+ self.logger.error(f"Failed to calculate send time: {str(e)}")
827
+ # Fallback: 2 hours from now
828
+ return datetime.utcnow() + timedelta(hours=2)
829
+
830
+ def get_scheduled_events(self, org_id: str = None, status: str = None) -> List[Dict[str, Any]]:
831
+ """
832
+ Get list of scheduled events.
833
+
834
+ Args:
835
+ org_id: Filter by organization ID (optional)
836
+ status: Filter by status (optional)
837
+
838
+ Returns:
839
+ List of scheduled events
840
+ """
841
+ try:
842
+ conn = sqlite3.connect(self.main_db_path)
843
+ cursor = conn.cursor()
844
+
845
+ query = "SELECT * FROM scheduled_events WHERE 1=1"
846
+ params = []
847
+
848
+ if org_id:
849
+ query += " AND org_id = ?"
850
+ params.append(org_id)
851
+
852
+ if status:
853
+ query += " AND status = ?"
854
+ params.append(status)
855
+
856
+ query += " ORDER BY scheduled_time ASC"
857
+
858
+ cursor.execute(query, params)
859
+ rows = cursor.fetchall()
860
+
861
+ # Get column names
862
+ columns = [description[0] for description in cursor.description]
863
+
864
+ conn.close()
865
+
866
+ # Convert to list of dictionaries
867
+ events = []
868
+ for row in rows:
869
+ event = dict(zip(columns, row))
870
+ # Parse event_data JSON
871
+ if event['event_data']:
872
+ try:
873
+ event['event_data'] = json.loads(event['event_data'])
874
+ except json.JSONDecodeError:
875
+ pass
876
+ events.append(event)
877
+
878
+ return events
879
+
880
+ except Exception as e:
881
+ self.logger.error(f"Failed to get scheduled events: {str(e)}")
882
+ return []
883
+
884
+ def cancel_scheduled_event(self, event_id: str) -> bool:
885
+ """
886
+ Cancel a scheduled event by marking it as cancelled.
887
+
888
+ Args:
889
+ event_id: ID of the event to cancel
890
+
891
+ Returns:
892
+ True if cancelled successfully
893
+ """
894
+ try:
895
+ conn = sqlite3.connect(self.main_db_path)
896
+ cursor = conn.cursor()
897
+
898
+ cursor.execute("""
899
+ UPDATE scheduled_events
900
+ SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
901
+ WHERE event_id = ?
902
+ """, (event_id,))
903
+
904
+ conn.commit()
905
+ rows_affected = cursor.rowcount
906
+ conn.close()
907
+
908
+ if rows_affected > 0:
909
+ self.logger.info(f"Cancelled scheduled event: {event_id}")
910
+ return True
911
+ else:
912
+ self.logger.warning(f"Event not found for cancellation: {event_id}")
913
+ return False
914
+
915
+ except Exception as e:
916
+ self.logger.error(f"Failed to cancel event {event_id}: {str(e)}")
917
+ return False
918
+
919
+ def create_scheduling_rule(self, org_id: str, team_id: str = None, rule_name: str = 'default',
920
+ business_hours_start: str = '08:00', business_hours_end: str = '20:00',
921
+ default_delay_hours: int = 2, timezone: str = 'Asia/Bangkok',
922
+ follow_up_delay_hours: int = 120) -> bool:
923
+ """
924
+ Create or update a scheduling rule.
925
+
926
+ Args:
927
+ org_id: Organization ID
928
+ team_id: Team ID (optional)
929
+ rule_name: Name of the rule
930
+ business_hours_start: Business hours start time (HH:MM)
931
+ business_hours_end: Business hours end time (HH:MM)
932
+ default_delay_hours: Default delay in hours
933
+ timezone: Timezone for the rule
934
+ follow_up_delay_hours: Follow-up delay in hours
935
+
936
+ Returns:
937
+ True if created/updated successfully
938
+ """
939
+ try:
940
+ conn = sqlite3.connect(self.main_db_path)
941
+ cursor = conn.cursor()
942
+
943
+ cursor.execute("""
944
+ INSERT OR REPLACE INTO scheduling_rules
945
+ (id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
946
+ default_delay_hours, timezone, follow_up_delay_hours, updated_at)
947
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
948
+ """, (f"uuid:{str(uuid.uuid4())}", org_id, team_id, rule_name, business_hours_start, business_hours_end,
949
+ default_delay_hours, timezone, follow_up_delay_hours))
950
+
951
+ conn.commit()
952
+ conn.close()
953
+
954
+ self.logger.info(f"Created/updated scheduling rule for {org_id}/{team_id}")
955
+ return True
956
+
957
+ except Exception as e:
958
+ self.logger.error(f"Failed to create scheduling rule: {str(e)}")
959
+ return False