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,6 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-email
3
+ Version: 0.1.0
4
+ Requires-Dist: aiosmtplib>=5.1.0
5
+ Requires-Dist: patera
6
+ Requires-Python: >=3.12
@@ -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,7 @@
1
+ """
2
+ Email client module for sending emails using SMTP.
3
+ """
4
+
5
+ from .email_client import EmailClient, EmailConfig
6
+
7
+ __all__ = ["EmailClient", "EmailConfig"]
@@ -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)