@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,930 @@
|
|
|
1
|
+
# email-template-manager (Python) - Python SDK Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A comprehensive Python SDK for email template management, providing both low-level components and high-level integrations for FastAPI, Django, and Flask applications. Built with modern Python patterns, async support, and comprehensive type hints.
|
|
6
|
+
|
|
7
|
+
## Core Models
|
|
8
|
+
|
|
9
|
+
### Pydantic Models
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
13
|
+
from typing import List, Optional, Dict, Any, Literal
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
class TemplateVariable(BaseModel):
|
|
18
|
+
name: str
|
|
19
|
+
type: Literal['text', 'number', 'date', 'boolean', 'url']
|
|
20
|
+
description: Optional[str] = None
|
|
21
|
+
default_value: Optional[Any] = None
|
|
22
|
+
required: bool = True
|
|
23
|
+
|
|
24
|
+
class EmailTemplate(BaseModel):
|
|
25
|
+
id: Optional[str] = None
|
|
26
|
+
name: str
|
|
27
|
+
subject: str
|
|
28
|
+
html_body: str
|
|
29
|
+
text_body: str
|
|
30
|
+
variables: List[TemplateVariable] = []
|
|
31
|
+
category_id: Optional[str] = None
|
|
32
|
+
tags: List[str] = []
|
|
33
|
+
is_active: bool = True
|
|
34
|
+
version: int = 1
|
|
35
|
+
created_at: Optional[datetime] = None
|
|
36
|
+
updated_at: Optional[datetime] = None
|
|
37
|
+
created_by: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
class ScheduledEmail(BaseModel):
|
|
40
|
+
id: Optional[str] = None
|
|
41
|
+
template_id: str
|
|
42
|
+
recipient_email: EmailStr
|
|
43
|
+
recipient_name: Optional[str] = None
|
|
44
|
+
scheduled_for: datetime
|
|
45
|
+
trigger_type: Literal['immediate', 'relative', 'absolute']
|
|
46
|
+
trigger_offset: Optional[int] = None # days before/after reference date
|
|
47
|
+
reference_date: Optional[datetime] = None
|
|
48
|
+
variables: Dict[str, Any] = {}
|
|
49
|
+
status: Literal['pending', 'sent', 'failed', 'cancelled', 'paused'] = 'pending'
|
|
50
|
+
sent_at: Optional[datetime] = None
|
|
51
|
+
error_message: Optional[str] = None
|
|
52
|
+
retry_count: int = 0
|
|
53
|
+
max_retries: int = 3
|
|
54
|
+
metadata: Dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
class MagicLink(BaseModel):
|
|
57
|
+
id: Optional[str] = None
|
|
58
|
+
token: str
|
|
59
|
+
email: EmailStr
|
|
60
|
+
type: str
|
|
61
|
+
payload: Dict[str, Any] = {}
|
|
62
|
+
expires_at: datetime
|
|
63
|
+
used_at: Optional[datetime] = None
|
|
64
|
+
is_used: bool = False
|
|
65
|
+
metadata: Dict[str, Any] = {}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### SQLAlchemy Models
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from sqlalchemy import Column, String, Text, DateTime, Boolean, Integer, JSON
|
|
72
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
73
|
+
from sqlalchemy.sql import func
|
|
74
|
+
|
|
75
|
+
Base = declarative_base()
|
|
76
|
+
|
|
77
|
+
class EmailTemplateModel(Base):
|
|
78
|
+
__tablename__ = "email_templates"
|
|
79
|
+
|
|
80
|
+
id = Column(String, primary_key=True)
|
|
81
|
+
name = Column(String, nullable=False)
|
|
82
|
+
subject = Column(String, nullable=False)
|
|
83
|
+
html_body = Column(Text, nullable=False)
|
|
84
|
+
text_body = Column(Text, nullable=False)
|
|
85
|
+
variables = Column(JSON, default=[])
|
|
86
|
+
category_id = Column(String, nullable=True)
|
|
87
|
+
tags = Column(JSON, default=[])
|
|
88
|
+
is_active = Column(Boolean, default=True)
|
|
89
|
+
version = Column(Integer, default=1)
|
|
90
|
+
created_at = Column(DateTime, server_default=func.now())
|
|
91
|
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
|
92
|
+
created_by = Column(String, nullable=True)
|
|
93
|
+
|
|
94
|
+
class ScheduledEmailModel(Base):
|
|
95
|
+
__tablename__ = "scheduled_emails"
|
|
96
|
+
|
|
97
|
+
id = Column(String, primary_key=True)
|
|
98
|
+
template_id = Column(String, nullable=False)
|
|
99
|
+
recipient_email = Column(String, nullable=False)
|
|
100
|
+
recipient_name = Column(String, nullable=True)
|
|
101
|
+
scheduled_for = Column(DateTime, nullable=False)
|
|
102
|
+
trigger_type = Column(String, nullable=False)
|
|
103
|
+
trigger_offset = Column(Integer, nullable=True)
|
|
104
|
+
reference_date = Column(DateTime, nullable=True)
|
|
105
|
+
variables = Column(JSON, default={})
|
|
106
|
+
status = Column(String, default='pending')
|
|
107
|
+
sent_at = Column(DateTime, nullable=True)
|
|
108
|
+
error_message = Column(Text, nullable=True)
|
|
109
|
+
retry_count = Column(Integer, default=0)
|
|
110
|
+
max_retries = Column(Integer, default=3)
|
|
111
|
+
metadata = Column(JSON, default={})
|
|
112
|
+
|
|
113
|
+
class MagicLinkModel(Base):
|
|
114
|
+
__tablename__ = "magic_links"
|
|
115
|
+
|
|
116
|
+
id = Column(String, primary_key=True)
|
|
117
|
+
token = Column(String, unique=True, nullable=False)
|
|
118
|
+
email = Column(String, nullable=False)
|
|
119
|
+
type = Column(String, nullable=False)
|
|
120
|
+
payload = Column(JSON, default={})
|
|
121
|
+
expires_at = Column(DateTime, nullable=False)
|
|
122
|
+
used_at = Column(DateTime, nullable=True)
|
|
123
|
+
is_used = Column(Boolean, default=False)
|
|
124
|
+
metadata = Column(JSON, default={})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Core Classes
|
|
128
|
+
|
|
129
|
+
### EmailTemplateManager
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from typing import Optional, List, Dict, Any
|
|
133
|
+
from sqlalchemy.orm import Session
|
|
134
|
+
from sqlalchemy import create_engine
|
|
135
|
+
|
|
136
|
+
class EmailTemplateManager:
|
|
137
|
+
def __init__(self, database_url: str, echo: bool = False):
|
|
138
|
+
self.engine = create_engine(database_url, echo=echo)
|
|
139
|
+
Base.metadata.create_all(self.engine)
|
|
140
|
+
|
|
141
|
+
def get_session(self) -> Session:
|
|
142
|
+
from sqlalchemy.orm import sessionmaker
|
|
143
|
+
SessionLocal = sessionmaker(bind=self.engine)
|
|
144
|
+
return SessionLocal()
|
|
145
|
+
|
|
146
|
+
async def create_template(self, template: EmailTemplate) -> EmailTemplate:
|
|
147
|
+
"""Create a new email template"""
|
|
148
|
+
# Implementation
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
async def get_template(self, template_id: str) -> Optional[EmailTemplate]:
|
|
152
|
+
"""Get template by ID"""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
async def update_template(self, template_id: str, updates: Dict[str, Any]) -> EmailTemplate:
|
|
156
|
+
"""Update existing template"""
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
async def delete_template(self, template_id: str) -> bool:
|
|
160
|
+
"""Delete template"""
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
async def list_templates(
|
|
164
|
+
self,
|
|
165
|
+
skip: int = 0,
|
|
166
|
+
limit: int = 100,
|
|
167
|
+
filters: Optional[Dict[str, Any]] = None
|
|
168
|
+
) -> List[EmailTemplate]:
|
|
169
|
+
"""List templates with pagination and filtering"""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
async def render_template(
|
|
173
|
+
self,
|
|
174
|
+
template_id: str,
|
|
175
|
+
variables: Dict[str, Any]
|
|
176
|
+
) -> Dict[str, str]:
|
|
177
|
+
"""Render template with variables"""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def validate_template_variables(
|
|
181
|
+
self,
|
|
182
|
+
template: EmailTemplate,
|
|
183
|
+
variables: Dict[str, Any]
|
|
184
|
+
) -> Dict[str, Any]:
|
|
185
|
+
"""Validate template variables"""
|
|
186
|
+
pass
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### EmailScheduler
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
import asyncio
|
|
193
|
+
from datetime import datetime, timedelta
|
|
194
|
+
from typing import List, Optional, Dict, Any
|
|
195
|
+
|
|
196
|
+
class EmailScheduler:
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
template_manager: EmailTemplateManager,
|
|
200
|
+
email_provider: 'EmailProvider',
|
|
201
|
+
max_retries: int = 3,
|
|
202
|
+
batch_size: int = 100
|
|
203
|
+
):
|
|
204
|
+
self.template_manager = template_manager
|
|
205
|
+
self.email_provider = email_provider
|
|
206
|
+
self.max_retries = max_retries
|
|
207
|
+
self.batch_size = batch_size
|
|
208
|
+
|
|
209
|
+
async def schedule_email(self, email: ScheduledEmail) -> ScheduledEmail:
|
|
210
|
+
"""Schedule a single email"""
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
async def schedule_batch(self, emails: List[ScheduledEmail]) -> List[ScheduledEmail]:
|
|
214
|
+
"""Schedule multiple emails"""
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
async def cancel_scheduled_email(self, email_id: str) -> bool:
|
|
218
|
+
"""Cancel a scheduled email"""
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
async def pause_scheduled_email(self, email_id: str) -> bool:
|
|
222
|
+
"""Pause a scheduled email"""
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
async def resume_scheduled_email(self, email_id: str) -> bool:
|
|
226
|
+
"""Resume a paused email"""
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def process_pending_emails(self) -> Dict[str, Any]:
|
|
230
|
+
"""Process all pending emails"""
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
async def retry_failed_emails(self) -> Dict[str, Any]:
|
|
234
|
+
"""Retry failed emails"""
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
async def get_scheduled_email(self, email_id: str) -> Optional[ScheduledEmail]:
|
|
238
|
+
"""Get scheduled email by ID"""
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
async def list_scheduled_emails(
|
|
242
|
+
self,
|
|
243
|
+
skip: int = 0,
|
|
244
|
+
limit: int = 100,
|
|
245
|
+
filters: Optional[Dict[str, Any]] = None
|
|
246
|
+
) -> List[ScheduledEmail]:
|
|
247
|
+
"""List scheduled emails"""
|
|
248
|
+
pass
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### MagicLinkManager
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
import secrets
|
|
255
|
+
import hashlib
|
|
256
|
+
from datetime import datetime, timedelta
|
|
257
|
+
from typing import Optional, Dict, Any
|
|
258
|
+
|
|
259
|
+
class MagicLinkManager:
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
database_manager: EmailTemplateManager,
|
|
263
|
+
encryption_key: str,
|
|
264
|
+
default_expiry_hours: int = 24
|
|
265
|
+
):
|
|
266
|
+
self.db = database_manager
|
|
267
|
+
self.encryption_key = encryption_key
|
|
268
|
+
self.default_expiry_hours = default_expiry_hours
|
|
269
|
+
|
|
270
|
+
async def generate_magic_link(
|
|
271
|
+
self,
|
|
272
|
+
email: str,
|
|
273
|
+
link_type: str,
|
|
274
|
+
payload: Dict[str, Any],
|
|
275
|
+
expires_in_hours: Optional[int] = None
|
|
276
|
+
) -> MagicLink:
|
|
277
|
+
"""Generate a new magic link"""
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
async def validate_magic_link(self, token: str) -> Optional[MagicLink]:
|
|
281
|
+
"""Validate magic link token"""
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
async def use_magic_link(self, token: str) -> MagicLink:
|
|
285
|
+
"""Mark magic link as used and return it"""
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
async def revoke_magic_link(self, token: str) -> bool:
|
|
289
|
+
"""Revoke a magic link"""
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
async def cleanup_expired_links(self) -> int:
|
|
293
|
+
"""Clean up expired magic links"""
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
def _generate_token(self, length: int = 32) -> str:
|
|
297
|
+
"""Generate secure random token"""
|
|
298
|
+
return secrets.token_urlsafe(length)
|
|
299
|
+
|
|
300
|
+
def _hash_token(self, token: str) -> str:
|
|
301
|
+
"""Hash token for storage"""
|
|
302
|
+
return hashlib.sha256(f"{token}{self.encryption_key}".encode()).hexdigest()
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Email Providers
|
|
306
|
+
|
|
307
|
+
### Abstract Base Provider
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from abc import ABC, abstractmethod
|
|
311
|
+
from typing import List, Dict, Any, Optional
|
|
312
|
+
|
|
313
|
+
class EmailProvider(ABC):
|
|
314
|
+
def __init__(self, config: Dict[str, Any]):
|
|
315
|
+
self.config = config
|
|
316
|
+
|
|
317
|
+
@abstractmethod
|
|
318
|
+
async def send_email(
|
|
319
|
+
self,
|
|
320
|
+
to_email: str,
|
|
321
|
+
subject: str,
|
|
322
|
+
html_body: str,
|
|
323
|
+
text_body: str,
|
|
324
|
+
from_email: Optional[str] = None,
|
|
325
|
+
from_name: Optional[str] = None,
|
|
326
|
+
reply_to: Optional[str] = None,
|
|
327
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
328
|
+
) -> Dict[str, Any]:
|
|
329
|
+
"""Send a single email"""
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
@abstractmethod
|
|
333
|
+
async def send_batch(self, emails: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
334
|
+
"""Send multiple emails"""
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
@abstractmethod
|
|
338
|
+
async def get_delivery_status(self, message_id: str) -> Dict[str, Any]:
|
|
339
|
+
"""Get delivery status for a message"""
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
@abstractmethod
|
|
343
|
+
async def process_webhook(self, payload: Dict[str, Any], signature: str) -> List[Dict[str, Any]]:
|
|
344
|
+
"""Process webhook from email provider"""
|
|
345
|
+
pass
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### SendGrid Provider
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
import sendgrid
|
|
352
|
+
from sendgrid.helpers.mail import Mail, Email, To, Content
|
|
353
|
+
|
|
354
|
+
class SendGridProvider(EmailProvider):
|
|
355
|
+
def __init__(self, config: Dict[str, Any]):
|
|
356
|
+
super().__init__(config)
|
|
357
|
+
self.client = sendgrid.SendGridAPIClient(api_key=config['api_key'])
|
|
358
|
+
self.from_email = config['from_email']
|
|
359
|
+
self.from_name = config.get('from_name', '')
|
|
360
|
+
|
|
361
|
+
async def send_email(
|
|
362
|
+
self,
|
|
363
|
+
to_email: str,
|
|
364
|
+
subject: str,
|
|
365
|
+
html_body: str,
|
|
366
|
+
text_body: str,
|
|
367
|
+
from_email: Optional[str] = None,
|
|
368
|
+
from_name: Optional[str] = None,
|
|
369
|
+
reply_to: Optional[str] = None,
|
|
370
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
371
|
+
) -> Dict[str, Any]:
|
|
372
|
+
"""Send email using SendGrid"""
|
|
373
|
+
# Implementation
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
async def send_batch(self, emails: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
377
|
+
"""Send batch emails using SendGrid"""
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
async def get_delivery_status(self, message_id: str) -> Dict[str, Any]:
|
|
381
|
+
"""Get delivery status from SendGrid"""
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
async def process_webhook(self, payload: Dict[str, Any], signature: str) -> List[Dict[str, Any]]:
|
|
385
|
+
"""Process SendGrid webhook"""
|
|
386
|
+
pass
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### AWS SES Provider
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
import boto3
|
|
393
|
+
from botocore.exceptions import ClientError
|
|
394
|
+
|
|
395
|
+
class SESProvider(EmailProvider):
|
|
396
|
+
def __init__(self, config: Dict[str, Any]):
|
|
397
|
+
super().__init__(config)
|
|
398
|
+
self.client = boto3.client(
|
|
399
|
+
'ses',
|
|
400
|
+
region_name=config.get('region', 'us-east-1'),
|
|
401
|
+
aws_access_key_id=config.get('access_key_id'),
|
|
402
|
+
aws_secret_access_key=config.get('secret_access_key')
|
|
403
|
+
)
|
|
404
|
+
self.from_email = config['from_email']
|
|
405
|
+
|
|
406
|
+
async def send_email(self, **kwargs) -> Dict[str, Any]:
|
|
407
|
+
"""Send email using AWS SES"""
|
|
408
|
+
# Implementation
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
# ... other methods
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Framework Integrations
|
|
415
|
+
|
|
416
|
+
### FastAPI Integration
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
|
|
420
|
+
from fastapi.responses import JSONResponse
|
|
421
|
+
from typing import List, Optional
|
|
422
|
+
|
|
423
|
+
def create_email_router(
|
|
424
|
+
template_manager: EmailTemplateManager,
|
|
425
|
+
scheduler: EmailScheduler,
|
|
426
|
+
magic_link_manager: MagicLinkManager
|
|
427
|
+
):
|
|
428
|
+
from fastapi import APIRouter
|
|
429
|
+
router = APIRouter(prefix="/email", tags=["email"])
|
|
430
|
+
|
|
431
|
+
@router.post("/templates/", response_model=EmailTemplate)
|
|
432
|
+
async def create_template(template: EmailTemplate):
|
|
433
|
+
"""Create a new email template"""
|
|
434
|
+
return await template_manager.create_template(template)
|
|
435
|
+
|
|
436
|
+
@router.get("/templates/", response_model=List[EmailTemplate])
|
|
437
|
+
async def list_templates(
|
|
438
|
+
skip: int = 0,
|
|
439
|
+
limit: int = 100,
|
|
440
|
+
category: Optional[str] = None
|
|
441
|
+
):
|
|
442
|
+
"""List email templates"""
|
|
443
|
+
filters = {"category_id": category} if category else None
|
|
444
|
+
return await template_manager.list_templates(skip, limit, filters)
|
|
445
|
+
|
|
446
|
+
@router.get("/templates/{template_id}", response_model=EmailTemplate)
|
|
447
|
+
async def get_template(template_id: str):
|
|
448
|
+
"""Get template by ID"""
|
|
449
|
+
template = await template_manager.get_template(template_id)
|
|
450
|
+
if not template:
|
|
451
|
+
raise HTTPException(status_code=404, detail="Template not found")
|
|
452
|
+
return template
|
|
453
|
+
|
|
454
|
+
@router.put("/templates/{template_id}", response_model=EmailTemplate)
|
|
455
|
+
async def update_template(template_id: str, updates: Dict[str, Any]):
|
|
456
|
+
"""Update template"""
|
|
457
|
+
return await template_manager.update_template(template_id, updates)
|
|
458
|
+
|
|
459
|
+
@router.delete("/templates/{template_id}")
|
|
460
|
+
async def delete_template(template_id: str):
|
|
461
|
+
"""Delete template"""
|
|
462
|
+
success = await template_manager.delete_template(template_id)
|
|
463
|
+
if not success:
|
|
464
|
+
raise HTTPException(status_code=404, detail="Template not found")
|
|
465
|
+
return {"success": True}
|
|
466
|
+
|
|
467
|
+
@router.post("/schedule/", response_model=ScheduledEmail)
|
|
468
|
+
async def schedule_email(email: ScheduledEmail, background_tasks: BackgroundTasks):
|
|
469
|
+
"""Schedule an email"""
|
|
470
|
+
scheduled = await scheduler.schedule_email(email)
|
|
471
|
+
if email.trigger_type == 'immediate':
|
|
472
|
+
background_tasks.add_task(scheduler.process_pending_emails)
|
|
473
|
+
return scheduled
|
|
474
|
+
|
|
475
|
+
@router.post("/schedule/batch/", response_model=List[ScheduledEmail])
|
|
476
|
+
async def schedule_batch(emails: List[ScheduledEmail]):
|
|
477
|
+
"""Schedule multiple emails"""
|
|
478
|
+
return await scheduler.schedule_batch(emails)
|
|
479
|
+
|
|
480
|
+
@router.post("/magic-links/generate/", response_model=MagicLink)
|
|
481
|
+
async def generate_magic_link(
|
|
482
|
+
email: str,
|
|
483
|
+
link_type: str,
|
|
484
|
+
payload: Dict[str, Any],
|
|
485
|
+
expires_in_hours: Optional[int] = None
|
|
486
|
+
):
|
|
487
|
+
"""Generate magic link"""
|
|
488
|
+
return await magic_link_manager.generate_magic_link(
|
|
489
|
+
email, link_type, payload, expires_in_hours
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
@router.post("/magic-links/validate/")
|
|
493
|
+
async def validate_magic_link(token: str):
|
|
494
|
+
"""Validate magic link"""
|
|
495
|
+
link = await magic_link_manager.validate_magic_link(token)
|
|
496
|
+
if not link:
|
|
497
|
+
raise HTTPException(status_code=404, detail="Invalid or expired token")
|
|
498
|
+
return link
|
|
499
|
+
|
|
500
|
+
@router.post("/magic-links/use/")
|
|
501
|
+
async def use_magic_link(token: str):
|
|
502
|
+
"""Use magic link"""
|
|
503
|
+
try:
|
|
504
|
+
link = await magic_link_manager.use_magic_link(token)
|
|
505
|
+
return link
|
|
506
|
+
except Exception as e:
|
|
507
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
508
|
+
|
|
509
|
+
return router
|
|
510
|
+
|
|
511
|
+
# Usage example
|
|
512
|
+
def create_app():
|
|
513
|
+
app = FastAPI(title="Email Template Manager")
|
|
514
|
+
|
|
515
|
+
# Initialize managers
|
|
516
|
+
template_manager = EmailTemplateManager("postgresql://...")
|
|
517
|
+
email_provider = SendGridProvider({"api_key": "...", "from_email": "..."})
|
|
518
|
+
scheduler = EmailScheduler(template_manager, email_provider)
|
|
519
|
+
magic_link_manager = MagicLinkManager(template_manager, "encryption_key")
|
|
520
|
+
|
|
521
|
+
# Include router
|
|
522
|
+
email_router = create_email_router(template_manager, scheduler, magic_link_manager)
|
|
523
|
+
app.include_router(email_router)
|
|
524
|
+
|
|
525
|
+
return app
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Django Integration
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from django.conf import settings
|
|
532
|
+
from django.http import JsonResponse
|
|
533
|
+
from django.views.decorators.http import require_http_methods
|
|
534
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
535
|
+
from django.utils.decorators import method_decorator
|
|
536
|
+
from django.views import View
|
|
537
|
+
import json
|
|
538
|
+
|
|
539
|
+
class EmailTemplateView(View):
|
|
540
|
+
def __init__(self):
|
|
541
|
+
self.template_manager = EmailTemplateManager(settings.DATABASE_URL)
|
|
542
|
+
self.scheduler = EmailScheduler(self.template_manager, self.get_email_provider())
|
|
543
|
+
|
|
544
|
+
def get_email_provider(self):
|
|
545
|
+
provider_config = settings.EMAIL_PROVIDER_CONFIG
|
|
546
|
+
if provider_config['provider'] == 'sendgrid':
|
|
547
|
+
return SendGridProvider(provider_config)
|
|
548
|
+
# ... other providers
|
|
549
|
+
|
|
550
|
+
@method_decorator(csrf_exempt)
|
|
551
|
+
def dispatch(self, request, *args, **kwargs):
|
|
552
|
+
return super().dispatch(request, *args, **kwargs)
|
|
553
|
+
|
|
554
|
+
async def post(self, request):
|
|
555
|
+
"""Create email template"""
|
|
556
|
+
data = json.loads(request.body)
|
|
557
|
+
template = EmailTemplate(**data)
|
|
558
|
+
created_template = await self.template_manager.create_template(template)
|
|
559
|
+
return JsonResponse(created_template.dict())
|
|
560
|
+
|
|
561
|
+
async def get(self, request):
|
|
562
|
+
"""List email templates"""
|
|
563
|
+
templates = await self.template_manager.list_templates()
|
|
564
|
+
return JsonResponse([t.dict() for t in templates], safe=False)
|
|
565
|
+
|
|
566
|
+
# Django settings configuration
|
|
567
|
+
EMAIL_TEMPLATE_MANAGER = {
|
|
568
|
+
'DATABASE_URL': 'postgresql://...',
|
|
569
|
+
'EMAIL_PROVIDER_CONFIG': {
|
|
570
|
+
'provider': 'sendgrid',
|
|
571
|
+
'api_key': 'your-sendgrid-api-key',
|
|
572
|
+
'from_email': 'your-email@domain.com',
|
|
573
|
+
'from_name': 'Your App'
|
|
574
|
+
},
|
|
575
|
+
'MAGIC_LINK_CONFIG': {
|
|
576
|
+
'encryption_key': 'your-encryption-key',
|
|
577
|
+
'default_expiry_hours': 24
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### Flask Integration
|
|
583
|
+
|
|
584
|
+
```python
|
|
585
|
+
from flask import Flask, request, jsonify, Blueprint
|
|
586
|
+
from typing import Dict, Any
|
|
587
|
+
|
|
588
|
+
def create_email_blueprint(
|
|
589
|
+
template_manager: EmailTemplateManager,
|
|
590
|
+
scheduler: EmailScheduler,
|
|
591
|
+
magic_link_manager: MagicLinkManager
|
|
592
|
+
) -> Blueprint:
|
|
593
|
+
bp = Blueprint('email', __name__, url_prefix='/email')
|
|
594
|
+
|
|
595
|
+
@bp.route('/templates/', methods=['POST'])
|
|
596
|
+
async def create_template():
|
|
597
|
+
data = request.get_json()
|
|
598
|
+
template = EmailTemplate(**data)
|
|
599
|
+
created_template = await template_manager.create_template(template)
|
|
600
|
+
return jsonify(created_template.dict())
|
|
601
|
+
|
|
602
|
+
@bp.route('/templates/', methods=['GET'])
|
|
603
|
+
async def list_templates():
|
|
604
|
+
skip = request.args.get('skip', 0, type=int)
|
|
605
|
+
limit = request.args.get('limit', 100, type=int)
|
|
606
|
+
templates = await template_manager.list_templates(skip, limit)
|
|
607
|
+
return jsonify([t.dict() for t in templates])
|
|
608
|
+
|
|
609
|
+
@bp.route('/schedule/', methods=['POST'])
|
|
610
|
+
async def schedule_email():
|
|
611
|
+
data = request.get_json()
|
|
612
|
+
scheduled_email = ScheduledEmail(**data)
|
|
613
|
+
result = await scheduler.schedule_email(scheduled_email)
|
|
614
|
+
return jsonify(result.dict())
|
|
615
|
+
|
|
616
|
+
return bp
|
|
617
|
+
|
|
618
|
+
# Usage
|
|
619
|
+
def create_app():
|
|
620
|
+
app = Flask(__name__)
|
|
621
|
+
|
|
622
|
+
# Initialize managers
|
|
623
|
+
template_manager = EmailTemplateManager("postgresql://...")
|
|
624
|
+
email_provider = SendGridProvider({"api_key": "...", "from_email": "..."})
|
|
625
|
+
scheduler = EmailScheduler(template_manager, email_provider)
|
|
626
|
+
magic_link_manager = MagicLinkManager(template_manager, "encryption_key")
|
|
627
|
+
|
|
628
|
+
# Register blueprint
|
|
629
|
+
email_bp = create_email_blueprint(template_manager, scheduler, magic_link_manager)
|
|
630
|
+
app.register_blueprint(email_bp)
|
|
631
|
+
|
|
632
|
+
return app
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
## Utilities and Helpers
|
|
636
|
+
|
|
637
|
+
### Template Rendering
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
from jinja2 import Environment, BaseLoader
|
|
641
|
+
import re
|
|
642
|
+
from typing import Dict, Any
|
|
643
|
+
|
|
644
|
+
class LiquidTemplateRenderer:
|
|
645
|
+
def __init__(self):
|
|
646
|
+
self.env = Environment(loader=BaseLoader())
|
|
647
|
+
|
|
648
|
+
def render(self, template_content: str, variables: Dict[str, Any]) -> str:
|
|
649
|
+
"""Render liquid template with variables"""
|
|
650
|
+
template = self.env.from_string(template_content)
|
|
651
|
+
return template.render(**variables)
|
|
652
|
+
|
|
653
|
+
def validate_template(self, template_content: str) -> Dict[str, Any]:
|
|
654
|
+
"""Validate template syntax"""
|
|
655
|
+
try:
|
|
656
|
+
self.env.from_string(template_content)
|
|
657
|
+
return {"valid": True, "errors": []}
|
|
658
|
+
except Exception as e:
|
|
659
|
+
return {"valid": False, "errors": [str(e)]}
|
|
660
|
+
|
|
661
|
+
def extract_variables(self, template_content: str) -> List[str]:
|
|
662
|
+
"""Extract variable names from template"""
|
|
663
|
+
pattern = r'\{\{\s*(\w+)\s*\}\}'
|
|
664
|
+
return list(set(re.findall(pattern, template_content)))
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Validation Utilities
|
|
668
|
+
|
|
669
|
+
```python
|
|
670
|
+
import re
|
|
671
|
+
from typing import Dict, Any, List
|
|
672
|
+
from email_validator import validate_email, EmailNotValidError
|
|
673
|
+
|
|
674
|
+
class ValidationUtils:
|
|
675
|
+
@staticmethod
|
|
676
|
+
def validate_email_address(email: str) -> bool:
|
|
677
|
+
"""Validate email address format"""
|
|
678
|
+
try:
|
|
679
|
+
validate_email(email)
|
|
680
|
+
return True
|
|
681
|
+
except EmailNotValidError:
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def validate_template_variables(
|
|
686
|
+
template: EmailTemplate,
|
|
687
|
+
variables: Dict[str, Any]
|
|
688
|
+
) -> Dict[str, Any]:
|
|
689
|
+
"""Validate template variables against schema"""
|
|
690
|
+
errors = []
|
|
691
|
+
warnings = []
|
|
692
|
+
|
|
693
|
+
for var_def in template.variables:
|
|
694
|
+
if var_def.required and var_def.name not in variables:
|
|
695
|
+
errors.append(f"Required variable '{var_def.name}' is missing")
|
|
696
|
+
elif var_def.name in variables:
|
|
697
|
+
value = variables[var_def.name]
|
|
698
|
+
if not ValidationUtils._validate_variable_type(value, var_def.type):
|
|
699
|
+
errors.append(f"Variable '{var_def.name}' has invalid type")
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
"valid": len(errors) == 0,
|
|
703
|
+
"errors": errors,
|
|
704
|
+
"warnings": warnings
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
@staticmethod
|
|
708
|
+
def _validate_variable_type(value: Any, expected_type: str) -> bool:
|
|
709
|
+
"""Validate individual variable type"""
|
|
710
|
+
type_validators = {
|
|
711
|
+
'text': lambda v: isinstance(v, str),
|
|
712
|
+
'number': lambda v: isinstance(v, (int, float)),
|
|
713
|
+
'date': lambda v: isinstance(v, str) and ValidationUtils._is_valid_date(v),
|
|
714
|
+
'boolean': lambda v: isinstance(v, bool),
|
|
715
|
+
'url': lambda v: isinstance(v, str) and ValidationUtils._is_valid_url(v)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
validator = type_validators.get(expected_type)
|
|
719
|
+
return validator(value) if validator else True
|
|
720
|
+
|
|
721
|
+
@staticmethod
|
|
722
|
+
def _is_valid_date(date_str: str) -> bool:
|
|
723
|
+
"""Check if string is valid date"""
|
|
724
|
+
try:
|
|
725
|
+
from datetime import datetime
|
|
726
|
+
datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
|
727
|
+
return True
|
|
728
|
+
except ValueError:
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
@staticmethod
|
|
732
|
+
def _is_valid_url(url: str) -> bool:
|
|
733
|
+
"""Check if string is valid URL"""
|
|
734
|
+
url_pattern = re.compile(
|
|
735
|
+
r'^https?://' # http:// or https://
|
|
736
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
|
737
|
+
r'localhost|' # localhost...
|
|
738
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
|
739
|
+
r'(?::\d+)?' # optional port
|
|
740
|
+
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
|
741
|
+
return url_pattern.match(url) is not None
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
## CLI Tools
|
|
745
|
+
|
|
746
|
+
### Command Line Interface
|
|
747
|
+
|
|
748
|
+
```python
|
|
749
|
+
import click
|
|
750
|
+
import asyncio
|
|
751
|
+
from typing import Optional
|
|
752
|
+
|
|
753
|
+
@click.group()
|
|
754
|
+
def cli():
|
|
755
|
+
"""Email Template Manager CLI"""
|
|
756
|
+
pass
|
|
757
|
+
|
|
758
|
+
@cli.command()
|
|
759
|
+
@click.option('--database-url', required=True, help='Database connection URL')
|
|
760
|
+
@click.option('--name', required=True, help='Template name')
|
|
761
|
+
@click.option('--subject', required=True, help='Email subject')
|
|
762
|
+
@click.option('--html-file', required=True, help='HTML template file')
|
|
763
|
+
@click.option('--text-file', help='Text template file')
|
|
764
|
+
def create_template(database_url: str, name: str, subject: str, html_file: str, text_file: Optional[str]):
|
|
765
|
+
"""Create a new email template"""
|
|
766
|
+
async def _create():
|
|
767
|
+
manager = EmailTemplateManager(database_url)
|
|
768
|
+
|
|
769
|
+
with open(html_file, 'r') as f:
|
|
770
|
+
html_body = f.read()
|
|
771
|
+
|
|
772
|
+
text_body = ""
|
|
773
|
+
if text_file:
|
|
774
|
+
with open(text_file, 'r') as f:
|
|
775
|
+
text_body = f.read()
|
|
776
|
+
|
|
777
|
+
template = EmailTemplate(
|
|
778
|
+
name=name,
|
|
779
|
+
subject=subject,
|
|
780
|
+
html_body=html_body,
|
|
781
|
+
text_body=text_body
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
result = await manager.create_template(template)
|
|
785
|
+
click.echo(f"Created template: {result.id}")
|
|
786
|
+
|
|
787
|
+
asyncio.run(_create())
|
|
788
|
+
|
|
789
|
+
@cli.command()
|
|
790
|
+
@click.option('--database-url', required=True)
|
|
791
|
+
def process_emails(database_url: str):
|
|
792
|
+
"""Process pending scheduled emails"""
|
|
793
|
+
async def _process():
|
|
794
|
+
manager = EmailTemplateManager(database_url)
|
|
795
|
+
# Initialize email provider from config
|
|
796
|
+
provider = SendGridProvider({"api_key": "...", "from_email": "..."})
|
|
797
|
+
scheduler = EmailScheduler(manager, provider)
|
|
798
|
+
|
|
799
|
+
result = await scheduler.process_pending_emails()
|
|
800
|
+
click.echo(f"Processed {result['processed']} emails")
|
|
801
|
+
|
|
802
|
+
asyncio.run(_process())
|
|
803
|
+
|
|
804
|
+
if __name__ == '__main__':
|
|
805
|
+
cli()
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
## Testing Framework
|
|
809
|
+
|
|
810
|
+
### Test Utilities
|
|
811
|
+
|
|
812
|
+
```python
|
|
813
|
+
import pytest
|
|
814
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
815
|
+
from typing import Dict, Any, Optional
|
|
816
|
+
|
|
817
|
+
class MockEmailProvider(EmailProvider):
|
|
818
|
+
def __init__(self):
|
|
819
|
+
self.sent_emails = []
|
|
820
|
+
self.delivery_statuses = {}
|
|
821
|
+
|
|
822
|
+
async def send_email(self, **kwargs) -> Dict[str, Any]:
|
|
823
|
+
email_data = kwargs.copy()
|
|
824
|
+
message_id = f"mock_{len(self.sent_emails)}"
|
|
825
|
+
email_data['message_id'] = message_id
|
|
826
|
+
self.sent_emails.append(email_data)
|
|
827
|
+
return {"message_id": message_id, "status": "sent"}
|
|
828
|
+
|
|
829
|
+
async def send_batch(self, emails: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
830
|
+
results = []
|
|
831
|
+
for email in emails:
|
|
832
|
+
result = await self.send_email(**email)
|
|
833
|
+
results.append(result)
|
|
834
|
+
return results
|
|
835
|
+
|
|
836
|
+
async def get_delivery_status(self, message_id: str) -> Dict[str, Any]:
|
|
837
|
+
return self.delivery_statuses.get(message_id, {"status": "delivered"})
|
|
838
|
+
|
|
839
|
+
async def process_webhook(self, payload: Dict[str, Any], signature: str) -> List[Dict[str, Any]]:
|
|
840
|
+
return [payload]
|
|
841
|
+
|
|
842
|
+
@pytest.fixture
|
|
843
|
+
def mock_email_provider():
|
|
844
|
+
return MockEmailProvider()
|
|
845
|
+
|
|
846
|
+
@pytest.fixture
|
|
847
|
+
def template_manager():
|
|
848
|
+
# Use in-memory SQLite for testing
|
|
849
|
+
return EmailTemplateManager("sqlite:///:memory:")
|
|
850
|
+
|
|
851
|
+
@pytest.fixture
|
|
852
|
+
def sample_template():
|
|
853
|
+
return EmailTemplate(
|
|
854
|
+
name="Test Template",
|
|
855
|
+
subject="Hello {{name}}",
|
|
856
|
+
html_body="<h1>Hello {{name}}</h1><p>{{message}}</p>",
|
|
857
|
+
text_body="Hello {{name}}\n\n{{message}}",
|
|
858
|
+
variables=[
|
|
859
|
+
TemplateVariable(name="name", type="text", required=True),
|
|
860
|
+
TemplateVariable(name="message", type="text", required=True)
|
|
861
|
+
]
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Example test
|
|
865
|
+
@pytest.mark.asyncio
|
|
866
|
+
async def test_create_template(template_manager, sample_template):
|
|
867
|
+
created = await template_manager.create_template(sample_template)
|
|
868
|
+
assert created.id is not None
|
|
869
|
+
assert created.name == sample_template.name
|
|
870
|
+
assert created.subject == sample_template.subject
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
## Package Configuration
|
|
874
|
+
|
|
875
|
+
### setup.py
|
|
876
|
+
|
|
877
|
+
```python
|
|
878
|
+
from setuptools import setup, find_packages
|
|
879
|
+
|
|
880
|
+
setup(
|
|
881
|
+
name="email-template-manager",
|
|
882
|
+
version="1.0.0",
|
|
883
|
+
description="Universal email template management library",
|
|
884
|
+
author="Your Name",
|
|
885
|
+
author_email="your.email@example.com",
|
|
886
|
+
url="https://github.com/your-org/email-template-manager",
|
|
887
|
+
packages=find_packages(),
|
|
888
|
+
install_requires=[
|
|
889
|
+
"pydantic>=1.8.0",
|
|
890
|
+
"sqlalchemy>=1.4.0",
|
|
891
|
+
"alembic>=1.7.0",
|
|
892
|
+
"jinja2>=3.0.0",
|
|
893
|
+
"email-validator>=1.1.0",
|
|
894
|
+
"cryptography>=3.4.0",
|
|
895
|
+
"click>=8.0.0",
|
|
896
|
+
],
|
|
897
|
+
extras_require={
|
|
898
|
+
"fastapi": ["fastapi>=0.68.0", "uvicorn>=0.15.0"],
|
|
899
|
+
"django": ["django>=3.2.0"],
|
|
900
|
+
"flask": ["flask>=2.0.0"],
|
|
901
|
+
"sendgrid": ["sendgrid>=6.8.0"],
|
|
902
|
+
"ses": ["boto3>=1.18.0"],
|
|
903
|
+
"mailgun": ["requests>=2.26.0"],
|
|
904
|
+
"postmark": ["postmarker>=0.15.0"],
|
|
905
|
+
"test": [
|
|
906
|
+
"pytest>=6.2.0",
|
|
907
|
+
"pytest-asyncio>=0.15.0",
|
|
908
|
+
"pytest-cov>=2.12.0",
|
|
909
|
+
]
|
|
910
|
+
},
|
|
911
|
+
entry_points={
|
|
912
|
+
"console_scripts": [
|
|
913
|
+
"email-template-manager=email_template_manager.cli:cli",
|
|
914
|
+
],
|
|
915
|
+
},
|
|
916
|
+
classifiers=[
|
|
917
|
+
"Development Status :: 4 - Beta",
|
|
918
|
+
"Intended Audience :: Developers",
|
|
919
|
+
"License :: OSI Approved :: MIT License",
|
|
920
|
+
"Programming Language :: Python :: 3",
|
|
921
|
+
"Programming Language :: Python :: 3.8",
|
|
922
|
+
"Programming Language :: Python :: 3.9",
|
|
923
|
+
"Programming Language :: Python :: 3.10",
|
|
924
|
+
"Programming Language :: Python :: 3.11",
|
|
925
|
+
],
|
|
926
|
+
python_requires=">=3.8",
|
|
927
|
+
)
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
This Python SDK provides comprehensive email template management capabilities with support for major Python web frameworks and email providers.
|