Skip to content

Commit d8e2200

Browse files
Merge pull request #1 from radomirbrkovic/v2
Refactoring MailBridge and change for version v2
2 parents b33e4d7 + 296aa2b commit d8e2200

27 files changed

Lines changed: 4998 additions & 453 deletions

mailbridge/client.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from typing import Optional, Dict, Any, Union, List
2+
from pathlib import Path
3+
4+
from mailbridge.dto.email_message_dto import EmailMessageDto
5+
from mailbridge.dto.email_response_dto import EmailResponseDTO
6+
from mailbridge.dto.bulk_email_dto import BulkEmailDTO
7+
from mailbridge.dto.bulk_email_response_dto import BulkEmailResponseDTO
8+
from mailbridge.exceptions import ProviderNotFoundError
9+
from mailbridge.providers.base_email_provider import BaseEmailProvider
10+
from mailbridge.providers.brevo_provider import BrevoProvider
11+
from mailbridge.providers.mailgun_provider import MailgunProvider
12+
from mailbridge.providers.postmark_provider import PostmarkProvider
13+
from mailbridge.providers.sendgrid_provider import SendGridProvider
14+
from mailbridge.providers.ses_provider import SESProvider
15+
from mailbridge.providers.smtp_provider import SMTPProvider
16+
17+
class MailBridge:
18+
19+
PROVIDERS = {
20+
'smtp': SMTPProvider,
21+
'sendgrid': SendGridProvider,
22+
'mailgun': MailgunProvider,
23+
'ses': SESProvider,
24+
'postmark': PostmarkProvider,
25+
'brevo': BrevoProvider,
26+
}
27+
28+
def __init__(self, provider: str, **config):
29+
self.provider_name = provider.lower()
30+
if self.provider_name not in self.PROVIDERS:
31+
available = ', '.join(self.PROVIDERS.keys())
32+
raise ProviderNotFoundError(
33+
f"Provider '{provider}' not found. Available providers: {available}"
34+
)
35+
36+
provider_class = self.PROVIDERS[self.provider_name]
37+
self.provider: BaseEmailProvider = provider_class(**config)
38+
39+
def send(
40+
self,
41+
to: Union[str, List[str]],
42+
subject: str,
43+
body: str,
44+
from_email: Optional[str] = None,
45+
cc: Optional[Union[str, List[str]]] = None,
46+
bcc: Optional[Union[str, List[str]]] = None,
47+
reply_to: Optional[str] = None,
48+
attachments: Optional[List[Union[Path, tuple]]] = None,
49+
html: bool = True,
50+
headers: Optional[Dict[str, str]] = None,
51+
template_id: Optional[str] = None,
52+
template_data: Optional[Dict[str, Any]] = None,
53+
tags: Optional[List[str]] = None
54+
) -> EmailResponseDTO:
55+
"""
56+
Send an email.
57+
58+
Args:
59+
to: Recipient email address(es)
60+
subject: Email subject
61+
body: Email body (HTML or plain text)
62+
from_email: Sender email address (optional if set in config)
63+
cc: CC recipients (optional)
64+
bcc: BCC recipients (optional)
65+
reply_to: Reply-To address (optional)
66+
attachments: List of file paths or tuples (filename, content, mimetype)
67+
html: Whether body is HTML (default: True)
68+
headers: Custom headers (optional)
69+
template_id: Template id (optional)
70+
template_data: Template varibales (optional)
71+
tags: Tags (optional)
72+
73+
Returns:
74+
Dict with response data (success, message_id, etc.)
75+
76+
Raises:
77+
EmailSendError: If sending fails
78+
79+
Examples:
80+
>>> mailer.send(
81+
... to='user@example.com',
82+
... subject='Welcome',
83+
... body='<h1>Hello!</h1>'
84+
... )
85+
86+
>>> mailer.send(
87+
... to=['user1@example.com', 'user2@example.com'],
88+
... subject='Newsletter',
89+
... body='Content here',
90+
... attachments=[Path('report.pdf')]
91+
... )
92+
"""
93+
message = EmailMessageDto(
94+
to=to,
95+
subject=subject,
96+
body=body,
97+
from_email=from_email,
98+
cc=cc,
99+
bcc=bcc,
100+
reply_to=reply_to,
101+
attachments=attachments,
102+
html=html,
103+
headers=headers,
104+
template_id=template_id,
105+
template_data=template_data,
106+
tags=tags
107+
)
108+
109+
return self.provider.send(message)
110+
111+
def send_bulk(
112+
self,
113+
messages: Union[List[EmailMessageDto], BulkEmailDTO],
114+
default_from: str = None,
115+
tags: List[str] = None
116+
) -> BulkEmailResponseDTO:
117+
"""
118+
Send multiple emails at once.
119+
120+
Args:
121+
messages: List of EmailMessageDto or BulkEmailDTO
122+
default_from: Default sender email (optional)
123+
tags: Common tags for all messages (optional)
124+
125+
Returns:
126+
BulkEmailResponseDTO with results
127+
128+
Example:
129+
messages = [
130+
EmailMessageDto(to='user1@example.com', subject='Hi', body='Hello 1'),
131+
EmailMessageDto(to='user2@example.com', subject='Hi', body='Hello 2'),
132+
]
133+
134+
result = mailer.send_bulk(messages)
135+
print(f"Sent: {result.successful}/{result.total}")
136+
"""
137+
# If already a BulkEmailDTO, use it directly
138+
if isinstance(messages, BulkEmailDTO):
139+
return self.provider.send_bulk(messages)
140+
141+
# Otherwise, create BulkEmailDTO from list
142+
bulk = BulkEmailDTO(
143+
messages=messages,
144+
default_from=default_from,
145+
tags=tags
146+
)
147+
148+
return self.provider.send_bulk(bulk)
149+
150+
def supports_templates(self) -> bool:
151+
"""
152+
Check if current provider supports template emails.
153+
154+
Returns:
155+
True if templates are supported, False otherwise
156+
"""
157+
return self.provider.supports_templates()
158+
159+
def supports_bulk_sending(self) -> bool:
160+
"""
161+
Check if current provider has native bulk sending API.
162+
163+
Returns:
164+
True if native bulk API exists, False otherwise
165+
"""
166+
return self.provider.supports_bulk_sending()
167+
168+
def __enter__(self):
169+
"""Context manager entry."""
170+
return self
171+
172+
def __exit__(self, exc_type, exc_val, exc_tb):
173+
"""Context manager exit."""
174+
self.provider.close()
175+
176+
def close(self):
177+
"""Close provider connection."""
178+
self.provider.close()
179+
180+
@classmethod
181+
def register_provider(cls, name: str, provider_class: type):
182+
"""
183+
Register a custom provider.
184+
185+
Args:
186+
name: Provider name
187+
provider_class: Provider class (must inherit from BaseEmailProvider)
188+
189+
Example:
190+
>>> class MyProvider(BaseEmailProvider):
191+
... def send(self, message):
192+
... # Implementation
193+
... pass
194+
>>> MailBridge.register_provider('myprovider', MyProvider)
195+
"""
196+
if not issubclass(provider_class, BaseEmailProvider):
197+
raise TypeError(f"{provider_class} must inherit from EmailProvider")
198+
cls.PROVIDERS[name.lower()] = provider_class
199+
200+
@classmethod
201+
def available_providers(cls) -> List[str]:
202+
"""
203+
Get list of available providers.
204+
205+
Returns:
206+
List of provider names
207+
"""
208+
return list(cls.PROVIDERS.keys())

