fusesell 1.2.8__py3-none-any.whl → 1.3.0__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.

Potentially problematic release.


This version of fusesell might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusesell
3
- Version: 1.2.8
3
+ Version: 1.3.0
4
4
  Summary: Local implementation of FuseSell AI sales automation pipeline
5
5
  Author-email: FuseSell Team <team@fusesell.ai>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  fusesell.py,sha256=t5PjkhWEJGINp4k517u0EX0ge7lzuHOUHHro-BE1mGk,596
2
- fusesell-1.2.8.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
- fusesell_local/__init__.py,sha256=ORlqpW5kTRov-Xw9f3Bk0L7wy1Q5bxTdaZAT9fIhLcQ,966
2
+ fusesell-1.3.0.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
+ fusesell_local/__init__.py,sha256=7shpYv9KVMmtCmwRUUrJOIid2cvvUuLpZwnbNNk4TS8,967
4
4
  fusesell_local/api.py,sha256=AcPune5YJdgi7nsMeusCUqc49z5UiycsQb6n3yiV_No,10839
5
5
  fusesell_local/cli.py,sha256=MYnVxuEf5KTR4VcO3sc-VtP9NkWlSixJsYfOWST2Ds0,65859
6
6
  fusesell_local/pipeline.py,sha256=RMF_kgwNEc1ka8-CDJyzIOTSo8PGtR_zPKAgRevhlNo,39913
@@ -12,7 +12,7 @@ fusesell_local/stages/base_stage.py,sha256=ldo5xuHZto7ceEg3i_3rxAx0xPccK4n2jaxEJ
12
12
  fusesell_local/stages/data_acquisition.py,sha256=Td3mwakJRoEYbi3od4v2ZzKOHLgLSgccZVxH3ezs1_4,71081
13
13
  fusesell_local/stages/data_preparation.py,sha256=XWLg9b1w2NrMxLcrWDqB95mRmLQmVIMXpKNaBNr98TQ,52751
14
14
  fusesell_local/stages/follow_up.py,sha256=H9Xek6EoIbHrerQvGTRswXDNFH6zq71DcRxxj0zpo2g,77747
15
- fusesell_local/stages/initial_outreach.py,sha256=0JJMRtfvy1F83Z5anLm8b0634q4JXoFi__IxginPURw,120643
15
+ fusesell_local/stages/initial_outreach.py,sha256=hvt0tpJQGDX5-k3pb2SR54IOnhQv2XyYXYtiVVECmts,131850
16
16
  fusesell_local/stages/lead_scoring.py,sha256=ir3l849eMGrGLf0OYUcmA1F3FwyYhAplS4niU3R2GRY,60658
17
17
  fusesell_local/tests/conftest.py,sha256=TWUtlP6cNPVOYkTPz-j9BzS_KnXdPWy8D-ObPLHvXYs,366
18
18
  fusesell_local/tests/test_api.py,sha256=763rUVb5pAuAQOovug6Ka0T9eGK8-WVOC_J08M7TETo,1827
@@ -22,14 +22,14 @@ fusesell_local/tests/test_data_manager_sales_process.py,sha256=NbwxQ9oBKCZfrkRQY
22
22
  fusesell_local/tests/test_data_manager_teams.py,sha256=kjk4V4r9ja4EVREIiQMxkuZd470SSwRHJAvpHln9KO4,4578
23
23
  fusesell_local/utils/__init__.py,sha256=TVemlo0wpckhNUxP3a1Tky3ekswy8JdIHaXBlkKXKBQ,330
24
24
  fusesell_local/utils/birthday_email_manager.py,sha256=NKLoUyzPedyhewZPma21SOoU8p9wPquehloer7TRA9U,20478
25
- fusesell_local/utils/data_manager.py,sha256=60CLOVkVB76AQx1wQyja0PFmA1t-YJITiGNni14IPOs,188449
26
- fusesell_local/utils/event_scheduler.py,sha256=YwWIdkvRdWFdDLX-sepI5AXJOhEIullIclpk9njvZAA,38577
25
+ fusesell_local/utils/data_manager.py,sha256=FHW9nvLXDgf-HYNFwxZlegZp0OgB3altszW6INIgyLM,188910
26
+ fusesell_local/utils/event_scheduler.py,sha256=TDk1v19cNgLhn2aJriQfpvZnwBcRpOWyHLDvkefW110,39834
27
27
  fusesell_local/utils/llm_client.py,sha256=FVc25UlGt6hro7h5Iw7PHSXY3E3_67Xc-SUbHuMSRs0,10437
28
28
  fusesell_local/utils/logger.py,sha256=sWlV8Tjyz_Z8J4zXKOnNalh8_iD6ytfrwPZpD-wcEOs,6259
29
29
  fusesell_local/utils/timezone_detector.py,sha256=0cAE4c8ZXqCA8AvxRKm6PrFKmAmsbq3HOHR6w-mW3KQ,39997
30
30
  fusesell_local/utils/validators.py,sha256=Z1VzeoxFsnuzlIA_ZaMWoy-0Cgyqupd47kIdljlMDbs,15438
31
- fusesell-1.2.8.dist-info/METADATA,sha256=9r91NKwHYUIJ-FiYGr7JoNsWe5dDLMd0JO6Nf8MlXW0,35074
32
- fusesell-1.2.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- fusesell-1.2.8.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
- fusesell-1.2.8.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
- fusesell-1.2.8.dist-info/RECORD,,
31
+ fusesell-1.3.0.dist-info/METADATA,sha256=cBJe2l9s_vsCApaTmAtH_tOjdq9berqTED9CGOtSoEM,35074
32
+ fusesell-1.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
+ fusesell-1.3.0.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
+ fusesell-1.3.0.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
+ fusesell-1.3.0.dist-info/RECORD,,
@@ -32,6 +32,6 @@ __all__ = [
32
32
  "validate_config",
33
33
  ]
