patera-email 0.1.0__tar.gz
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.
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "patera-email"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"aiosmtplib>=5.1.0",
|
|
7
|
+
"patera",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[tool.uv.sources]
|
|
11
|
+
patera = { workspace = true }
|
|
12
|
+
|
|
13
|
+
[tool.uv.build-backend]
|
|
14
|
+
module-name = "patera.email"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email client extension
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import aiosmtplib as smtplib
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
from typing import TYPE_CHECKING, Optional, cast, TypedDict, NotRequired
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from jinja2 import Environment
|
|
10
|
+
|
|
11
|
+
from patera.base_extension import BaseExtension
|
|
12
|
+
from patera.utilities import run_sync_or_async
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from patera import Patera
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _EmailConfigs(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Email client configuration model
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
SENDER_NAME_OR_ADDRESS: str = Field(
|
|
24
|
+
description="The name or address of the email sender"
|
|
25
|
+
)
|
|
26
|
+
SMTP_SERVER: str = Field(description="SMTP server address")
|
|
27
|
+
SMTP_PORT: int = Field(description="SMTP server port")
|
|
28
|
+
USERNAME: Optional[str] = Field(None, description="SMTP username")
|
|
29
|
+
PASSWORD: Optional[str] = Field(None, description="SMTP password")
|
|
30
|
+
USE_TLS: Optional[bool] = Field(False, description="Use TLS for SMTP connection")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EmailConfig(TypedDict):
|
|
34
|
+
"""
|
|
35
|
+
Email client configuration dictionary
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
SENDER_NAME_OR_ADDRESS: str
|
|
39
|
+
SMTP_SERVER: str
|
|
40
|
+
SMTP_PORT: int
|
|
41
|
+
USERNAME: NotRequired[str]
|
|
42
|
+
PASSWORD: NotRequired[str]
|
|
43
|
+
USE_TLS: NotRequired[bool]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EmailClient(BaseExtension):
|
|
47
|
+
"""
|
|
48
|
+
Email client extension class
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, configs_name: str = "EMAIL_CLIENT"):
|
|
52
|
+
self._app: "Optional[Patera]" = None
|
|
53
|
+
self._configs_name = configs_name
|
|
54
|
+
self._configs: dict[str, str | int | bool] = {}
|
|
55
|
+
self.render_engine: Environment = None # type: ignore
|
|
56
|
+
|
|
57
|
+
def init_app(self, app: "Patera") -> None:
|
|
58
|
+
"""Initilizes the extension with the Patera app"""
|
|
59
|
+
self._app = app
|
|
60
|
+
self._configs = app.get_conf(self._configs_name, {})
|
|
61
|
+
self._configs = self.validate_configs(self._configs, _EmailConfigs)
|
|
62
|
+
|
|
63
|
+
self._app.add_extension(self)
|
|
64
|
+
self.render_engine = self._app.jinja_environment
|
|
65
|
+
|
|
66
|
+
def get_client(self) -> smtplib.SMTP:
|
|
67
|
+
"""Returns the email client instance"""
|
|
68
|
+
return smtplib.SMTP(
|
|
69
|
+
hostname=cast(str, self._configs.get("SMTP_SERVER")),
|
|
70
|
+
port=cast(int, self._configs.get("SMTP_PORT")),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def send_email_with_template(
|
|
74
|
+
self,
|
|
75
|
+
to_address: str | list[str],
|
|
76
|
+
subject: str,
|
|
77
|
+
template_path: str,
|
|
78
|
+
attachments: Optional[dict[str, bytes]] = None,
|
|
79
|
+
context: Optional[dict] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Sends an email using a template and context data"""
|
|
82
|
+
|
|
83
|
+
if context is None:
|
|
84
|
+
context = {}
|
|
85
|
+
|
|
86
|
+
for method in self.app.global_context_methods:
|
|
87
|
+
additional_context = await run_sync_or_async(method)
|
|
88
|
+
if not isinstance(additional_context, dict):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"Return of global context method must be off type dictionary"
|
|
91
|
+
)
|
|
92
|
+
context = {**context, **additional_context}
|
|
93
|
+
context["url_for"] = self.app.url_for
|
|
94
|
+
|
|
95
|
+
template = self.render_engine.get_template(template_path)
|
|
96
|
+
rendered = await template.render_async(**context)
|
|
97
|
+
await self.send_email(to_address, subject, rendered, attachments)
|
|
98
|
+
|
|
99
|
+
async def send_email(
|
|
100
|
+
self,
|
|
101
|
+
to_address: str | list[str],
|
|
102
|
+
subject: str,
|
|
103
|
+
body: str,
|
|
104
|
+
attachments: Optional[dict[str, bytes]] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Sends an email with attachments using the SMTP client
|
|
108
|
+
|
|
109
|
+
:param to_address: Recipient email address or comma separated list of addresses
|
|
110
|
+
:param subject: Email subject
|
|
111
|
+
:param body: Email body
|
|
112
|
+
:param attachments: Dictionary of attachment filenames and their byte content
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(to_address, str):
|
|
115
|
+
to_address = [to_address]
|
|
116
|
+
|
|
117
|
+
msg: EmailMessage = EmailMessage()
|
|
118
|
+
msg["From"] = cast(str, self._configs.get("SENDER_NAME_OR_ADDRESS"))
|
|
119
|
+
msg["To"] = ", ".join(to_address)
|
|
120
|
+
msg["Subject"] = subject
|
|
121
|
+
msg.set_content(body)
|
|
122
|
+
|
|
123
|
+
if attachments:
|
|
124
|
+
for filename, filecontent in attachments.items():
|
|
125
|
+
msg.add_attachment(
|
|
126
|
+
filecontent,
|
|
127
|
+
maintype="application",
|
|
128
|
+
subtype="octet-stream",
|
|
129
|
+
filename=filename,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async with self.get_client() as client:
|
|
133
|
+
if cast(bool, self._configs.get("USE_TLS")):
|
|
134
|
+
await client.starttls()
|
|
135
|
+
if (
|
|
136
|
+
self._configs.get("USERNAME") is not None
|
|
137
|
+
and self._configs.get("PASSWORD") is not None
|
|
138
|
+
):
|
|
139
|
+
await client.login(
|
|
140
|
+
cast(str, self._configs.get("USERNAME")),
|
|
141
|
+
cast(str, self._configs.get("PASSWORD")),
|
|
142
|
+
)
|
|
143
|
+
await client.send_message(msg)
|