Initial commit
This commit is contained in:
21
src/infrastructure/mail/__init__.py
Normal file
21
src/infrastructure/mail/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from anyio.functools import lru_cache
|
||||
from src.application.contracts.i_sender import ISender
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.mail.render import TemplateRenderer
|
||||
from src.infrastructure.mail.sender import EmailSender
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_renderer() -> TemplateRenderer:
|
||||
return TemplateRenderer()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_email_sender() -> ISender:
|
||||
return EmailSender(
|
||||
host=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
username=str(settings.SMTP_USER),
|
||||
password=settings.SMTP_PASSWORD,
|
||||
from_addr=settings.SMTP_FROM,
|
||||
)
|
||||
17
src/infrastructure/mail/render.py
Normal file
17
src/infrastructure/mail/render.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
|
||||
class TemplateRenderer:
|
||||
def __init__(self, templates_dir: Path | str | None = None):
|
||||
if templates_dir is None:
|
||||
templates_dir = Path(__file__).parent / "templates"
|
||||
self._env = Environment(
|
||||
loader=FileSystemLoader(templates_dir),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def render(self, template_name: str, **kwargs: object) -> str:
|
||||
return self._env.get_template(template_name).render(**kwargs)
|
||||
46
src/infrastructure/mail/sender.py
Normal file
46
src/infrastructure/mail/sender.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import aiosmtplib
|
||||
from email.message import EmailMessage
|
||||
from src.application.contracts.i_sender import ISender
|
||||
|
||||
|
||||
class EmailSender(ISender):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
from_addr: str,
|
||||
use_tls: bool = True,
|
||||
timeout: int = 10,
|
||||
):
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._from_addr = from_addr
|
||||
self._use_tls = use_tls
|
||||
self._timeout = timeout
|
||||
|
||||
async def send(self, to: str, subject: str, body: str, plain: str | None = None) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._from_addr
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
|
||||
if plain:
|
||||
message.set_content(plain)
|
||||
message.add_alternative(body, subtype="html")
|
||||
else:
|
||||
message.set_content(body, subtype="html")
|
||||
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
use_tls=True,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
171
src/infrastructure/mail/templates/email_code.html
Normal file
171
src/infrastructure/mail/templates/email_code.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>{{ subject }}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0E1126;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif !important;}</style><![endif]-->
|
||||
|
||||
<!-- Preheader -->
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
|
||||
Ваш код: {{ code }}. Действует {{ ttl_minutes }} мин.  ͏
|
||||
</div>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||
style="background:#0E1126;padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
<!-- Outer card 600px -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600"
|
||||
style="max-width:600px;width:100%;border-radius:20px;overflow:hidden;
|
||||
border:1px solid rgba(93,4,217,0.30);">
|
||||
|
||||
<!-- ====== HEADER — gradient bar ====== -->
|
||||
<tr>
|
||||
<td style="padding:28px 32px;
|
||||
background:linear-gradient(135deg,#260E59 0%,#5D04D9 50%,#056CF2 100%);">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:#ffffff;font-size:22px;font-weight:800;
|
||||
letter-spacing:0.4px;">
|
||||
{{ brand }}
|
||||
</div>
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:rgba(255,255,255,0.80);font-size:13px;
|
||||
margin-top:6px;letter-spacing:0.2px;">
|
||||
Подтверждение · Безопасность
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" valign="middle">
|
||||
<!-- Shield icon via CSS -->
|
||||
<div style="width:44px;height:44px;border-radius:12px;
|
||||
background:rgba(255,255,255,0.12);
|
||||
text-align:center;line-height:44px;font-size:22px;">
|
||||
🔐
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ====== BODY ====== -->
|
||||
<tr>
|
||||
<td style="padding:28px 32px;background:#0E1126;">
|
||||
|
||||
<!-- Greeting -->
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:#ffffff;font-size:20px;font-weight:700;">
|
||||
Ваш код подтверждения
|
||||
</div>
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:rgba(255,255,255,0.70);font-size:14px;
|
||||
line-height:22px;margin-top:10px;">
|
||||
Введите этот код в приложении, чтобы завершить действие.
|
||||
Никому не сообщайте его.
|
||||
</div>
|
||||
|
||||
<!-- ── Code card ── -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||
style="margin-top:24px;">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(160deg,#260E59 0%,#1a0a3e 100%);
|
||||
border:1px solid rgba(93,4,217,0.50);
|
||||
border-radius:16px;padding:24px 20px;text-align:center;">
|
||||
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:rgba(255,255,255,0.55);font-size:11px;
|
||||
letter-spacing:2px;text-transform:uppercase;">
|
||||
код подтверждения
|
||||
</div>
|
||||
|
||||
<div style="font-family:'SF Mono','Cascadia Code','Fira Code',monospace;
|
||||
color:#ffffff;font-size:38px;font-weight:800;
|
||||
letter-spacing:10px;margin-top:12px;
|
||||
padding:12px 0;
|
||||
background:linear-gradient(90deg,#05C7F2,#5D04D9,#056CF2);
|
||||
-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;
|
||||
background-clip:text;">
|
||||
{{ code }}
|
||||
</div>
|
||||
|
||||
<!-- TTL pill -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||
style="margin:14px auto 0;">
|
||||
<tr>
|
||||
<td style="background:rgba(5,199,242,0.12);
|
||||
border:1px solid rgba(5,199,242,0.30);
|
||||
border-radius:20px;padding:6px 16px;">
|
||||
<span style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:#05C7F2;font-size:13px;font-weight:600;">
|
||||
⏱ Действует {{ ttl_minutes }} мин
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Warning -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||
style="margin-top:20px;">
|
||||
<tr>
|
||||
<td style="background:rgba(93,4,217,0.08);
|
||||
border-left:3px solid #5D04D9;
|
||||
border-radius:0 10px 10px 0;padding:14px 16px;">
|
||||
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:rgba(255,255,255,0.70);font-size:13px;line-height:20px;">
|
||||
⚠️ Если вы не запрашивали код — просто проигнорируйте
|
||||
это письмо. Никогда не сообщайте код третьим лицам.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ====== DIVIDER ====== -->
|
||||
<tr>
|
||||
<td style="padding:0 32px;">
|
||||
<div style="height:1px;
|
||||
background:linear-gradient(90deg,transparent,rgba(5,199,242,0.25),transparent);">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ====== FOOTER ====== -->
|
||||
<tr>
|
||||
<td style="padding:20px 32px 28px;background:#0E1126;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td align="right" valign="bottom"
|
||||
style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
|
||||
color:rgba(255,255,255,0.30);font-size:11px;">
|
||||
© {{ year }} {{ brand }}<br />
|
||||
Ref: {{ trace_id }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- /Outer card -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
7
src/infrastructure/mail/templates/email_code.txt
Normal file
7
src/infrastructure/mail/templates/email_code.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
{{ brand }}
|
||||
Ваш код подтверждения: {{ code }}
|
||||
Срок действия: {{ ttl_minutes }} минут
|
||||
|
||||
Если вы не запрашивали код — игнорируйте это письмо.
|
||||
|
||||
Ref: {{ trace_id }}
|
||||
Reference in New Issue
Block a user