34
34
 
35
- __version__ = "1.2.8"
35
+ __version__ = "1.3.0"
36
36
  __author__ = "FuseSell Team"
37
37
  __description__ = "Local implementation of FuseSell AI sales automation pipeline"
@@ -7,7 +7,7 @@ import json
7
7
  import uuid
8
8
  import requests
9
9
  import re
10
- from typing import Dict, Any, List, Optional
10
+ from typing import Dict, Any, List, Optional
11
11
  from datetime import datetime
12
12
  from .base_stage import BaseStage
13
13
 
@@ -169,11 +169,17 @@ class InitialOutreachStage(BaseStage):
169
169
  raise ValueError(f"Draft not found: {selected_draft_id}")
170
170
 
171
171
  # Get customer data for context
172
- customer_data = self._get_customer_data(context)
173
- scoring_data = self._get_scoring_data(context)
174
-
175
- # Rewrite the draft based on reason
176
- rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
172
+ customer_data = self._get_customer_data(context)
173
+ scoring_data = self._get_scoring_data(context)
174
+
175
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
176
+ context.setdefault('_recipient_identity', recipient_identity)
177
+ context.setdefault('_recipient_identity', recipient_identity)
178
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
179
+ context['customer_first_name'] = recipient_identity['first_name']
180
+
181
+ # Rewrite the draft based on reason
182
+ rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
177
183
 
178
184
  # Save the rewritten draft
179
185
  saved_draft = self._save_rewritten_draft(context, rewritten_draft, selected_draft_id)
@@ -563,11 +569,11 @@ class InitialOutreachStage(BaseStage):
563
569
  'lead_scoring': input_data.get('lead_scoring', [])
564
570
  }
565
571
 
566
- def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
567
- """Get recommended product from scoring data."""
568
- try:
569
- # Try to get from analysis first
570
- analysis = scoring_data.get('analysis', {})
572
+ def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
573
+ """Get recommended product from scoring data."""
574
+ try:
575
+ # Try to get from analysis first
576
+ analysis = scoring_data.get('analysis', {})
571
577
  if 'recommended_product' in analysis:
572
578
  return analysis['recommended_product']
573
579
 
@@ -584,14 +590,73 @@ class InitialOutreachStage(BaseStage):
584
590
 
585
591
  return None
586
592
  except Exception as e:
587
- self.logger.error(f"Failed to get recommended product: {str(e)}")
588
- return None
589
-
590
- def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
591
- """
592
- Get auto interaction configuration from team settings.
593
-
594
- Args:
593
+ self.logger.error(f"Failed to get recommended product: {str(e)}")
594
+ return None
595
+
596
+ def _resolve_recipient_identity(
597
+ self,
598
+ customer_data: Dict[str, Any],
599
+ context: Dict[str, Any]
600
+ ) -> Dict[str, Optional[str]]:
601
+ """
602
+ Resolve recipient contact information and derive a safe first name.
603
+ """
604
+ input_data = context.get('input_data', {}) or {}
605
+ stage_results = context.get('stage_results', {}) or {}
606
+ data_acquisition: Dict[str, Any] = {}
607
+ if isinstance(stage_results, dict):
608
+ data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
609
+
610
+ primary_contact = dict(customer_data.get('primaryContact', {}) or {})
611
+
612
+ recipient_email = (
613
+ input_data.get('recipient_address')
614
+ or primary_contact.get('email')
615
+ or primary_contact.get('emailAddress')
616
+ or data_acquisition.get('contact_email')
617
+ or data_acquisition.get('customer_email')
618
+ or input_data.get('customer_email')
619
+ )
620
+
621
+ recipient_name = (
622
+ input_data.get('recipient_name')
623
+ or primary_contact.get('name')
624
+ or primary_contact.get('fullName')
625
+ or data_acquisition.get('contact_name')
626
+ or data_acquisition.get('customer_contact')
627
+ or input_data.get('customer_name')
628
+ )
629
+
630
+ first_name_source = (
631
+ context.get('customer_first_name')
632
+ or input_data.get('customer_first_name')
633
+ or recipient_name
634
+ )
635
+
636
+ first_name = ''
637
+ if isinstance(first_name_source, str) and first_name_source.strip():
638
+ first_name = self._extract_first_name(first_name_source.strip())
639
+ if not first_name and isinstance(recipient_name, str) and recipient_name.strip():
640
+ first_name = self._extract_first_name(recipient_name.strip())
641
+
642
+ if recipient_name and not primary_contact.get('name'):
643
+ primary_contact['name'] = recipient_name
644
+ if recipient_email and not primary_contact.get('email'):
645
+ primary_contact['email'] = recipient_email
646
+ if primary_contact and isinstance(customer_data, dict):
647
+ customer_data['primaryContact'] = primary_contact
648
+
649
+ return {
650
+ 'email': recipient_email,
651
+ 'full_name': recipient_name,
652
+ 'first_name': first_name
653
+ }
654
+
655
+ def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
656
+ """
657
+ Get auto interaction configuration from team settings.
658
+
659
+ Args:
595
660
  team_id: Team ID to get settings for
596
661
 
597
662
  Returns:
@@ -648,6 +713,10 @@ class InitialOutreachStage(BaseStage):
648
713
  try:
649
714
  input_data = context.get('input_data', {})
650
715
  rep_profile = rep_profile or {}
716
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
717
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
718
+ context['customer_first_name'] = recipient_identity['first_name']
719
+ context.setdefault('_recipient_identity', recipient_identity)
651
720
  if rep_profile:
