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