@bernierllc/email 1.0.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 (53) hide show
  1. package/.eslintrc.json +112 -0
  2. package/.flake8 +18 -0
  3. package/.github/workflows/ci.yml +300 -0
  4. package/EXTRACTION_SUMMARY.md +265 -0
  5. package/IMPLEMENTATION_STATUS.md +159 -0
  6. package/LICENSE +7 -0
  7. package/OPEN_SOURCE_SETUP.md +420 -0
  8. package/PACKAGE_USAGE.md +471 -0
  9. package/README.md +232 -0
  10. package/examples/fastapi-example/main.py +257 -0
  11. package/examples/nextjs-example/next-env.d.ts +13 -0
  12. package/examples/nextjs-example/package.json +26 -0
  13. package/examples/nextjs-example/pages/admin/templates.tsx +157 -0
  14. package/examples/nextjs-example/tsconfig.json +28 -0
  15. package/package.json +32 -0
  16. package/packages/core/package.json +70 -0
  17. package/packages/core/rollup.config.js +37 -0
  18. package/packages/core/specification.md +416 -0
  19. package/packages/core/src/adapters/supabase.ts +291 -0
  20. package/packages/core/src/core/scheduler.ts +356 -0
  21. package/packages/core/src/core/template-manager.ts +388 -0
  22. package/packages/core/src/index.ts +30 -0
  23. package/packages/core/src/providers/base.ts +104 -0
  24. package/packages/core/src/providers/sendgrid.ts +368 -0
  25. package/packages/core/src/types/provider.ts +91 -0
  26. package/packages/core/src/types/scheduled.ts +78 -0
  27. package/packages/core/src/types/template.ts +97 -0
  28. package/packages/core/tsconfig.json +23 -0
  29. package/packages/python/README.md +106 -0
  30. package/packages/python/email_template_manager/__init__.py +66 -0
  31. package/packages/python/email_template_manager/config.py +98 -0
  32. package/packages/python/email_template_manager/core/magic_links.py +245 -0
  33. package/packages/python/email_template_manager/core/manager.py +344 -0
  34. package/packages/python/email_template_manager/core/scheduler.py +473 -0
  35. package/packages/python/email_template_manager/exceptions.py +67 -0
  36. package/packages/python/email_template_manager/models/magic_link.py +59 -0
  37. package/packages/python/email_template_manager/models/scheduled.py +78 -0
  38. package/packages/python/email_template_manager/models/template.py +90 -0
  39. package/packages/python/email_template_manager/providers/aws_ses.py +44 -0
  40. package/packages/python/email_template_manager/providers/base.py +94 -0
  41. package/packages/python/email_template_manager/providers/sendgrid.py +325 -0
  42. package/packages/python/email_template_manager/providers/smtp.py +44 -0
  43. package/packages/python/pyproject.toml +133 -0
  44. package/packages/python/setup.py +93 -0
  45. package/packages/python/specification.md +930 -0
  46. package/packages/react/README.md +13 -0
  47. package/packages/react/package.json +105 -0
  48. package/packages/react/rollup.config.js +37 -0
  49. package/packages/react/specification.md +569 -0
  50. package/packages/react/src/index.ts +20 -0
  51. package/packages/react/tsconfig.json +24 -0
  52. package/src/index.js +1 -0
  53. package/test_package.py +125 -0