652
721
  primary_name = rep_profile.get('name')
653
722
  if primary_name:
@@ -725,26 +794,33 @@ class InitialOutreachStage(BaseStage):
725
794
  # Select the best subject line (first one, or most relevant)
726
795
  selected_subject = subject_lines[0] if subject_lines else f"Partnership opportunity for {company_info.get('name', 'your company')}"
727
796
 
728
- draft = {
729
- 'draft_id': draft_id,
730
- 'approach': approach['name'],
731
- 'tone': approach['tone'],
732
- 'focus': approach['focus'],
733
- 'subject': selected_subject, # Single subject instead of array
734
- 'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
735
- 'email_body': email_content,
736
- 'call_to_action': self._extract_call_to_action(email_content),
737
- 'personalization_score': self._calculate_personalization_score(email_content, customer_data),
738
- 'generated_at': datetime.now().isoformat(),
739
- 'status': 'draft',
740
- 'metadata': {
741
- 'customer_company': company_info.get('name', 'Unknown'),
742
- 'contact_name': contact_info.get('name', 'Unknown'),
743
- 'recommended_product': recommended_product.get('product_name', 'Unknown'),
744
- 'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
745
- 'generation_method': 'llm_powered'
746
- }
747
- }
797
+ draft = {
798
+ 'draft_id': draft_id,
799
+ 'approach': approach['name'],
800
+ 'tone': approach['tone'],
801
+ 'focus': approach['focus'],
802
+ 'subject': selected_subject, # Single subject instead of array
803
+ 'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
804
+ 'email_body': email_content,
805
+ 'email_format': 'html',
806
+ 'recipient_email': recipient_identity.get('email'),
807
+ 'recipient_name': recipient_identity.get('full_name'),
808
+ 'customer_first_name': recipient_identity.get('first_name'),
809
+ 'call_to_action': self._extract_call_to_action(email_content),
810
+ 'personalization_score': self._calculate_personalization_score(email_content, customer_data),
811
+ 'generated_at': datetime.now().isoformat(),
812
+ 'status': 'draft',
813
+ 'metadata': {
814
+ 'customer_company': company_info.get('name', 'Unknown'),
815
+ 'contact_name': contact_info.get('name', 'Unknown'),
816
+ 'recipient_email': recipient_identity.get('email'),
817
+ 'recipient_name': recipient_identity.get('full_name'),
818
+ 'email_format': 'html',
819
+ 'recommended_product': recommended_product.get('product_name', 'Unknown'),
820
+ 'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
821
+ 'generation_method': 'llm_powered'
822
+ }
823
+ }
748
824
 
749
825
  generated_drafts.append(draft)
750
826
 
@@ -904,6 +980,7 @@ class InitialOutreachStage(BaseStage):
904
980
  first_name = name_parts[0]
905
981
  else:
906
982
  first_name = contact_name or ''
983
+ context.setdefault('customer_first_name', first_name or contact_name or '')
907
984
 
908
985
  action = input_data.get('action', 'draft_write')
909
986
  action_labels = {
@@ -1044,7 +1121,7 @@ class InitialOutreachStage(BaseStage):
1044
1121
  return rep
1045
1122
  return reps[0] if reps else {}
1046
1123
 
1047
- def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any]) -> str:
1124
+ def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any], customer_first_name: str) -> str:
1048
1125
  if not html:
1049
1126
  return ''
1050
1127
 
@@ -1065,12 +1142,84 @@ class InitialOutreachStage(BaseStage):
1065
1142
  else:
1066
1143
  html = html.replace(placeholder, '')
1067
1144
 
1068
- # Remove any lingering placeholder fragments such as "[Your LinkedIn Profile"
1069
1145
  html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
1070
- # Collapse empty paragraphs created by placeholder removal
1146
+
1147
+ if '<p' not in html.lower():
1148
+ lines = [line.strip() for line in html.splitlines() if line.strip()]
1149
+ if lines:
1150
+ html = ''.join(f'<p>{line}</p>' for line in lines)
1151
+
1152
+ html = self._deduplicate_greeting(html, customer_first_name or '')
1071
1153
  html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
1072
1154
  return html
1073
1155
 
