@bernierllc/email 1.0.0 → 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/src/index.js +0 -1
- package/test_package.py +0 -125
|
@@ -1,344 +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 email template manager implementation
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import logging
|
|
14
|
-
import uuid
|
|
15
|
-
from datetime import datetime
|
|
16
|
-
from typing import Any, Dict, List, Optional
|
|
17
|
-
|
|
18
|
-
from jinja2 import BaseLoader, Environment, TemplateError
|
|
19
|
-
from sqlalchemy import (
|
|
20
|
-
JSON,
|
|
21
|
-
Boolean,
|
|
22
|
-
Column,
|
|
23
|
-
DateTime,
|
|
24
|
-
Integer,
|
|
25
|
-
String,
|
|
26
|
-
Text,
|
|
27
|
-
create_engine,
|
|
28
|
-
)
|
|
29
|
-
from sqlalchemy.ext.declarative import declarative_base
|
|
30
|
-
from sqlalchemy.orm import Session, sessionmaker
|
|
31
|
-
from sqlalchemy.sql import func
|
|
32
|
-
|
|
33
|
-
from ..exceptions import DatabaseError, TemplateNotFoundError, ValidationError
|
|
34
|
-
from ..models.template import (
|
|
35
|
-
EmailTemplate,
|
|
36
|
-
RenderedTemplate,
|
|
37
|
-
TemplateFilters,
|
|
38
|
-
TemplateVariable,
|
|
39
|
-
ValidationResult,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
logger = logging.getLogger(__name__)
|
|
43
|
-
|
|
44
|
-
Base = declarative_base()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class EmailTemplateModel(Base):
|
|
48
|
-
"""SQLAlchemy model for email templates"""
|
|
49
|
-
|
|
50
|
-
__tablename__ = "email_templates"
|
|
51
|
-
|
|
52
|
-
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
53
|
-
name = Column(String, nullable=False)
|
|
54
|
-
subject = Column(String, nullable=False)
|
|
55
|
-
html_body = Column(Text, nullable=False)
|
|
56
|
-
text_body = Column(Text, nullable=False)
|
|
57
|
-
variables = Column(JSON, default=[])
|
|
58
|
-
category_id = Column(String, nullable=True)
|
|
59
|
-
tags = Column(JSON, default=[])
|
|
60
|
-
is_active = Column(Boolean, default=True)
|
|
61
|
-
version = Column(Integer, default=1)
|
|
62
|
-
created_at = Column(DateTime, server_default=func.now())
|
|
63
|
-
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
|
64
|
-
created_by = Column(String, nullable=True)
|
|
65
|
-
template_metadata = Column(JSON, default={})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class EmailTemplateManager:
|
|
69
|
-
"""Manages email templates with database persistence"""
|
|
70
|
-
|
|
71
|
-
def __init__(self, database_url: str, echo: bool = False):
|
|
72
|
-
"""Initialize template manager with database connection"""
|
|
73
|
-
self.engine = create_engine(database_url, echo=echo)
|
|
74
|
-
Base.metadata.create_all(self.engine)
|
|
75
|
-
self.SessionLocal = sessionmaker(bind=self.engine)
|
|
76
|
-
self.jinja_env = Environment(loader=BaseLoader())
|
|
77
|
-
|
|
78
|
-
def get_session(self) -> Session:
|
|
79
|
-
"""Get database session"""
|
|
80
|
-
return self.SessionLocal()
|
|
81
|
-
|
|
82
|
-
async def create_template(self, template: EmailTemplate) -> EmailTemplate:
|
|
83
|
-
"""Create a new email template"""
|
|
84
|
-
try:
|
|
85
|
-
with self.get_session() as session:
|
|
86
|
-
# Validate template
|
|
87
|
-
validation_result = self.validate_template_variables(template, {})
|
|
88
|
-
if not validation_result.is_valid:
|
|
89
|
-
raise ValidationError(
|
|
90
|
-
"Template validation failed", validation_result.errors
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
# Create model instance
|
|
94
|
-
template_model = EmailTemplateModel(
|
|
95
|
-
id=template.id or str(uuid.uuid4()),
|
|
96
|
-
name=template.name,
|
|
97
|
-
subject=template.subject,
|
|
98
|
-
html_body=template.html_body,
|
|
99
|
-
text_body=template.text_body,
|
|
100
|
-
variables=[var.dict() for var in template.variables],
|
|
101
|
-
category_id=template.category_id,
|
|
102
|
-
tags=template.tags,
|
|
103
|
-
is_active=template.is_active,
|
|
104
|
-
version=template.version,
|
|
105
|
-
created_by=template.created_by,
|
|
106
|
-
template_metadata=template.metadata,
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
session.add(template_model)
|
|
110
|
-
session.commit()
|
|
111
|
-
session.refresh(template_model)
|
|
112
|
-
|
|
113
|
-
return self._model_to_template(template_model)
|
|
114
|
-
|
|
115
|
-
except Exception as e:
|
|
116
|
-
logger.error(f"Failed to create template: {str(e)}")
|
|
117
|
-
raise DatabaseError(f"Failed to create template: {str(e)}")
|
|
118
|
-
|
|
119
|
-
async def get_template(self, template_id: str) -> Optional[EmailTemplate]:
|
|
120
|
-
"""Get template by ID"""
|
|
121
|
-
try:
|
|
122
|
-
with self.get_session() as session:
|
|
123
|
-
template_model = (
|
|
124
|
-
session.query(EmailTemplateModel)
|
|
125
|
-
.filter(EmailTemplateModel.id == template_id)
|
|
126
|
-
.first()
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
if not template_model:
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
return self._model_to_template(template_model)
|
|
133
|
-
|
|
134
|
-
except Exception as e:
|
|
135
|
-
logger.error(f"Failed to get template {template_id}: {str(e)}")
|
|
136
|
-
raise DatabaseError(f"Failed to get template: {str(e)}")
|
|
137
|
-
|
|
138
|
-
async def update_template(
|
|
139
|
-
self, template_id: str, updates: Dict[str, Any]
|
|
140
|
-
) -> EmailTemplate:
|
|
141
|
-
"""Update existing template"""
|
|
142
|
-
try:
|
|
143
|
-
with self.get_session() as session:
|
|
144
|
-
template_model = (
|
|
145
|
-
session.query(EmailTemplateModel)
|
|
146
|
-
.filter(EmailTemplateModel.id == template_id)
|
|
147
|
-
.first()
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
if not template_model:
|
|
151
|
-
raise TemplateNotFoundError(f"Template {template_id} not found")
|
|
152
|
-
|
|
153
|
-
# Update fields
|
|
154
|
-
for key, value in updates.items():
|
|
155
|
-
if hasattr(template_model, key):
|
|
156
|
-
setattr(template_model, key, value)
|
|
157
|
-
|
|
158
|
-
template_model.updated_at = datetime.utcnow()
|
|
159
|
-
session.commit()
|
|
160
|
-
session.refresh(template_model)
|
|
161
|
-
|
|
162
|
-
return self._model_to_template(template_model)
|
|
163
|
-
|
|
164
|
-
except TemplateNotFoundError:
|
|
165
|
-
raise
|
|
166
|
-
except Exception as e:
|
|
167
|
-
logger.error(f"Failed to update template {template_id}: {str(e)}")
|
|
168
|
-
raise DatabaseError(f"Failed to update template: {str(e)}")
|
|
169
|
-
|
|
170
|
-
async def delete_template(self, template_id: str) -> bool:
|
|
171
|
-
"""Delete template"""
|
|
172
|
-
try:
|
|
173
|
-
with self.get_session() as session:
|
|
174
|
-
template_model = (
|
|
175
|
-
session.query(EmailTemplateModel)
|
|
176
|
-
.filter(EmailTemplateModel.id == template_id)
|
|
177
|
-
.first()
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
if not template_model:
|
|
181
|
-
raise TemplateNotFoundError(f"Template {template_id} not found")
|
|
182
|
-
|
|
183
|
-
session.delete(template_model)
|
|
184
|
-
session.commit()
|
|
185
|
-
return True
|
|
186
|
-
|
|
187
|
-
except TemplateNotFoundError:
|
|
188
|
-
raise
|
|
189
|
-
except Exception as e:
|
|
190
|
-
logger.error(f"Failed to delete template {template_id}: {str(e)}")
|
|
191
|
-
raise DatabaseError(f"Failed to delete template: {str(e)}")
|
|
192
|
-
|
|
193
|
-
async def list_templates(
|
|
194
|
-
self, filters: Optional[TemplateFilters] = None, skip: int = 0, limit: int = 100
|
|
195
|
-
) -> List[EmailTemplate]:
|
|
196
|
-
"""List templates with optional filtering"""
|
|
197
|
-
try:
|
|
198
|
-
with self.get_session() as session:
|
|
199
|
-
query = session.query(EmailTemplateModel)
|
|
200
|
-
|
|
201
|
-
# Apply filters
|
|
202
|
-
if filters:
|
|
203
|
-
if filters.category_id:
|
|
204
|
-
query = query.filter(
|
|
205
|
-
EmailTemplateModel.category_id == filters.category_id
|
|
206
|
-
)
|
|
207
|
-
if filters.is_active is not None:
|
|
208
|
-
query = query.filter(
|
|
209
|
-
EmailTemplateModel.is_active == filters.is_active
|
|
210
|
-
)
|
|
211
|
-
if filters.search:
|
|
212
|
-
search_term = f"%{filters.search}%"
|
|
213
|
-
query = query.filter(
|
|
214
|
-
EmailTemplateModel.name.ilike(search_term)
|
|
215
|
-
| EmailTemplateModel.subject.ilike(search_term)
|
|
216
|
-
)
|
|
217
|
-
if filters.created_by:
|
|
218
|
-
query = query.filter(
|
|
219
|
-
EmailTemplateModel.created_by == filters.created_by
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
# Apply pagination
|
|
223
|
-
query = query.offset(skip).limit(limit)
|
|
224
|
-
|
|
225
|
-
template_models = query.all()
|
|
226
|
-
return [self._model_to_template(model) for model in template_models]
|
|
227
|
-
|
|
228
|
-
except Exception as e:
|
|
229
|
-
logger.error(f"Failed to list templates: {str(e)}")
|
|
230
|
-
raise DatabaseError(f"Failed to list templates: {str(e)}")
|
|
231
|
-
|
|
232
|
-
async def render_template(
|
|
233
|
-
self, template_id: str, variables: Dict[str, Any]
|
|
234
|
-
) -> RenderedTemplate:
|
|
235
|
-
"""Render template with variables"""
|
|
236
|
-
template = await self.get_template(template_id)
|
|
237
|
-
if not template:
|
|
238
|
-
raise TemplateNotFoundError(f"Template {template_id} not found")
|
|
239
|
-
|
|
240
|
-
# Validate variables
|
|
241
|
-
validation_result = self.validate_template_variables(template, variables)
|
|
242
|
-
if not validation_result.is_valid:
|
|
243
|
-
raise ValidationError(
|
|
244
|
-
"Variable validation failed", validation_result.errors
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
try:
|
|
248
|
-
# Render subject
|
|
249
|
-
subject_template = self.jinja_env.from_string(template.subject)
|
|
250
|
-
rendered_subject = subject_template.render(**variables)
|
|
251
|
-
|
|
252
|
-
# Render HTML content
|
|
253
|
-
html_template = self.jinja_env.from_string(template.html_body)
|
|
254
|
-
rendered_html = html_template.render(**variables)
|
|
255
|
-
|
|
256
|
-
# Render text content
|
|
257
|
-
text_template = self.jinja_env.from_string(template.text_body)
|
|
258
|
-
rendered_text = text_template.render(**variables)
|
|
259
|
-
|
|
260
|
-
return RenderedTemplate(
|
|
261
|
-
subject=rendered_subject,
|
|
262
|
-
html_content=rendered_html,
|
|
263
|
-
text_content=rendered_text,
|
|
264
|
-
variables=variables,
|
|
265
|
-
template_id=template_id,
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
except TemplateError as e:
|
|
269
|
-
logger.error(f"Template rendering failed: {str(e)}")
|
|
270
|
-
raise ValidationError(f"Template rendering failed: {str(e)}")
|
|
271
|
-
|
|
272
|
-
def validate_template_variables(
|
|
273
|
-
self, template: EmailTemplate, variables: Dict[str, Any]
|
|
274
|
-
) -> ValidationResult:
|
|
275
|
-
"""Validate template variables"""
|
|
276
|
-
errors = []
|
|
277
|
-
warnings = []
|
|
278
|
-
|
|
279
|
-
# Check required variables
|
|
280
|
-
for var in template.variables:
|
|
281
|
-
if var.required and var.name not in variables:
|
|
282
|
-
errors.append(
|
|
283
|
-
{
|
|
284
|
-
"field": var.name,
|
|
285
|
-
"message": f"Required variable '{var.name}' is missing",
|
|
286
|
-
"code": "MISSING_REQUIRED_VARIABLE",
|
|
287
|
-
}
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
# Check variable types
|
|
291
|
-
for var in template.variables:
|
|
292
|
-
if var.name in variables:
|
|
293
|
-
value = variables[var.name]
|
|
294
|
-
if not self._validate_variable_type(var, value):
|
|
295
|
-
errors.append(
|
|
296
|
-
{
|
|
297
|
-
"field": var.name,
|
|
298
|
-
"message": f"Variable '{var.name}' has invalid type",
|
|
299
|
-
"code": "INVALID_VARIABLE_TYPE",
|
|
300
|
-
"expected_type": var.type,
|
|
301
|
-
"actual_value": value,
|
|
302
|
-
}
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
return ValidationResult(
|
|
306
|
-
is_valid=len(errors) == 0, errors=errors, warnings=warnings
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
def _validate_variable_type(self, var: TemplateVariable, value: Any) -> bool:
|
|
310
|
-
"""Validate variable type"""
|
|
311
|
-
if var.type == "text":
|
|
312
|
-
return isinstance(value, str)
|
|
313
|
-
elif var.type == "number":
|
|
314
|
-
return isinstance(value, (int, float))
|
|
315
|
-
elif var.type == "boolean":
|
|
316
|
-
return isinstance(value, bool)
|
|
317
|
-
elif var.type == "email":
|
|
318
|
-
return isinstance(value, str) and "@" in value
|
|
319
|
-
elif var.type == "url":
|
|
320
|
-
return isinstance(value, str) and (
|
|
321
|
-
"http://" in value or "https://" in value
|
|
322
|
-
)
|
|
323
|
-
elif var.type == "date":
|
|
324
|
-
return isinstance(value, (str, datetime))
|
|
325
|
-
return True
|
|
326
|
-
|
|
327
|
-
def _model_to_template(self, model: EmailTemplateModel) -> EmailTemplate:
|
|
328
|
-
"""Convert SQLAlchemy model to Pydantic model"""
|
|
329
|
-
return EmailTemplate(
|
|
330
|
-
id=model.id,
|
|
331
|
-
name=model.name,
|
|
332
|
-
subject=model.subject,
|
|
333
|
-
html_body=model.html_body,
|
|
334
|
-
text_body=model.text_body,
|
|
335
|
-
variables=[TemplateVariable(**var) for var in model.variables],
|
|
336
|
-
category_id=model.category_id,
|
|
337
|
-
tags=model.tags,
|
|
338
|
-
is_active=model.is_active,
|
|
339
|
-
version=model.version,
|
|
340
|
-
created_at=model.created_at,
|
|
341
|
-
updated_at=model.updated_at,
|
|
342
|
-
created_by=model.created_by,
|
|
343
|
-
metadata=model.template_metadata,
|
|
344
|
-
)
|