@@ -0,0 +1,473 @@
1
+ """
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ """
8
+
9
+ """
10
+ Email scheduling and batch processing
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import uuid
16
+ from datetime import datetime, timedelta
17
+ from typing import List, Optional
18
+
19
+ from sqlalchemy import JSON, Column, DateTime, Integer, String, Text
20
+
21
+ from ..exceptions import SchedulingError, TemplateNotFoundError
22
+ from ..models.scheduled import (
23
+ ProcessingResult,
24
+ ScheduledEmail,
25
+ SchedulingFilters,
26
+ )
27
+ from ..providers.base import EmailMessage, EmailProvider
28
+ from .manager import Base, EmailTemplateManager
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class ScheduledEmailModel(Base):
34
+ """SQLAlchemy model for scheduled emails"""
35
+
36
+ __tablename__ = "scheduled_emails"
37
+
38
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
39
+ template_id = Column(String, nullable=False)
40
+ recipient_email = Column(String, nullable=False)
41
+ recipient_name = Column(String, nullable=True)
42
+ scheduled_for = Column(DateTime, nullable=False)
43
+ trigger_type = Column(String, nullable=False)
44
+ trigger_offset = Column(Integer, nullable=True)
45
+ reference_date = Column(DateTime, nullable=True)
46
+ variables = Column(JSON, default={})
47
+ status = Column(String, default="pending")
48
+ sent_at = Column(DateTime, nullable=True)
49
+ error_message = Column(Text, nullable=True)
50
+ retry_count = Column(Integer, default=0)
51
+ max_retries = Column(Integer, default=3)
52
+ email_metadata = Column(JSON, default={})
53
+ created_at = Column(DateTime, default=datetime.utcnow)
54
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
55
+
56
+
57
+ class EmailScheduler:
58
+ """Manages email scheduling and delivery"""
59
+
60
+ def __init__(
61
+ self,
62
+ template_manager: EmailTemplateManager,
63
+ email_provider: EmailProvider,
64
+ batch_size: int = 50,
65
+ retry_delays: List[int] = None,
66
+ ):
67
+ """
68
+ Initialize email scheduler
69
+
70
+ Args:
71
+ template_manager: Template manager instance
72
+ email_provider: Email provider instance
73
+ batch_size: Number of emails to process in each batch
74
+ retry_delays: Retry delays in minutes [5, 15, 60] by default
75
+ """
76
+ self.template_manager = template_manager
77
+ self.email_provider = email_provider
78
+ self.batch_size = batch_size
79
+ self.retry_delays = retry_delays or [5, 15, 60]
80
+
81
+ # Ensure tables are created
82
+ Base.metadata.create_all(self.template_manager.engine)
83
+
84
+ async def schedule_email(self, email: ScheduledEmail) -> ScheduledEmail:
85
+ """Schedule a single email"""
86
+ try:
87
+ # Validate template exists
88
+ template = await self.template_manager.get_template(email.template_id)
89
+ if not template:
90
+ raise TemplateNotFoundError(f"Template {email.template_id} not found")
91
+
92
+ # Calculate scheduled time based on trigger type
93
+ scheduled_for = self._calculate_scheduled_time(
94
+ email.trigger_type, email.trigger_offset, email.reference_date
95
+ )
96
+
97
+ with self.template_manager.get_session() as session:
98
+ scheduled_email_model = ScheduledEmailModel(
99
+ id=email.id or str(uuid.uuid4()),
100
+ template_id=email.template_id,
101
+ recipient_email=str(email.recipient_email),
102
+ recipient_name=email.recipient_name,
103
+ scheduled_for=scheduled_for,
104
+ trigger_type=email.trigger_type,
105
+ trigger_offset=email.trigger_offset,
106
+ reference_date=email.reference_date,
107
+ variables=email.variables,
108
+ status=email.status,
109
+ retry_count=email.retry_count,
110
+ max_retries=email.max_retries,
111
+ email_metadata=email.metadata,
112
+ )
113
+
114
+ session.add(scheduled_email_model)
115
+ session.commit()
116
+ session.refresh(scheduled_email_model)
117
+
118
+ return self._model_to_scheduled_email(scheduled_email_model)
119
+
120
+ except Exception as e:
121
+ logger.error(f"Failed to schedule email: {str(e)}")
122
+ raise SchedulingError(f"Failed to schedule email: {str(e)}")
123
+
124
+ async def schedule_batch(
125
+ self, emails: List[ScheduledEmail]
126
+ ) -> List[ScheduledEmail]:
127
+ """Schedule multiple emails"""
128
+ results = []
129
+ for email in emails:
130
+ try:
131
+ result = await self.schedule_email(email)
132
+ results.append(result)
133
+ except Exception as e:
134
+ logger.error(f"Failed to schedule email in batch: {str(e)}")
135
+ # Create a failed scheduled email record
136
+ failed_email = email.copy()
137
+ failed_email.status = "failed"
138
+ failed_email.error_message = str(e)
139
+ results.append(failed_email)
140
+
141
+ return results
142
+
143
+ async def cancel_scheduled_email(self, email_id: str) -> bool:
144
+ """Cancel a scheduled email"""
145
+ try:
146
+ with self.template_manager.get_session() as session:
147
+ scheduled_email = (
148
+ session.query(ScheduledEmailModel)
149
+ .filter(ScheduledEmailModel.id == email_id)
150
+ .first()
151
+ )
152
+
153
+ if not scheduled_email:
154
+ return False
155
+
156
+ if scheduled_email.status in ["sent", "failed"]:
157
+ return False
158
+
159
+ scheduled_email.status = "cancelled"
160
+ scheduled_email.updated_at = datetime.utcnow()
161
+ session.commit()
162
+
163
+ return True
164
+
165
+ except Exception as e:
166
+ logger.error(f"Failed to cancel scheduled email {email_id}: {str(e)}")
167
+ raise SchedulingError(f"Failed to cancel email: {str(e)}")
168
+
169
+ async def pause_scheduled_email(self, email_id: str) -> bool:
170
+ """Pause a scheduled email"""
171
+ return await self._update_email_status(email_id, "paused")
172
+
173
+ async def resume_scheduled_email(self, email_id: str) -> bool:
174
+ """Resume a paused email"""
175
+ return await self._update_email_status(email_id, "pending")
176
+
177
+ async def process_pending_emails(self) -> ProcessingResult:
178
+ """Process pending emails that are ready to be sent"""
179
+ start_time = datetime.utcnow()
180
+ processed_count = 0
181
+ success_count = 0
182
+ failed_count = 0
183
+ errors = []
184
+
185
+ try:
186
+ # Get pending emails ready to be sent
187
+ pending_emails = await self._get_pending_emails()
188
+
189
+ for scheduled_email in pending_emails:
190
+ try:
191
+ # Render and send the email
192
+ success = await self._send_scheduled_email(scheduled_email)
193
+ processed_count += 1
194
+
195
+ if success:
196
+ success_count += 1
197
+ else:
198
+ failed_count += 1
199
+
200
+ except Exception as e:
201
+ failed_count += 1
202
+ error_msg = f"Email {scheduled_email.id}: {str(e)}"
203
+ errors.append({"email_id": scheduled_email.id, "error": error_msg})
204
+ logger.error(error_msg)
205
+
206
+ # Process in batches to avoid overwhelming the system
207
+ if processed_count % self.batch_size == 0:
208
+ await asyncio.sleep(0.1) # Brief pause between batches
209
+
210
+ processing_time = (datetime.utcnow() - start_time).total_seconds()
211
+
212
+ return ProcessingResult(
213
+ processed_count=processed_count,
214
+ success_count=success_count,
215
+ failed_count=failed_count,
216
+ errors=errors,
217
+ processing_time_seconds=processing_time,
218
+ )
219
+
220
+ except Exception as e:
221
+ logger.error(f"Failed to process pending emails: {str(e)}")
222
+ raise SchedulingError(f"Failed to process emails: {str(e)}")
223
+
224
+ async def retry_failed_emails(self) -> ProcessingResult:
225
+ """Retry failed emails that haven't exceeded max retries"""
226
+ try:
227
+ with self.template_manager.get_session() as session:
228
+ # Get failed emails that can be retried
229
+ failed_emails = (
230
+ session.query(ScheduledEmailModel)
231
+ .filter(
232
+ ScheduledEmailModel.status == "failed",
233
+ ScheduledEmailModel.retry_count
234
+ < ScheduledEmailModel.max_retries,
235
+ )
236
+ .all()
237
+ )
238
+
239
+ for email_model in failed_emails:
240
+ # Calculate next retry time based on retry count
241
+ if email_model.retry_count < len(self.retry_delays):
242
+ delay_minutes = self.retry_delays[email_model.retry_count]
243
+ else:
244
+ delay_minutes = self.retry_delays[-1]
245
+
246
+ next_retry = datetime.utcnow() + timedelta(minutes=delay_minutes)
247
+
248
+ # Update email for retry
249
+ email_model.status = "pending"
250
+ email_model.scheduled_for = next_retry
251
+ email_model.retry_count += 1
252
+ email_model.error_message = None
253
+ email_model.updated_at = datetime.utcnow()
254
+
255
+ session.commit()
256
+
257
+ # Process the retries
258
+ return await self.process_pending_emails()
259
+
260
+ except Exception as e:
261
+ logger.error(f"Failed to retry failed emails: {str(e)}")
262
+ raise SchedulingError(f"Failed to retry emails: {str(e)}")
263
+
264
+ async def get_scheduled_email(self, email_id: str) -> Optional[ScheduledEmail]:
265
+ """Get scheduled email by ID"""
266
+ try:
267
+ with self.template_manager.get_session() as session:
268
+ email_model = (
269
+ session.query(ScheduledEmailModel)
270
+ .filter(ScheduledEmailModel.id == email_id)
271
+ .first()
272
+ )
273
+
274
+ if not email_model:
275
+ return None
276
+
277
+ return self._model_to_scheduled_email(email_model)
278
+
279
+ except Exception as e:
280
+ logger.error(f"Failed to get scheduled email {email_id}: {str(e)}")
281
+ return None
282
+
283
+ async def list_scheduled_emails(
284
+ self, filters: Optional[SchedulingFilters] = None
285
+ ) -> List[ScheduledEmail]:
286
+ """List scheduled emails with optional filtering"""
287
+ try:
288
+ with self.template_manager.get_session() as session:
289
+ query = session.query(ScheduledEmailModel)
290
+
291
+ # Apply filters
292
+ if filters:
293
+ if filters.template_id:
294
+ query = query.filter(
295
+ ScheduledEmailModel.template_id == filters.template_id
296
+ )
297
+ if filters.recipient_email:
298
+ query = query.filter(
299
+ ScheduledEmailModel.recipient_email
300
+ == filters.recipient_email
301
+ )
302
+ if filters.status:
303
+ query = query.filter(
304
+ ScheduledEmailModel.status == filters.status
305
+ )
306
+ if filters.scheduled_after:
307
+ query = query.filter(
308
+ ScheduledEmailModel.scheduled_for >= filters.scheduled_after
309
+ )
310
+ if filters.scheduled_before:
311
+ query = query.filter(
312
+ ScheduledEmailModel.scheduled_for
313
+ <= filters.scheduled_before
314
+ )
315
+
316
+ # Apply pagination
317
+ if filters and filters.offset:
318
+ query = query.offset(filters.offset)
319
+ if filters and filters.limit:
320
+ query = query.limit(filters.limit)
321
+ else:
322
+ query = query.limit(100) # Default limit
323
+
324
+ email_models = query.all()
325
+ return [self._model_to_scheduled_email(model) for model in email_models]
326
+
327
+ except Exception as e:
328
+ logger.error(f"Failed to list scheduled emails: {str(e)}")
329
+ return []
330
+
331
+ def _calculate_scheduled_time(
332
+ self,
333
+ trigger_type: str,
334
+ trigger_offset: Optional[int],
335
+ reference_date: Optional[datetime],
336
+ ) -> datetime:
337
+ """Calculate when the email should be sent"""
338
+ if trigger_type == "immediate":
339
+ return datetime.utcnow()
340
+ elif (
341
+ trigger_type == "relative" and reference_date and trigger_offset is not None
342
+ ):
343
+ return reference_date + timedelta(days=trigger_offset)
344
+ elif trigger_type == "absolute" and reference_date:
345
+ return reference_date
346
+ else:
347
+ # Default to immediate if invalid configuration
348
+ return datetime.utcnow()
349
+
350
+ async def _get_pending_emails(self) -> List[ScheduledEmailModel]:
351
+ """Get emails that are ready to be sent"""
352
+ with self.template_manager.get_session() as session:
353
+ return (
354
+ session.query(ScheduledEmailModel)
355
+ .filter(
356
+ ScheduledEmailModel.status == "pending",
357
+ ScheduledEmailModel.scheduled_for <= datetime.utcnow(),
358
+ )
359
+ .limit(self.batch_size)
360
+ .all()
361
+ )
362
+
363
+ async def _send_scheduled_email(self, scheduled_email: ScheduledEmailModel) -> bool:
364
+ """Send a single scheduled email"""
365
+ try:
366
+ # Render the template
367
+ rendered = await self.template_manager.render_template(
368
+ scheduled_email.template_id, scheduled_email.variables
369
+ )
370
+
371
+ # Create email message
372
+ email_message = EmailMessage(
373
+ to_email=scheduled_email.recipient_email,
374
+ to_name=scheduled_email.recipient_name,
375
+ subject=rendered.subject,
376
+ html_content=rendered.html_content,
377
+ text_content=rendered.text_content,
378
+ metadata={
379
+ "scheduled_email_id": scheduled_email.id,
380
+ "template_id": scheduled_email.template_id,
381
+ **scheduled_email.metadata,
382
+ },
383
+ )
384
+
385
+ # Send the email
386
+ result = await self.email_provider.send_email(email_message)
387
+
388
+ # Update scheduled email status
389
+ with self.template_manager.get_session() as session:
390
+ email_model = (
391
+ session.query(ScheduledEmailModel)
392
+ .filter(ScheduledEmailModel.id == scheduled_email.id)
393
+ .first()
394
+ )
395
+
396
+ if email_model:
397
+ if result.success:
398
+ email_model.status = "sent"
399
+ email_model.sent_at = datetime.utcnow()
400
+ email_model.error_message = None
401
+ else:
402
+ email_model.status = "failed"
403
+ email_model.error_message = result.error_message
404
+
405
+ email_model.updated_at = datetime.utcnow()
406
+ session.commit()
407
+
408
+ return result.success
409
+
410
+ except Exception as e:
411
+ # Update scheduled email with error
412
+ with self.template_manager.get_session() as session:
413
+ email_model = (
414
+ session.query(ScheduledEmailModel)
415
+ .filter(ScheduledEmailModel.id == scheduled_email.id)
416
+ .first()
417
+ )
418
+
419
+ if email_model:
420
+ email_model.status = "failed"
421
+ email_model.error_message = str(e)
422
+ email_model.updated_at = datetime.utcnow()
423
+ session.commit()
424
+
425
+ logger.error(
426
+ f"Failed to send scheduled email {scheduled_email.id}: {str(e)}"
427
+ )
428
+ return False
429
+
430
+ async def _update_email_status(self, email_id: str, status: str) -> bool:
431
+ """Update email status"""
432
+ try:
433
+ with self.template_manager.get_session() as session:
434
+ email_model = (
435
+ session.query(ScheduledEmailModel)
436
+ .filter(ScheduledEmailModel.id == email_id)
437
+ .first()
438
+ )
439
+
440
+ if not email_model:
441
+ return False
442
+
443
+ email_model.status = status
444
+ email_model.updated_at = datetime.utcnow()
445
+ session.commit()
446
+
447
+ return True
448
+
449
+ except Exception as e:
450
+ logger.error(f"Failed to update email status: {str(e)}")
451
+ return False
452
+
453
+ def _model_to_scheduled_email(self, model: ScheduledEmailModel) -> ScheduledEmail:
454
+ """Convert SQLAlchemy model to Pydantic model"""
455
+ return ScheduledEmail(
456
+ id=model.id,
457
+ template_id=model.template_id,
458
+ recipient_email=model.recipient_email,
459
+ recipient_name=model.recipient_name,
460
+ scheduled_for=model.scheduled_for,
461
+ trigger_type=model.trigger_type,
462
+ trigger_offset=model.trigger_offset,
463
+ reference_date=model.reference_date,
464
+ variables=model.variables,
465
+ status=model.status,
466
+ sent_at=model.sent_at,
467
+ error_message=model.error_message,
468
+ retry_count=model.retry_count,
469
+ max_retries=model.max_retries,
470
+ metadata=model.email_metadata,
471
+ created_at=model.created_at,
472
+ updated_at=model.updated_at,
473
+ )
@@ -0,0 +1,67 @@
1
+ """
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ """
8
+
9
+ """
10
+ Core exceptions for email template manager
11
+ """
12
+
13
+
14
+ class EmailTemplateManagerError(Exception):
15
+ """Base exception for all email template manager errors"""
16
+
17
+ pass
18
+
19
+
20
+ class TemplateNotFoundError(EmailTemplateManagerError):
21
+ """Raised when a template is not found"""
22
+
23
+ pass
24
+
25
+
26
+ class ValidationError(EmailTemplateManagerError):
27
+ """Raised when validation fails"""
28
+
29
+ def __init__(self, message: str, errors: list = None):
30
+ super().__init__(message)
31
+ self.errors = errors or []
32
+
33
+
34
+ class DeliveryError(EmailTemplateManagerError):
35
+ """Raised when email delivery fails"""
36
+
37
+ pass
38
+
39
+
40
+ class ConfigurationError(EmailTemplateManagerError):
41
+ """Raised when configuration is invalid"""
42
+
43
+ pass
44
+
45
+
46
+ class SchedulingError(EmailTemplateManagerError):
47
+ """Raised when email scheduling fails"""
48
+
49
+ pass
50
+
51
+
52
+ class ProviderError(EmailTemplateManagerError):
53
+ """Raised when email provider operations fail"""
54
+
55
+ pass
56
+
57
+
58
+ class EncryptionError(EmailTemplateManagerError):
59
+ """Raised when encryption/decryption operations fail"""
60
+
61
+ pass
62
+
63
+
64
+ class DatabaseError(EmailTemplateManagerError):
65
+ """Raised when database operations fail"""
66
+
67
+ pass
@@ -0,0 +1,59 @@
1
+ """
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ """
8
+
9
+ """
10
+ Magic link data models
11
+ """
12
+
13
+ from datetime import datetime
14
+ from typing import Any, Dict, Optional
15
+
16
+ from pydantic import BaseModel, EmailStr, Field
17
+
18
+
19
+ class MagicLink(BaseModel):
20
+ """Magic link model"""
21
+
22
+ id: Optional[str] = None
23
+ token: str
24
+ email: EmailStr
25
+ type: str
26
+ payload: Dict[str, Any] = Field(default_factory=dict)
27
+ expires_at: datetime
28
+ used_at: Optional[datetime] = None
29
+ is_used: bool = False
30
+ metadata: Dict[str, Any] = Field(default_factory=dict)
31
+ created_at: Optional[datetime] = None
32
+
33
+ class Config:
34
+ from_attributes = True
35
+ json_encoders = {datetime: lambda v: v.isoformat() if v else None}
36
+
37
+
38
+ class MagicLinkConfig(BaseModel):
39
+ """Magic link configuration"""
40
+
41
+ default_expiry_hours: int = 24
42
+ token_length: int = 32
43
+ allow_reuse: bool = False
44
+ max_uses: int = 1
45
+ cleanup_expired_links: bool = True
46
+
47
+
48
+ class MagicLinkFilters(BaseModel):
49
+ """Magic link filters for querying"""
50
+
51
+ email: Optional[str] = None
52
+ type: Optional[str] = None
53
+ is_used: Optional[bool] = None
54
+ expires_after: Optional[datetime] = None
55
+ expires_before: Optional[datetime] = None
56
+ created_after: Optional[datetime] = None
57
+ created_before: Optional[datetime] = None
58
+ limit: Optional[int] = None
59
+ offset: Optional[int] = None
@@ -0,0 +1,78 @@
1
+ """
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ """
8
+
9
+ """
10
+ Scheduled email data models
11
+ """
12
+
13
+ from datetime import datetime
14
+ from typing import Any, Dict, Literal, Optional
15
+
16
+ from pydantic import BaseModel, EmailStr, Field
17
+
18
+
19
+ class ScheduledEmail(BaseModel):
20
+ """Scheduled email model"""
21
+
22
+ id: Optional[str] = None
23
+ template_id: str
24
+ recipient_email: EmailStr
25
+ recipient_name: Optional[str] = None
26
+ scheduled_for: datetime
27
+ trigger_type: Literal["immediate", "relative", "absolute"]
28
+ trigger_offset: Optional[int] = None # days before/after reference date
29
+ reference_date: Optional[datetime] = None
30
+ variables: Dict[str, Any] = Field(default_factory=dict)
31
+ status: Literal["pending", "sent", "failed", "cancelled", "paused"] = "pending"
32
+ sent_at: Optional[datetime] = None
33
+ error_message: Optional[str] = None
34
+ retry_count: int = 0
35
+ max_retries: int = 3
36
+ metadata: Dict[str, Any] = Field(default_factory=dict)
37
+ created_at: Optional[datetime] = None
38
+ updated_at: Optional[datetime] = None
39
+
40
+ class Config:
41
+ from_attributes = True
42
+ json_encoders = {datetime: lambda v: v.isoformat() if v else None}
43
+
44
+
45
+ class SchedulingFilters(BaseModel):
46
+ """Scheduling filters for querying scheduled emails"""
47
+
48
+ template_id: Optional[str] = None
49
+ recipient_email: Optional[str] = None
50
+ status: Optional[Literal["pending", "sent", "failed", "cancelled", "paused"]] = None
51
+ scheduled_after: Optional[datetime] = None
52
+ scheduled_before: Optional[datetime] = None
53
+ created_after: Optional[datetime] = None
54
+ created_before: Optional[datetime] = None
55
+ limit: Optional[int] = None
56
+ offset: Optional[int] = None
57
+
58
+
59
+ class ProcessingResult(BaseModel):
60
+ """Result of email processing operation"""
61
+
62
+ processed_count: int
63
+ success_count: int
64
+ failed_count: int
65
+ errors: list[Dict[str, Any]] = Field(default_factory=list)
66
+ processing_time_seconds: float
67
+
68
+
69
+ class EmailBatch(BaseModel):
70
+ """Batch of emails to be scheduled"""
71
+
72
+ template_id: str
73
+ recipients: list[Dict[str, Any]] # List of recipient data with variables
74
+ scheduled_for: Optional[datetime] = None
75
+ trigger_type: Literal["immediate", "relative", "absolute"] = "immediate"
76
+ trigger_offset: Optional[int] = None
77
+ reference_date: Optional[datetime] = None
78
+ metadata: Dict[str, Any] = Field(default_factory=dict)