1156
+ def _deduplicate_greeting(self, html: str, customer_first_name: str) -> str:
1157
+ paragraphs = re.findall(r'(<p.*?>.*?</p>)', html, flags=re.IGNORECASE | re.DOTALL)
1158
+ if not paragraphs:
1159
+ return html
1160
+
1161
+ greeting_seen = False
1162
+ cleaned: List[str] = []
1163
+ for para in paragraphs:
1164
+ text = self._strip_html_tags(para).strip()
1165
+ normalized_para = para
1166
+ if self._looks_like_greeting(text):
1167
+ normalized_para = self._standardize_greeting_paragraph(para, customer_first_name)
1168
+ if greeting_seen:
1169
+ continue
1170
+ greeting_seen = True
1171
+ cleaned.append(normalized_para)
1172
+
1173
+ remainder = re.sub(r'(<p.*?>.*?</p>)', '__PARA__', html, flags=re.IGNORECASE | re.DOTALL)
1174
+ rebuilt = ''
1175
+ idx = 0
1176
+ for segment in remainder.split('__PARA__'):
1177
+ rebuilt += segment
1178
+ if idx < len(cleaned):
1179
+ rebuilt += cleaned[idx]
1180
+ idx += 1
1181
+ if idx < len(cleaned):
1182
+ rebuilt += ''.join(cleaned[idx:])
1183
+ return rebuilt
1184
+
1185
+ def _looks_like_greeting(self, text: str) -> bool:
1186
+ lowered = text.lower().replace('\xa0', ' ').strip()
1187
+ return lowered.startswith(('hi ', 'hello ', 'dear '))
1188
+
1189
+ def _standardize_greeting_paragraph(self, paragraph_html: str, customer_first_name: str) -> str:
1190
+ text = self._strip_html_tags(paragraph_html).strip()
1191
+ lowered = text.lower()
1192
+ first_word = next((candidate.title() for candidate in ('dear', 'hello', 'hi') if lowered.startswith(candidate)), 'Hi')
1193
+
1194
+ if customer_first_name:
1195
+ greeting = f"{first_word} {customer_first_name},"
1196
+ else:
1197
+ greeting = f"{first_word} there,"
1198
+
1199
+ remainder = ''
1200
+ match = re.match(r' *(hi|hello|dear)\b[^,]*,(.*)', text, flags=re.IGNORECASE | re.DOTALL)
1201
+ if match:
1202
+ remainder = match.group(2).lstrip()
1203
+ elif lowered.startswith(('hi', 'hello', 'dear')):
1204
+ parts = text.split(',', 1)
1205
+ if len(parts) > 1:
1206
+ remainder = parts[1].lstrip()
1207
+ else:
1208
+ remainder = text[len(text.split(' ', 1)[0]):].lstrip()
1209
+
1210
+ if remainder:
1211
+ sanitized_text = f"{greeting} {remainder}".strip()
1212
+ else:
1213
+ sanitized_text = greeting
1214
+
1215
+ return re.sub(
1216
+ r'(<p.*?>).*?(</p>)',
1217
+ lambda m: f"{m.group(1)}{sanitized_text}{m.group(2)}",
1218
+ paragraph_html,
1219
+ count=1,
1220
+ flags=re.IGNORECASE | re.DOTALL
1221
+ )
1222
+
1074
1223
  def _extract_first_name(self, full_name: str) -> str:
1075
1224
  if not full_name:
1076
1225
  return ''
@@ -1120,18 +1269,22 @@ class InitialOutreachStage(BaseStage):
1120
1269
  self.logger.debug('Skipping prompt entry because it is not a dict: %s', entry)
1121
1270
  return None
1122
1271
 
1123
- email_body = entry.get('body') or entry.get('content') or ''
1124
- if isinstance(email_body, dict):
1125
- email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
1126
- email_body = str(email_body).strip()
1127
- if not email_body:
1128
- self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
1129
- return None
1130
-
1131
- subject = entry.get('subject')
1132
- if isinstance(subject, list):
1133
- subject = subject[0] if subject else ''
1134
- subject = str(subject).strip() if subject else ''
1272
+ email_body = entry.get('body') or entry.get('content') or ''
1273
+ if isinstance(email_body, dict):
1274
+ email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
1275
+ email_body = str(email_body).strip()
1276
+ if not email_body:
1277
+ self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
1278
+ return None
1279
+
1280
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1281
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
1282
+ context['customer_first_name'] = recipient_identity['first_name']
1283
+
1284
+ subject = entry.get('subject')
1285
+ if isinstance(subject, list):
1286
+ subject = subject[0] if subject else ''
1287
+ subject = str(subject).strip() if subject else ''
1135
1288
 
1136
1289
  subject_alternatives: List[str] = []
1137
1290
  for key in ('subject_alternatives', 'subject_variations', 'subject_variants', 'alternative_subjects', 'subjects'):
@@ -1177,16 +1330,22 @@ class InitialOutreachStage(BaseStage):
1177
1330
  message_type = entry.get('message_type') or 'Email'
1178
1331
  rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1179
1332
  staff_name = context.get('input_data', {}).get('staff_name') or self.config.get('staff_name', 'Sales Team')
1180
- email_body = self._sanitize_email_body(email_body, staff_name, rep_profile)
1181
-
1182
- metadata = {
1183
- 'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
1184
- 'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
1185
- 'recommended_product': product_name or 'Unknown',
1186
- 'generation_method': 'prompt_template',
1187
- 'tags': tags,
1188
- 'message_type': message_type
1189
- }
1333
+ first_name = recipient_identity.get('first_name') or context.get('customer_first_name') or context.get('input_data', {}).get('customer_name') or ''
1334
+ email_body = self._sanitize_email_body(email_body, staff_name, rep_profile, first_name)
1335
+ if '<html' not in email_body.lower():
1336
+ email_body = f"<html><body>{email_body}</body></html>"
1337
+
1338
+ metadata = {
1339
+ 'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
1340
+ 'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
1341
+ 'recipient_email': recipient_identity.get('email'),
1342
+ 'recipient_name': recipient_identity.get('full_name'),
1343
+ 'email_format': 'html',
1344
+ 'recommended_product': product_name or 'Unknown',
1345
+ 'generation_method': 'prompt_template',
1346
+ 'tags': tags,
1347
+ 'message_type': message_type
1348
+ }
1190
1349
 
1191
1350
  draft_id = entry.get('draft_id') or f"uuid:{str(uuid.uuid4())}"
1192
1351
  draft_approach = "prompt"
@@ -1196,15 +1355,19 @@ class InitialOutreachStage(BaseStage):
1196
1355
  'draft_id': draft_id,
1197
1356
  'approach': approach,
1198
1357
  'tone': mail_tone,
