@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,344 @@
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
+ )