mailbridge/dto/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from mailbridge.dto.email_response_dto import EmailResponseDTO
2+
from mailbridge.dto.email_message_dto import EmailMessageDto
3+
from mailbridge.dto.bulk_email_dto import BulkEmailDTO
4+
from mailbridge.dto.bulk_email_response_dto import BulkEmailResponseDTO
5+
6+
__all__ = [
7+
'EmailMessageDto',
8+
'EmailResponseDTO',
9+
'BulkEmailDTO',
10+
'BulkEmailResponseDTO',
11+
]

mailbridge/dto/bulk_email_dto.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from dataclasses import dataclass
2+
from typing import List, Optional
3+
4+
from mailbridge.dto.email_message_dto import EmailMessageDto
5+
6+
7+
@dataclass
8+
class BulkEmailDTO:
9+
10+
messages: List[EmailMessageDto]
11+
default_from: Optional[str] = None
12+
tags: Optional[List[str]] = None
13+
14+
def __post_init__(self):
15+
if not self.messages:
16+
raise ValueError("At least one message must be provided")
17+
18+
if self.default_from:
19+
for message in self.messages:
20+
if not message.from_email:
21+
message.from_email = self.default_from
22+
23+
if self.tags:
24+
for message in self.messages:
25+
if message.tags:
26+
message.tags.extend(self.tags)
27+
else:
28+
message.tags = self.tags.copy()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from dataclasses import dataclass, field
2+
from typing import List
3+
from mailbridge.dto.email_response_dto import EmailResponseDTO
4+
5+
6+
@dataclass
7+
class BulkEmailResponseDTO:
8+
total: int
9+
successful: int
10+
failed: int
11+
responses: List[EmailResponseDTO] = field(default_factory=list)
12+
13+
@classmethod
14+
def from_responses(cls, responses: List[EmailResponseDTO]) -> 'BulkEmailResponseDTO':
15+
total = len(responses)
16+
successful = sum(1 for r in responses if r.success)
17+
failed = total - successful
18+
19+
return cls(
20+
total=total,
21+
successful=successful,
22+
failed=failed,
23+
responses=responses
24+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
from typing import Optional, List, Dict, Union, Any
4+
5+
6+
@dataclass
7+
class EmailMessageDto:
8+
to: Union[str, List[str]]
9+
subject: Optional[str] = None
10+
body: Optional[str] = None
11+
from_email: Optional[str] = None
12+
cc: Optional[Union[str, List[str]]] = None
13+
bcc: Optional[Union[str, List[str]]] = None
14+
reply_to: Optional[str] = None
15+
attachments: Optional[List[Union[Path, tuple]]] = None
16+
html: bool = True
17+
headers: Optional[Dict[str, str]] = None
18+
template_id: Optional[str] = None
19+
template_data: Optional[Dict[str, Any]] = None
20+
tags: Optional[List[str]] = None
21+
22+
23+
def __post_init__(self):
24+
"""Normalize email addresses to lists."""
25+
if isinstance(self.to, str):
26+
self.to = [self.to]
27+
if isinstance(self.cc, str):
28+
self.cc = [self.cc]
29+
if isinstance(self.bcc, str):
30+
self.bcc = [self.bcc]
31+
32+
if not self.template_id and not self.subject:
33+
raise ValueError("Either 'subject' or 'template_id' must be provided")
34+
35+
if not self.template_id and not self.body:
36+
raise ValueError("Either 'body' or 'template_id' must be provided")
37+
38+
def is_template_email(self) -> bool:
39+
"""Check if this is a template-based email."""
40+
return self.template_id is not None
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dataclasses import dataclass, field
2+
from typing import Optional, Dict, Any
3+
4+
5+
@dataclass
6+
class EmailResponseDTO:
7+
success: bool
8+
message_id: Optional[str] = None
9+
provider: Optional[str] = None
10+
error: Optional[str] = None
11+
metadata: Optional[Dict[str, Any]] = field(default_factory=dict)

mailbridge/exceptions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Custom exceptions for MailBridge."""
2+
3+
4+
class MailBridgeError(Exception):
5+
"""Base exception for all MailBridge errors."""
6+
pass
7+
8+
9+
class ConfigurationError(MailBridgeError):
10+
"""Raised when configuration is invalid or missing."""
11+
pass
12+
13+
14+
class EmailSendError(MailBridgeError):
15+
"""Raised when email sending fails."""
16+
17+
def __init__(self, message: str, provider: str = None, original_error: Exception = None):
18+
self.provider = provider
19+
self.original_error = original_error
20+
super().__init__(message)
21+
22+
23+
class ProviderNotFoundError(MailBridgeError):
24+
"""Raised when requested provider is not available."""
25+
pass
26+
27+
28+
class AttachmentError(MailBridgeError):
29+
"""Raised when there's an issue with attachments."""
30+
pass

mailbridge/mail.py

Lines changed: 0 additions & 21 deletions
This file was deleted.

0 commit comments

Comments
 (0)