1199
- 'focus': focus,
1200
- 'subject': subject,
1201
- 'subject_alternatives': subject_alternatives,
1202
- 'email_body': email_body,
1203
- 'call_to_action': call_to_action,
1204
- 'product_mention': product_mention,
1205
- 'product_name': product_name,
1206
- 'priority_order': priority_order if priority_order is not None else 0,
1207
- 'personalization_score': personalization_score,
1358
+ 'focus': focus,
1359
+ 'subject': subject,
1360
+ 'subject_alternatives': subject_alternatives,
1361
+ 'email_body': email_body,
1362
+ 'email_format': 'html',
1363
+ 'recipient_email': recipient_identity.get('email'),
1364
+ 'recipient_name': recipient_identity.get('full_name'),
1365
+ 'customer_first_name': recipient_identity.get('first_name'),
1366
+ 'call_to_action': call_to_action,
1367
+ 'product_mention': product_mention,
1368
+ 'product_name': product_name,
1369
+ 'priority_order': priority_order if priority_order is not None else 0,
1370
+ 'personalization_score': personalization_score,
1208
1371
  'generated_at': datetime.now().isoformat(),
1209
1372
  'status': 'draft',
1210
1373
  'metadata': metadata
@@ -1258,7 +1421,7 @@ class InitialOutreachStage(BaseStage):
1258
1421
  )
1259
1422
 
1260
1423
  # Clean and validate the generated content
1261
- cleaned_content = self._clean_email_content(email_content)
1424
+ cleaned_content = self._clean_email_content(email_content, context)
1262
1425
 
1263
1426
  return cleaned_content
1264
1427
 
