@bernierllc/email 1.0.1 → 1.1.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.
- package/README.md +76 -217
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/simple-email-service.d.ts +58 -0
- package/dist/simple-email-service.d.ts.map +1 -0
- package/dist/simple-email-service.js +416 -0
- package/dist/simple-email-service.js.map +1 -0
- package/dist/types.d.ts +311 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -22
- package/.eslintrc.json +0 -112
- package/.flake8 +0 -18
- package/.github/workflows/ci.yml +0 -300
- package/EXTRACTION_SUMMARY.md +0 -265
- package/IMPLEMENTATION_STATUS.md +0 -159
- package/OPEN_SOURCE_SETUP.md +0 -420
- package/PACKAGE_USAGE.md +0 -471
- package/examples/fastapi-example/main.py +0 -257
- package/examples/nextjs-example/next-env.d.ts +0 -13
- package/examples/nextjs-example/package.json +0 -26
- package/examples/nextjs-example/pages/admin/templates.tsx +0 -157
- package/examples/nextjs-example/tsconfig.json +0 -28
- package/packages/core/package.json +0 -70
- package/packages/core/rollup.config.js +0 -37
- package/packages/core/specification.md +0 -416
- package/packages/core/src/adapters/supabase.ts +0 -291
- package/packages/core/src/core/scheduler.ts +0 -356
- package/packages/core/src/core/template-manager.ts +0 -388
- package/packages/core/src/index.ts +0 -30
- package/packages/core/src/providers/base.ts +0 -104
- package/packages/core/src/providers/sendgrid.ts +0 -368
- package/packages/core/src/types/provider.ts +0 -91
- package/packages/core/src/types/scheduled.ts +0 -78
- package/packages/core/src/types/template.ts +0 -97
- package/packages/core/tsconfig.json +0 -23
- package/packages/python/README.md +0 -106
- package/packages/python/email_template_manager/__init__.py +0 -66
- package/packages/python/email_template_manager/config.py +0 -98
- package/packages/python/email_template_manager/core/magic_links.py +0 -245
- package/packages/python/email_template_manager/core/manager.py +0 -344
- package/packages/python/email_template_manager/core/scheduler.py +0 -473
- package/packages/python/email_template_manager/exceptions.py +0 -67
- package/packages/python/email_template_manager/models/magic_link.py +0 -59
- package/packages/python/email_template_manager/models/scheduled.py +0 -78
- package/packages/python/email_template_manager/models/template.py +0 -90
- package/packages/python/email_template_manager/providers/aws_ses.py +0 -44
- package/packages/python/email_template_manager/providers/base.py +0 -94
- package/packages/python/email_template_manager/providers/sendgrid.py +0 -325
- package/packages/python/email_template_manager/providers/smtp.py +0 -44
- package/packages/python/pyproject.toml +0 -133
- package/packages/python/setup.py +0 -93
- package/packages/python/specification.md +0 -930
- package/packages/react/README.md +0 -13
- package/packages/react/package.json +0 -105
- package/packages/react/rollup.config.js +0 -37
- package/packages/react/specification.md +0 -569
- package/packages/react/src/index.ts +0 -20
- package/packages/react/tsconfig.json +0 -24
- package/plans/email-template-manager_app-admin.md +0 -590
- package/src/index.js +0 -1
- package/test_package.py +0 -125
|
@@ -1,473 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,67 +0,0 @@
|
|
|
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
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
|
@@ -1,78 +0,0 @@
|
|
|
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)
|