@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,90 @@
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 template data models
11
+ """
12
+
13
+ from datetime import datetime
14
+ from typing import Any, Dict, List, Literal, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ class TemplateVariable(BaseModel):
20
+ """Template variable definition"""
21
+
22
+ name: str
23
+ type: Literal["text", "number", "date", "boolean", "url", "email"]
24
+ description: Optional[str] = None
25
+ default_value: Optional[Any] = None
26
+ required: bool = True
27
+ validation: Optional[Dict[str, Any]] = None
28
+
29
+
30
+ class EmailTemplate(BaseModel):
31
+ """Email template model"""
32
+
33
+ id: Optional[str] = None
34
+ name: str
35
+ subject: str
36
+ html_body: str
37
+ text_body: str
38
+ variables: List[TemplateVariable] = Field(default_factory=list)
39
+ category_id: Optional[str] = None
40
+ tags: List[str] = Field(default_factory=list)
41
+ is_active: bool = True
42
+ version: int = 1
43
+ created_at: Optional[datetime] = None
44
+ updated_at: Optional[datetime] = None
45
+ created_by: Optional[str] = None
46
+ metadata: Dict[str, Any] = Field(default_factory=dict)
47
+
48
+ class Config:
49
+ from_attributes = True
50
+ json_encoders = {datetime: lambda v: v.isoformat() if v else None}
51
+
52
+
53
+ class TemplateCategory(BaseModel):
54
+ """Template category model"""
55
+
56
+ id: str
57
+ name: str
58
+ description: Optional[str] = None
59
+ color: Optional[str] = None
60
+ is_active: bool = True
61
+
62
+
63
+ class RenderedTemplate(BaseModel):
64
+ """Rendered template result"""
65
+
66
+ subject: str
67
+ html_content: str
68
+ text_content: str
69
+ variables: Dict[str, Any]
70
+ template_id: str
71
+
72
+
73
+ class TemplateFilters(BaseModel):
74
+ """Template filtering options"""
75
+
76
+ category_id: Optional[str] = None
77
+ tags: List[str] = Field(default_factory=list)
78
+ is_active: Optional[bool] = None
79
+ search: Optional[str] = None
80
+ created_by: Optional[str] = None
81
+ created_after: Optional[datetime] = None
82
+ created_before: Optional[datetime] = None
83
+
84
+
85
+ class ValidationResult(BaseModel):
86
+ """Template validation result"""
87
+
88
+ is_valid: bool
89
+ errors: List[Dict[str, Any]] = Field(default_factory=list)
90
+ warnings: List[Dict[str, Any]] = Field(default_factory=list)
@@ -0,0 +1,44 @@
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
+ AWS SES email provider implementation
11
+ """
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from .base import DeliveryStatus, EmailMessage, EmailProvider, SendResult
16
+
17
+
18
+ class SESProvider(EmailProvider):
19
+ """AWS SES email provider"""
20
+
21
+ def __init__(self, config: Dict[str, Any]):
22
+ super().__init__(config)
23
+ # TODO: Initialize boto3 SES client
24
+
25
+ async def send_email(self, email: EmailMessage) -> SendResult:
26
+ """Send a single email via AWS SES"""
27
+ # TODO: Implement SES email sending
28
+ return SendResult(
29
+ success=False, error_message="SES provider not yet implemented"
30
+ )
31
+
32
+ async def send_batch(self, emails: List[EmailMessage]) -> List[SendResult]:
33
+ """Send multiple emails via AWS SES"""
34
+ return [await self.send_email(email) for email in emails]
35
+
36
+ async def get_delivery_status(self, message_id: str) -> Optional[DeliveryStatus]:
37
+ """Get delivery status from AWS SES"""
38
+ return None
39
+
40
+ async def process_webhook(
41
+ self, payload: Any, signature: str
42
+ ) -> List[Dict[str, Any]]:
43
+ """Process AWS SES webhook events"""
44
+ return []
@@ -0,0 +1,94 @@
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
+ Base email provider abstract class
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+ from datetime import datetime
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from pydantic import BaseModel, EmailStr
18
+
19
+
20
+ class EmailMessage(BaseModel):
21
+ """Email message model"""
22
+
23
+ to_email: EmailStr
24
+ to_name: Optional[str] = None
25
+ from_email: Optional[EmailStr] = None
26
+ from_name: Optional[str] = None
27
+ subject: str
28
+ html_content: Optional[str] = None
29
+ text_content: Optional[str] = None
30
+ reply_to: Optional[EmailStr] = None
31
+ headers: Optional[Dict[str, str]] = None
32
+ attachments: Optional[List[Dict[str, Any]]] = None
33
+ metadata: Optional[Dict[str, Any]] = None
34
+
35
+
36
+ class SendResult(BaseModel):
37
+ """Email sending result"""
38
+
39
+ success: bool
40
+ message_id: Optional[str] = None
41
+ error_message: Optional[str] = None
42
+ provider_response: Optional[Any] = None
43
+ metadata: Optional[Dict[str, Any]] = None
44
+
45
+
46
+ class DeliveryStatus(BaseModel):
47
+ """Email delivery status"""
48
+
49
+ message_id: str
50
+ status: str # delivered, bounced, failed, pending, etc.
51
+ delivered_at: Optional[datetime] = None
52
+ opened_at: Optional[datetime] = None
53
+ clicked_at: Optional[datetime] = None
54
+ bounced_at: Optional[datetime] = None
55
+ reason: Optional[str] = None
56
+ metadata: Optional[Dict[str, Any]] = None
57
+
58
+
59
+ class EmailProvider(ABC):
60
+ """Abstract base class for email providers"""
61
+
62
+ def __init__(self, config: Dict[str, Any]):
63
+ """Initialize the email provider with configuration"""
64
+ self.config = config
65
+
66
+ @abstractmethod
67
+ async def send_email(self, email: EmailMessage) -> SendResult:
68
+ """Send a single email"""
69
+ pass
70
+
71
+ @abstractmethod
72
+ async def send_batch(self, emails: List[EmailMessage]) -> List[SendResult]:
73
+ """Send multiple emails"""
74
+ pass
75
+
76
+ @abstractmethod
77
+ async def get_delivery_status(self, message_id: str) -> Optional[DeliveryStatus]:
78
+ """Get delivery status for a message"""
79
+ pass
80
+
81
+ @abstractmethod
82
+ async def process_webhook(
83
+ self, payload: Any, signature: str
84
+ ) -> List[Dict[str, Any]]:
85
+ """Process webhook events from the provider"""
86
+ pass
87
+
88
+ def validate_configuration(self) -> bool:
89
+ """Validate provider configuration"""
90
+ return True
91
+
92
+ def get_provider_name(self) -> str:
93
+ """Get the provider name"""
94
+ return self.__class__.__name__.replace("Provider", "").lower()
@@ -0,0 +1,325 @@
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
+ SendGrid email provider implementation
11
+ """
12
+
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from sendgrid import SendGridAPIClient
18
+ from sendgrid.helpers.mail import (
19
+ Attachment,
20
+ Disposition,
21
+ FileContent,
22
+ FileName,
23
+ FileType,
24
+ From,
25
+ HtmlContent,
26
+ Mail,
27
+ PlainTextContent,
28
+ Subject,
29
+ To,
30
+ )
31
+
32
+ from ..exceptions import ConfigurationError, DeliveryError
33
+ from .base import DeliveryStatus, EmailMessage, EmailProvider, SendResult
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class SendGridProvider(EmailProvider):
39
+ """SendGrid email provider with full API support"""
40
+
41
+ def __init__(self, config: Dict[str, Any]):
42
+ """
43
+ Initialize SendGrid provider
44
+
45
+ Args:
46
+ config: Configuration dictionary with keys:
47
+ - api_key: SendGrid API key
48
+ - from_email: Default from email
49
+ - from_name: Default from name (optional)
50
+ - webhook_url: Webhook URL for delivery tracking (optional)
51
+ - webhook_secret: Webhook secret for verification (optional)
52
+ """
53
+ super().__init__(config)
54
+
55
+ if not config.get("api_key"):
56
+ raise ConfigurationError("SendGrid API key is required")
57
+
58
+ self.client = SendGridAPIClient(api_key=config["api_key"])
59
+ self.from_email = config["from_email"]
60
+ self.from_name = config.get("from_name", "")
61
+ self.webhook_url = config.get("webhook_url")
62
+ self.webhook_secret = config.get("webhook_secret")
63
+
64
+ async def send_email(self, email: EmailMessage) -> SendResult:
65
+ """Send a single email via SendGrid"""
66
+ try:
67
+ # Create the email
68
+ mail = Mail()
69
+
70
+ # Set sender
71
+ mail.from_email = From(
72
+ email=email.from_email or self.from_email,
73
+ name=email.from_name or self.from_name,
74
+ )
75
+
76
+ # Set recipient
77
+ mail.to = To(email=email.to_email, name=email.to_name)
78
+
79
+ # Set subject
80
+ mail.subject = Subject(email.subject)
81
+
82
+ # Set content
83
+ if email.html_content:
84
+ mail.content = HtmlContent(email.html_content)
85
+
86
+ if email.text_content:
87
+ if mail.content:
88
+ # Add plain text as alternative
89
+ mail.add_content(PlainTextContent(email.text_content))
90
+ else:
91
+ mail.content = PlainTextContent(email.text_content)
92
+
93
+ # Add reply-to if specified
94
+ if email.reply_to:
95
+ mail.reply_to = email.reply_to
96
+
97
+ # Add custom headers
98
+ if email.headers:
99
+ for key, value in email.headers.items():
100
+ mail.header = {key: value}
101
+
102
+ # Add attachments
103
+ if email.attachments:
104
+ for attachment_data in email.attachments:
105
+ attachment = Attachment(
106
+ file_content=FileContent(attachment_data["content"]),
107
+ file_name=FileName(attachment_data["filename"]),
108
+ file_type=FileType(
109
+ attachment_data.get(
110
+ "content_type", "application/octet-stream"
111
+ )
112
+ ),
113
+ disposition=Disposition("attachment"),
114
+ )
115
+ mail.attachment = attachment
116
+
117
+ # Add custom tracking parameters
118
+ if email.metadata:
119
+ mail.custom_args = email.metadata
120
+
121
+ # Send the email
122
+ response = self.client.send(mail)
123
+
124
+ # Parse response
125
+ message_id = None
126
+ if hasattr(response, "headers") and "X-Message-Id" in response.headers:
127
+ message_id = response.headers["X-Message-Id"]
128
+
129
+ logger.info(
130
+ f"Email sent successfully via SendGrid. Status: {response.status_code}"
131
+ )
132
+
133
+ return SendResult(
134
+ success=True,
135
+ message_id=message_id,
136
+ provider_response=response.body,
137
+ metadata={"status_code": response.status_code, "provider": "sendgrid"},
138
+ )
139
+
140
+ except Exception as e:
141
+ logger.error(f"Failed to send email via SendGrid: {str(e)}")
142
+ return SendResult(
143
+ success=False, error_message=str(e), metadata={"provider": "sendgrid"}
144
+ )
145
+
146
+ async def send_batch(self, emails: List[EmailMessage]) -> List[SendResult]:
147
+ """Send multiple emails (SendGrid sends individually)"""
148
+ results = []
149
+
150
+ for email in emails:
151
+ result = await self.send_email(email)
152
+ results.append(result)
153
+
154
+ return results
155
+
156
+ async def get_delivery_status(self, message_id: str) -> Optional[DeliveryStatus]:
157
+ """Get delivery status for a message using SendGrid's Event API"""
158
+ try:
159
+ # Use SendGrid's Event API to get delivery status
160
+ # Note: This requires the Events API to be enabled
161
+ params = {"msg_id": message_id, "limit": 1}
162
+
163
+ # Make the API call (response not used in this simplified implementation)
164
+ self.client.client.suppression.unsubscribes.get(
165
+ query_params=params
166
+ )
167
+
168
+ # This is a simplified implementation
169
+ # In practice, you'd need to parse the events and determine status
170
+ return DeliveryStatus(
171
+ message_id=message_id,
172
+ status="unknown", # Would be determined from events
173
+ delivered_at=None,
174
+ metadata={},
175
+ )
176
+
177
+ except Exception as e:
178
+ logger.error(f"Failed to get delivery status from SendGrid: {str(e)}")
179
+ return None
180
+
181
+ async def process_webhook(
182
+ self, payload: Any, signature: str
183
+ ) -> List[Dict[str, Any]]:
184
+ """Process SendGrid webhook events"""
185
+ try:
186
+ # Verify webhook signature if secret is configured
187
+ if self.webhook_secret:
188
+ # Implement signature verification here
189
+ # SendGrid uses a different signature method than others
190
+ pass
191
+
192
+ # Parse SendGrid webhook payload
193
+ events = []
194
+
195
+ if isinstance(payload, list):
196
+ # SendGrid sends events as an array
197
+ for event_data in payload:
198
+ event = {
199
+ "message_id": event_data.get("sg_message_id"),
200
+ "event_type": event_data.get("event"),
201
+ "timestamp": datetime.fromtimestamp(
202
+ event_data.get("timestamp", 0)
203
+ ),
204
+ "email": event_data.get("email"),
205
+ "reason": event_data.get("reason"),
206
+ "response": event_data.get("response"),
207
+ "provider": "sendgrid",
208
+ "raw_data": event_data,
209
+ }
210
+ events.append(event)
211
+
212
+ return events
213
+
214
+ except Exception as e:
215
+ logger.error(f"Failed to process SendGrid webhook: {str(e)}")
216
+ return []
217
+
218
+ def validate_configuration(self) -> bool:
219
+ """Validate SendGrid configuration"""
220
+ try:
221
+ # Test API key by making a simple API call
222
+ response = self.client.client.user.profile.get()
223
+ return response.status_code == 200
224
+ except Exception as e:
225
+ logger.error(f"SendGrid configuration validation failed: {str(e)}")
226
+ return False
227
+
228
+ async def create_template(self, template_data: Dict[str, Any]) -> str:
229
+ """Create a template in SendGrid (Dynamic Templates)"""
230
+ try:
231
+ data = {"name": template_data["name"], "generation": "dynamic"}
232
+
233
+ response = self.client.client.templates.post(request_body=data)
234
+
235
+ if response.status_code == 201:
236
+ template_id = response.body.get("id")
237
+
238
+ # Create a version for the template
239
+ version_data = {
240
+ "subject": template_data["subject"],
241
+ "html_content": template_data["html_content"],
242
+ "plain_content": template_data.get("text_content", ""),
243
+ "active": 1,
244
+ }
245
+
246
+ version_response = self.client.client.templates._(
247
+ template_id
248
+ ).versions.post(request_body=version_data)
249
+
250
+ if version_response.status_code == 201:
251
+ return template_id
252
+
253
+ raise DeliveryError(f"Failed to create SendGrid template: {response.body}")
254
+
255
+ except Exception as e:
256
+ logger.error(f"Failed to create SendGrid template: {str(e)}")
257
+ raise DeliveryError(f"Template creation failed: {str(e)}")
258
+
259
+ async def send_template_email(
260
+ self,
261
+ template_id: str,
262
+ to_email: str,
263
+ template_data: Dict[str, Any],
264
+ to_name: Optional[str] = None,
265
+ ) -> SendResult:
266
+ """Send an email using a SendGrid Dynamic Template"""
267
+ try:
268
+ mail = Mail()
269
+
270
+ # Set sender
271
+ mail.from_email = From(email=self.from_email, name=self.from_name)
272
+
273
+ # Set recipient
274
+ mail.to = To(email=to_email, name=to_name)
275
+
276
+ # Set template ID
277
+ mail.template_id = template_id
278
+
279
+ # Set dynamic template data
280
+ mail.dynamic_template_data = template_data
281
+
282
+ # Send the email
283
+ response = self.client.send(mail)
284
+
285
+ message_id = None
286
+ if hasattr(response, "headers") and "X-Message-Id" in response.headers:
287
+ message_id = response.headers["X-Message-Id"]
288
+
289
+ return SendResult(
290
+ success=True,
291
+ message_id=message_id,
292
+ provider_response=response.body,
293
+ metadata={
294
+ "status_code": response.status_code,
295
+ "provider": "sendgrid",
296
+ "template_id": template_id,
297
+ },
298
+ )
299
+
300
+ except Exception as e:
301
+ logger.error(f"Failed to send template email via SendGrid: {str(e)}")
302
+ return SendResult(
303
+ success=False,
304
+ error_message=str(e),
305
+ metadata={"provider": "sendgrid", "template_id": template_id},
306
+ )
307
+
308
+ async def get_webhook_data(
309
+ self, webhook_data: List[Dict[str, Any]]
310
+ ) -> List[Dict[str, Any]]:
311
+ """Process SendGrid webhook data"""
312
+ events = []
313
+
314
+ for event in webhook_data:
315
+ processed_event = {
316
+ "message_id": event.get("sg_message_id"),
317
+ "event_type": event.get("event"),
318
+ "timestamp": event.get("timestamp"),
319
+ "email": event.get("email"),
320
+ "reason": event.get("reason"),
321
+ "bounce_classification": event.get("bounce_classification"),
322
+ }
323
+ events.append(processed_event)
324
+
325
+ return events
@@ -0,0 +1,44 @@
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
+ SMTP email provider implementation
11
+ """
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from .base import DeliveryStatus, EmailMessage, EmailProvider, SendResult
16
+
17
+
18
+ class SMTPProvider(EmailProvider):
19
+ """SMTP email provider"""
20
+
21
+ def __init__(self, config: Dict[str, Any]):
22
+ super().__init__(config)
23
+ # TODO: Initialize SMTP connection
24
+
25
+ async def send_email(self, email: EmailMessage) -> SendResult:
26
+ """Send a single email via SMTP"""
27
+ # TODO: Implement SMTP email sending
28
+ return SendResult(
29
+ success=False, error_message="SMTP provider not yet implemented"
30
+ )
31
+
32
+ async def send_batch(self, emails: List[EmailMessage]) -> List[SendResult]:
33
+ """Send multiple emails via SMTP"""
34
+ return [await self.send_email(email) for email in emails]
35
+
36
+ async def get_delivery_status(self, message_id: str) -> Optional[DeliveryStatus]:
37
+ """Get delivery status - not supported by basic SMTP"""
38
+ return None
39
+
40
+ async def process_webhook(
41
+ self, payload: Any, signature: str
42
+ ) -> List[Dict[str, Any]]:
43
+ """Process webhook events - not supported by basic SMTP"""
44
+ return []