@@ -1369,35 +1532,61 @@ Generate 4 subject lines, one per line, no numbering or bullets:"""
1369
1532
  f"5-minute chat about {company_name}?"
1370
1533
  ]
1371
1534
 
1372
- def _clean_email_content(self, raw_content: str) -> str:
1373
- """Clean and validate generated email content."""
1374
- # Remove any unwanted prefixes or suffixes
1375
- content = raw_content.strip()
1376
-
1377
- # Remove common LLM artifacts
1378
- artifacts_to_remove = [
1379
- "Here's the email:",
1380
- "Here is the email:",
1381
- "Email content:",
1382
- "Generated email:",
1383
- "Subject:",
1384
- "Email:"
1385
- ]
1386
-
1387
- for artifact in artifacts_to_remove:
1388
- if content.startswith(artifact):
1389
- content = content[len(artifact):].strip()
1390
-
1391
- # Ensure proper email structure
1392
- if not content.startswith(('Dear', 'Hi', 'Hello', 'Greetings')):
1393
- # Add a greeting if missing
1394
- content = f"Dear Valued Customer,\n\n{content}"
1395
-
1396
- # Ensure proper closing
1397
- if not any(closing in content.lower() for closing in ['best regards', 'sincerely', 'best', 'thanks']):
1398
- content += "\n\nBest regards"
1399
-
1400
- return content
1535
+ def _clean_email_content(
1536
+ self,
1537
+ raw_content: str,
1538
+ context: Optional[Dict[str, Any]] = None
1539
+ ) -> str:
1540
+ """
1541
+ Clean and normalize generated email content, returning HTML.
1542
+ """
1543
+ content = (raw_content or "").replace("\r\n", "\n").strip()
1544
+
1545
+ artifacts_to_remove = (
1546
+ "Here's the email:",
1547
+ "Here is the email:",
1548
+ "Email content:",
1549
+ "Generated email:",
1550
+ "Subject:",
1551
+ "Email:"
1552
+ )
1553
+
1554
+ for artifact in artifacts_to_remove:
1555
+ if content.startswith(artifact):
1556
+ content = content[len(artifact):].strip()
1557
+
1558
+ lines = [line.strip() for line in content.split('\n') if line.strip()]
1559
+ content = '\n\n'.join(lines)
1560
+
1561
+ if not content:
1562
+ return "<html><body></body></html>"
1563
+
1564
+ if not content.lower().startswith(('dear ', 'hi ', 'hello ', 'greetings')):
1565
+ content = f"Dear Valued Customer,\n\n{content}"
1566
+
1567
+ closings = ('best regards', 'sincerely', 'thanks', 'thank you', 'kind regards')
1568
+ if not any(closing in content.lower() for closing in closings):
1569
+ content += "\n\nBest regards,\n[Your Name]"
1570
+
1571
+ ctx = context or {}
1572
+ input_data = ctx.get('input_data', {}) or {}
1573
+ rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1574
+ staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
1575
+ identity = ctx.get('_recipient_identity') or {}
1576
+ customer_first_name = (
1577
+ identity.get('first_name')
1578
+ or ctx.get('customer_first_name')
1579
+ or input_data.get('customer_first_name')
1580
+ or input_data.get('recipient_name')
1581
+ or input_data.get('customer_name')
1582
+ or ''
1583
+ )
1584
+
1585
+ sanitized = self._sanitize_email_body(content, staff_name, rep_profile, customer_first_name)
1586
+ if '<html' not in sanitized.lower():
1587
+ sanitized = f"<html><body>{sanitized}</body></html>"
1588
+
1589
+ return sanitized
1401
1590
 
1402
1591
  def _extract_call_to_action(self, email_content: str) -> str:
1403
1592
  """Extract the main call-to-action from email content."""
@@ -1489,55 +1678,85 @@ Best regards,
1489
1678
 
1490
1679
  def _generate_fallback_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1491
1680
  """Generate fallback draft when LLM generation fails."""
1492
- draft_id = f"uuid:{str(uuid.uuid4())}"
1493
- draft_approach = "fallback"
1494
- draft_type = "initial"
1495
-
1496
- return [{
1497
- 'draft_id': draft_id,
1498
- 'approach': 'fallback_template',
1499
- 'tone': 'professional',
1500
- 'focus': 'general outreach',
1501
- 'subject': self._generate_fallback_subject_lines(customer_data, recommended_product)[0],
1502
- 'subject_alternatives': self._generate_fallback_subject_lines(customer_data, recommended_product)[1:],
1503
- 'email_body': self._generate_template_email(customer_data, recommended_product, {'tone': 'professional'}, context),
1504
- 'call_to_action': 'Would you be interested in a brief call?',
1505
- 'personalization_score': 50,
1506
- 'generated_at': datetime.now().isoformat(),
1507
- 'status': 'draft',
1508
- 'metadata': {
1509
- 'generation_method': 'template_fallback',
1510
- 'note': 'Generated using template due to LLM failure'
1511
- }
1512
- }]
1681
+ draft_id = f"uuid:{str(uuid.uuid4())}"
1682
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1683
+ context.setdefault('_recipient_identity', recipient_identity)
1684
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
1685
+ context['customer_first_name'] = recipient_identity['first_name']
1686
+
1687
+ template_body = self._generate_template_email(
1688
+ customer_data,
1689
+ recommended_product,
1690
+ {'tone': 'professional'},
1691
+ context
1692
+ )
1693
+ email_body = self._clean_email_content(template_body, context)
1694
+
1695
+ fallback_subjects = self._generate_fallback_subject_lines(customer_data, recommended_product)
1696
+
1697
+ return [{
1698
+ 'draft_id': draft_id,
1699
+ 'approach': 'fallback_template',
1700
+ 'tone': 'professional',
1701
+ 'focus': 'general outreach',
1702
+ 'subject': fallback_subjects[0],
1703
+ 'subject_alternatives': fallback_subjects[1:],
1704
+ 'email_body': email_body,
1705
+ 'email_format': 'html',
1706
+ 'recipient_email': recipient_identity.get('email'),
1707
+ 'recipient_name': recipient_identity.get('full_name'),
1708
+ 'customer_first_name': recipient_identity.get('first_name'),
1709
+ 'call_to_action': 'Would you be interested in a brief call?',
1710
+ 'personalization_score': 50,
1711
+ 'generated_at': datetime.now().isoformat(),
1712
+ 'status': 'draft',
1713
+ 'metadata': {
1714
+ 'generation_method': 'template_fallback',
1715
+ 'note': 'Generated using template due to LLM failure',
1716
+ 'recipient_email': recipient_identity.get('email'),
1717
+ 'recipient_name': recipient_identity.get('full_name'),
1718
+ 'email_format': 'html'
1719
+ }
1720
+ }]
1513
1721
 
1514
1722
  def _get_mock_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1515
1723
  """Get mock email drafts for dry run."""
1516
1724
  input_data = context.get('input_data', {})
1517
1725
  company_info = customer_data.get('companyInfo', {})
1518
1726
 
1519
- return [{
1520
- 'draft_id': 'mock_draft_001',
1521
- 'approach': 'professional_direct',
1522
- 'tone': 'professional and direct',
1523
- 'focus': 'business value and ROI',
1524
- 'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
1525
- 'subject_alternatives': [
1526
- f"Quick Question About {company_info.get('name', 'Test Company')}",
1527
- f"Helping Companies Like {company_info.get('name', 'Test Company')}"
1528
- ],
1529
- 'email_body': f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
1530
-
1531
- This is a mock email that would be generated for testing purposes. In a real execution, this would contain personalized content based on the customer's company information, pain points, and our product recommendations.""",
1532
- 'call_to_action': 'Mock call to action',
1533
- 'personalization_score': 85,
1534
- 'generated_at': datetime.now().isoformat(),
1535
- 'status': 'mock',
1536
- 'metadata': {
1537
- 'generation_method': 'mock_data',
1538
- 'note': 'This is mock data for dry run testing'
1539
- }
1540
- }]
1727
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1728
+ context.setdefault('_recipient_identity', recipient_identity)
1729
+ mock_body = f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
1730
+
1731
+ This is a mock email that would be generated for testing purposes. In a real execution, this would contain personalized content based on the customer's company information, pain points, and our product recommendations."""
1732
+
1733
+ return [{
1734
+ 'draft_id': 'mock_draft_001',
1735
+ 'approach': 'professional_direct',
1736
+ 'tone': 'professional and direct',
1737
+ 'focus': 'business value and ROI',
1738
+ 'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
1739
+ 'subject_alternatives': [
1740
+ f"Quick Question About {company_info.get('name', 'Test Company')}",
1741
+ f"Helping Companies Like {company_info.get('name', 'Test Company')}"
1742
+ ],
1743
+ 'email_body': self._clean_email_content(mock_body, context),
1744
+ 'email_format': 'html',
1745
+ 'recipient_email': recipient_identity.get('email'),
1746
+ 'recipient_name': recipient_identity.get('full_name'),
1747
+ 'customer_first_name': recipient_identity.get('first_name'),
1748
+ 'call_to_action': 'Mock call to action',
1749
+ 'personalization_score': 85,
1750
+ 'generated_at': datetime.now().isoformat(),
1751
+ 'status': 'mock',
1752
+ 'metadata': {
1753
+ 'generation_method': 'mock_data',
1754
+ 'note': 'This is mock data for dry run testing',
1755
+ 'recipient_email': recipient_identity.get('email'),
1756
+ 'recipient_name': recipient_identity.get('full_name'),
1757
+ 'email_format': 'html'
1758
+ }
1759
+ }]
1541
1760
 
1542
1761
 
1543
1762
  def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
@@ -1876,16 +2095,17 @@ This is a mock email that would be generated for testing purposes. In a real exe
1876
2095
 
1877
2096
  def _rewrite_draft(self, existing_draft: Dict[str, Any], reason: str, customer_data: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
1878
2097
  """Rewrite existing draft based on reason using LLM."""
1879
- try:
1880
- if self.is_dry_run():
1881
- rewritten = existing_draft.copy()
1882
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1883
- rewritten['draft_approach'] = "rewrite"
1884
- rewritten['draft_type'] = "rewrite"
1885
- rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
1886
- rewritten['rewrite_reason'] = reason
1887
- rewritten['rewritten_at'] = datetime.now().isoformat()
1888
- return rewritten
2098
+ try:
2099
+ if self.is_dry_run():
2100
+ rewritten = existing_draft.copy()
2101
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2102
+ rewritten['draft_approach'] = "rewrite"
2103
+ rewritten['draft_type'] = "rewrite"
2104
+ rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
2105
+ rewritten['rewrite_reason'] = reason
2106
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2107
+ rewritten.setdefault('email_format', 'html')
2108
+ return rewritten
1889
2109
 
1890
2110
  input_data = context.get('input_data', {})
1891
2111
  company_info = customer_data.get('companyInfo', {})
@@ -1923,46 +2143,66 @@ Generate only the rewritten email content:"""
1923
2143
  )
1924
2144
 
1925
2145
  # Clean the rewritten content
1926
- cleaned_content = self._clean_email_content(rewritten_content)
1927
-
1928
- # Create rewritten draft object
1929
- rewritten = existing_draft.copy()
1930
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1931
- rewritten['draft_approach'] = "rewrite"
1932
- rewritten['draft_type'] = "rewrite"
1933
- rewritten['email_body'] = cleaned_content
1934
- rewritten['rewrite_reason'] = reason
1935
- rewritten['rewritten_at'] = datetime.now().isoformat()
1936
- rewritten['version'] = existing_draft.get('version', 1) + 1
1937
- rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
1938
- rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
2146
+ cleaned_content = self._clean_email_content(rewritten_content, context)
2147
+
2148
+ # Create rewritten draft object
2149
+ rewritten = existing_draft.copy()
2150
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2151
+ rewritten['draft_approach'] = "rewrite"
2152
+ rewritten['draft_type'] = "rewrite"
2153
+ rewritten['email_body'] = cleaned_content
2154
+ rewritten['rewrite_reason'] = reason
2155
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2156
+ rewritten['version'] = existing_draft.get('version', 1) + 1
2157
+ rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
2158
+ rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
2159
+ recipient_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
2160
+ if recipient_identity:
2161
+ rewritten['recipient_email'] = recipient_identity.get('email')
2162
+ rewritten['recipient_name'] = recipient_identity.get('full_name')
2163
+ rewritten['customer_first_name'] = recipient_identity.get('first_name')
2164
+ rewritten['email_format'] = 'html'
1939
2165
 
1940
2166
  # Update metadata
1941
2167
  if 'metadata' not in rewritten:
1942
2168
  rewritten['metadata'] = {}
1943
2169
  rewritten['metadata']['rewrite_history'] = rewritten['metadata'].get('rewrite_history', [])
1944
- rewritten['metadata']['rewrite_history'].append({
1945
- 'reason': reason,
1946
- 'rewritten_at': datetime.now().isoformat(),
1947
- 'original_draft_id': existing_draft.get('draft_id')
1948
- })
1949
- rewritten['metadata']['generation_method'] = 'llm_rewrite'
2170
+ rewritten['metadata']['rewrite_history'].append({
2171
+ 'reason': reason,
2172
+ 'rewritten_at': datetime.now().isoformat(),
2173
+ 'original_draft_id': existing_draft.get('draft_id')
2174
+ })
2175
+ rewritten['metadata']['generation_method'] = 'llm_rewrite'
2176
+ if recipient_identity:
2177
+ rewritten['metadata']['recipient_email'] = recipient_identity.get('email')
2178
+ rewritten['metadata']['recipient_name'] = recipient_identity.get('full_name')
2179
+ rewritten['metadata']['email_format'] = 'html'
1950
2180
 
1951
2181
  self.logger.info(f"Successfully rewrote draft based on reason: {reason}")
1952
2182
  return rewritten
1953
2183
 
1954
- except Exception as e:
1955
- self.logger.error(f"Failed to rewrite draft: {str(e)}")
1956
- # Fallback to simple modification
1957
- rewritten = existing_draft.copy()
1958
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1959
- rewritten['draft_approach'] = "rewrite"
1960
- rewritten['draft_type'] = "rewrite"
1961
- rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
1962
- rewritten['rewrite_reason'] = reason
1963
- rewritten['rewritten_at'] = datetime.now().isoformat()
1964
- rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
1965
- return rewritten
2184
+ except Exception as e:
2185
+ self.logger.error(f"Failed to rewrite draft: {str(e)}")
2186
+ # Fallback to simple modification
2187
+ rewritten = existing_draft.copy()
2188
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2189
+ rewritten['draft_approach'] = "rewrite"
2190
+ rewritten['draft_type'] = "rewrite"
2191
+ rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
2192
+ rewritten['rewrite_reason'] = reason
2193
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2194
+ rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
2195
+ rewritten['email_body'] = self._clean_email_content(rewritten['email_body'], context)
2196
+ fallback_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
2197
+ if fallback_identity:
2198
+ rewritten['recipient_email'] = fallback_identity.get('email')
2199
+ rewritten['recipient_name'] = fallback_identity.get('full_name')
2200
+ rewritten['customer_first_name'] = fallback_identity.get('first_name')
2201
+ rewritten['metadata']['recipient_email'] = fallback_identity.get('email')
2202
+ rewritten['metadata']['recipient_name'] = fallback_identity.get('full_name')
2203
+ rewritten['metadata']['email_format'] = 'html'
2204
+ rewritten['email_format'] = 'html'
2205
+ return rewritten
1966
2206
 
1967
2207
  def _save_rewritten_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
1968
2208
  """Save rewritten draft to database and file system."""
@@ -552,6 +552,7 @@ class LocalDataManager:
552
552
  status TEXT NOT NULL,
553
553
  task TEXT NOT NULL,
554
554
  cron TEXT NOT NULL,
555
+ cron_ts INTEGER,
555
556
  room_id TEXT,
556
557
  tags TEXT,
557
558
  customextra TEXT,
@@ -567,6 +568,11 @@ class LocalDataManager:
567
568
  )
568
569
  """)
569
570
 
571
+ cursor.execute("PRAGMA table_info(reminder_task)")
572
+ reminder_columns = {row[1] for row in cursor.fetchall()}
573
+ if 'cron_ts' not in reminder_columns:
574
+ cursor.execute("ALTER TABLE reminder_task ADD COLUMN cron_ts INTEGER")
575
+
570
576
  # Create extracted_files table (equivalent to gs_plan_setting_extracted_file)
571
577
  cursor.execute("""
572
578
  CREATE TABLE IF NOT EXISTS extracted_files (
@@ -684,6 +690,8 @@ class LocalDataManager:
684
690
  "CREATE INDEX IF NOT EXISTS idx_reminder_task_task_id ON reminder_task(task_id)")
685
691
  cursor.execute(
686
692
  "CREATE INDEX IF NOT EXISTS idx_reminder_task_cron ON reminder_task(cron)")
693
+ cursor.execute(
694
+ "CREATE INDEX IF NOT EXISTS idx_reminder_task_cron_ts ON reminder_task(cron_ts)")
687
695
  cursor.execute(
688
696
  "CREATE INDEX IF NOT EXISTS idx_extracted_files_org_id ON extracted_files(org_id)")
689
697
  cursor.execute(
@@ -4,7 +4,7 @@ Creates scheduled events in database for external app to handle
4
4
  """
5
5
 
6
6
  import logging
7
- from datetime import datetime, timedelta
7
+ from datetime import datetime, timedelta, timezone
8
8
  from typing import Dict, Any, Optional, List, Union
9
9
  import pytz
10
10
  import json
@@ -84,6 +84,7 @@ class EventScheduler:
84
84
  status TEXT NOT NULL,
85
85
  task TEXT NOT NULL,
86
86
  cron TEXT NOT NULL,
87
+ cron_ts INTEGER,
87
88
  room_id TEXT,
88
89
  tags TEXT,
89
90
  customextra TEXT,
@@ -115,6 +116,11 @@ class EventScheduler:
115
116
  CREATE INDEX IF NOT EXISTS idx_reminder_task_cron
116
117
  ON reminder_task(cron)
117
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")
118
124
 
119
125
  conn.commit()
120
126
  conn.close()
@@ -191,6 +197,19 @@ class EventScheduler:
191
197
  except ValueError:
192
198
  return value_str
193
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
+
194
213
  def _build_reminder_payload(
195
214
  self,
196
215
  base_context: Dict[str, Any],
@@ -296,11 +315,13 @@ class EventScheduler:
296
315
 
297
316
  cron_value = self._format_datetime(cron_value or send_time)
298
317
  scheduled_time_str = self._format_datetime(scheduled_time_value or send_time)
318
+ cron_ts = self._to_unix_timestamp(cron_value)
299
319
 
300
320
  return {
301
321
  'status': status,
302
322
  'task': task_label,
303
323
  'cron': cron_value,
324
+ 'cron_ts': cron_ts,
304
325
  'room_id': room_id,
305
326
  'tags': tags,
306
327
  'customextra': customextra,
@@ -346,15 +367,20 @@ class EventScheduler:
346
367
  conn = sqlite3.connect(self.main_db_path)
347
368
  cursor = conn.cursor()
348
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
+
349
374
  cursor.execute("""
350
375
  INSERT INTO reminder_task
351
- (id, status, task, cron, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
352
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
376
+ (id, status, task, cron, cron_ts, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
353
378
  """, (
354
379
  reminder_id,
355
380
  payload.get('status', 'published'),
356
381
  payload.get('task') or 'FuseSell Reminder',
357
382
  self._format_datetime(payload.get('cron')),
383
+ cron_ts,
358
384
  payload.get('room_id'),
359
385
  tags_str,
360
386
  customextra_str,
@@ -455,6 +481,7 @@ class EventScheduler:
455
481
  draft_id=draft_id,
456
482
  customer_timezone=customer_timezone
457
483
  )
484
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
458
485
  reminder_task_id = self._insert_reminder_task(reminder_payload)
459
486
 
460
487
  # Log the scheduling
@@ -585,6 +612,7 @@ class EventScheduler:
585
612
  draft_id=original_draft_id,
586
613
  customer_timezone=event_data['customer_timezone']
587
614
  )
615
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
588
616
  reminder_task_id = self._insert_reminder_task(reminder_payload)
589
617
 
590
618
  self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
@@ -834,18 +862,18 @@ class EventScheduler:
834
862
 
835
863
  # Convert to list of dictionaries
836
864
  events = []
837
- for row in rows:
838
- event = dict(zip(columns, row))
839
- # Parse event_data JSON
840
- if event['event_data']:
841
- try:
842
- event['event_data'] = json.loads(event['event_data'])
843
- except json.JSONDecodeError:
844
- pass
845
- events.append(event)
846
-
847
- return events
848
-
865
+ for row in rows:
866
+ event = dict(zip(columns, row))
867
+ # Parse event_data JSON
868
+ if event['event_data']:
869
+ try:
870
+ event['event_data'] = json.loads(event['event_data'])
871
+ except json.JSONDecodeError:
872
+ pass
873
+ events.append(event)
874
+
875
+ return events
876
+
849
877
  except Exception as e:
850
878
  self.logger.error(f"Failed to get scheduled events: {str(e)}")
851
879
  return []