4 Commits

Author SHA1 Message Date
e6ca15e8e7 feat: add forgot password 2026-05-19 15:29:23 +03:00
3c9718e565 feat: add change password logic 2026-05-19 08:57:00 +03:00
10e8dc4e96 feat: update style of mail 2026-04-19 10:53:18 +03:00
323e309c7e feat: change templates bitforce to elcsa 2026-04-18 12:39:02 +03:00
8 changed files with 181 additions and 110 deletions

31
.env-example Normal file
View File

@@ -0,0 +1,31 @@
APP_MODULE=src.main:app
APP_HOST=0.0.0.0
APP_PORT=8000
APP_WORKERS=3
VAULT_ADDR=http://localhost:8200
VAULT_ROLE_ID=replace-me
VAULT_SECRET_ID=replace-me
VAULT_AUTH_MOUNT=approle
VAULT_MOUNT_POINT=secrets
DOCS_USERNAME=admin
DOCS_PASSWORD=admin
RABBIT_HOST=localhost
RABBIT_PORT=5672
RABBIT_USER=guest
RABBIT_PASSWORD=guest
RABBIT_VHOST=/
RABBIT_PUBLISH_PERSIST=true
RABBIT_CONNECT_TIMEOUT=5
RABBIT_EMAIL_CODE_QUEUE=email.verification_code
SMTP_FROM=
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
LOG_LEVEL=INFO
LOG_FORMAT=TEXT

View File

@@ -4,5 +4,13 @@ from abc import ABC, abstractmethod
class ISender(ABC):
@abstractmethod
async def send(self, to: str, subject: str, body: str, plain: str | None = None) -> None:
async def send(
self,
to: str,
subject: str,
body: str,
plain: str | None = None,
*,
inline_png: tuple[str, bytes] | None = None,
) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,11 @@
from pathlib import Path
_LOGO_PATH = Path(__file__).resolve().parent / 'templates' / 'static' / 'exa-logo.png'
EXA_LOGO_CID = 'exa-logo'
def get_exa_logo_png() -> bytes | None:
if not _LOGO_PATH.is_file():
return None
return _LOGO_PATH.read_bytes()

View File

@@ -1,5 +1,10 @@
import aiosmtplib
from email.message import EmailMessage
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.policy import SMTP
from src.application.contracts.i_sender import ISender
@@ -23,17 +28,34 @@ class EmailSender(ISender):
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")
async def send(
self,
to: str,
subject: str,
body: str,
plain: str | None = None,
*,
inline_png: tuple[str, bytes] | None = None,
) -> None:
if inline_png and plain:
message = self._multipart_with_inline_png(
to=to,
subject=subject,
plain=plain,
html=body,
content_id=inline_png[0],
png_bytes=inline_png[1],
)
else:
message.set_content(body, subtype="html")
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,
@@ -41,6 +63,34 @@ class EmailSender(ISender):
port=self._port,
username=self._username,
password=self._password,
use_tls=True,
use_tls=self._use_tls,
timeout=self._timeout,
)
)
def _multipart_with_inline_png(
self,
*,
to: str,
subject: str,
plain: str,
html: str,
content_id: str,
png_bytes: bytes,
) -> MIMEMultipart:
root = MIMEMultipart('alternative', policy=SMTP)
root['Subject'] = subject
root['From'] = self._from_addr
root['To'] = to
root.attach(MIMEText(plain, 'plain', 'utf-8'))
related = MIMEMultipart('related', policy=SMTP)
related.attach(MIMEText(html, 'html', 'utf-8'))
image = MIMEImage(png_bytes, _subtype='png', policy=SMTP)
image.add_header('Content-ID', f'<{content_id}>')
image.add_header('Content-Disposition', 'inline', filename='exa-logo.png')
related.attach(image)
root.attach(related)
return root

View File

@@ -3,52 +3,43 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="color-scheme" content="light" />
<title>{{ subject }}</title>
</head>
<body style="margin:0;padding:0;background:#0E1126;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<body style="margin:0;padding:0;background:#F2F1E8;-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 }} мин. &#8199;&#65279;&#847;
</div>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="background:#0E1126;padding:32px 16px;">
style="background:#F2F1E8;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);">
style="max-width:600px;width:100%;border-radius:12px;overflow:hidden;
box-shadow:0 4px 24px rgba(14,16,61,0.08);border:1px solid rgba(14,16,61,0.08);">
<!-- ====== HEADER — gradient bar ====== -->
<tr>
<td style="padding:28px 32px;
background:linear-gradient(135deg,#260E59 0%,#5D04D9 50%,#056CF2 100%);">
<td style="padding:24px 32px;background:#0E103D;">
<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;">
<td align="left" valign="middle">
{% if logo_src %}
<img src="{{ logo_src }}" alt="{{ brand }}" width="200" height="auto"
style="display:block;max-width:200px;height:auto;border:0;outline:none;text-decoration:none;" />
{% else %}
<div style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#FFFFFF;font-size:22px;font-weight:800;letter-spacing:0.14em;">
{{ 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;">
🔐
{% endif %}
<div style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#A1A4C1;font-size:13px;margin-top:10px;">
Подтверждение действия
</div>
</td>
</tr>
@@ -56,57 +47,42 @@
</td>
</tr>
<!-- ====== BODY ====== -->
<tr>
<td style="padding:28px 32px;background:#0E1126;">
<td style="padding:28px 32px 24px;background:#EFECE0;">
<!-- Greeting -->
<div style="font-family:'Segoe UI',Arial,Helvetica,sans-serif;
color:#ffffff;font-size:20px;font-weight:700;">
<div style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#0E103D;font-size:19px;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 style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#0A0B2E;font-size:14px;line-height:22px;margin-top:10px;opacity:0.88;">
Введите код в приложении {{ brand }}. Не пересылайте это письмо и не сообщайте код другим людям.
</div>
<!-- ── Code card ── -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="margin-top:24px;">
style="margin-top:22px;">
<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;">
<td style="background:#151845;border-radius:10px;padding:22px 18px;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 style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#A1A4C1;font-size:11px;
letter-spacing:0.16em;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;">
<div style="font-family:'SF Mono','Cascadia Code',Consolas,monospace;
color:#FFFFFF;font-size:34px;font-weight:800;
letter-spacing:0.32em;margin-top:12px;padding:8px 0;">
{{ code }}
</div>
<!-- TTL pill -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="margin:14px auto 0;">
style="margin:16px 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 }} мин
<td style="background:#4A47A3;border-radius:8px;padding:10px 22px;">
<span style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#FFFFFF;font-size:13px;font-weight:600;">
Действует {{ ttl_minutes }} мин
</span>
</td>
</tr>
@@ -116,17 +92,13 @@
</tr>
</table>
<!-- Warning -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="margin-top:20px;">
style="margin-top:18px;">
<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;">
⚠️ Если вы не запрашивали код — просто проигнорируйте
это письмо. Никогда не сообщайте код третьим лицам.
<td style="background:#FDE8E8;border-radius:8px;padding:14px 16px;">
<div style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#991B1B;font-size:13px;line-height:20px;">
Если вы не запрашивали код, проигнорируйте письмо. Служба поддержки {{ brand }} никогда не попросит код по телефону или в мессенджере.
</div>
</td>
</tr>
@@ -135,25 +107,15 @@
</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;">
<td style="padding:20px 32px 24px;background:#0E103D;">
<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 align="left" valign="top"
style="font-family:Inter,'Segoe UI',Arial,Helvetica,sans-serif;
color:#A1A4C1;font-size:11px;line-height:17px;">
© {{ year }} {{ brand }}<br />
<span style="color:rgba(161,164,193,0.75);">Ref: {{ trace_id }}</span>
</td>
</tr>
</table>
@@ -161,11 +123,10 @@
</tr>
</table>
<!-- /Outer card -->
</td>
</tr>
</table>
</body>
</html>
</html>

View File

@@ -1,7 +1,10 @@
{{ brand }}
Ваш код подтверждения: {{ code }}
Код подтверждения: {{ code }}
Срок действия: {{ ttl_minutes }} минут
Если вы не запрашивали код — игнорируйте это письмо.
Введите код в приложении {{ brand }}. Не сообщайте код третьим лицам.
Ref: {{ trace_id }}
Если вы не запрашивали код — проигнорируйте это письмо.
© {{ year }} {{ brand }}
Ref: {{ trace_id }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -3,6 +3,7 @@ from faststream.rabbit.fastapi import RabbitRouter, RabbitMessage
from src.infrastructure.config import settings
from src.infrastructure.logger import logger
from src.infrastructure.mail import TemplateRenderer, get_email_sender, get_renderer
from src.infrastructure.mail.assets import EXA_LOGO_CID, get_exa_logo_png
from src.infrastructure.mail.sender import EmailSender
from datetime import datetime
from typing import Literal, Optional
@@ -27,7 +28,7 @@ class Payload(BaseModel):
class LoginCodeCreated(BaseModel):
event: Literal["login", "registration", "bank_details_update"]
event: Literal['login', 'registration', 'bank_details_update', 'change_password', 'forgot_password']
payload: Payload
metadata: Metadata
@@ -52,27 +53,33 @@ async def consume_email_code(
f"trace_id={trace_id}"
)
logo_png = get_exa_logo_png()
logo_src = f'cid:{EXA_LOGO_CID}' if logo_png else ''
html = renderer.render(
"email_code.html",
subject="Код подтверждения",
'email_code.html',
subject='Экса — код подтверждения',
code=msg_body.payload.code,
ttl_minutes=msg_body.payload.ttl_seconds // 60,
brand="Bitforce",
brand='Экса',
logo_src=logo_src,
trace_id=trace_id,
year=datetime.now().year,
)
text = renderer.render(
"email_code.txt",
'email_code.txt',
code=msg_body.payload.code,
ttl_minutes=msg_body.payload.ttl_seconds // 60,
brand="Bitforce",
brand='Экса',
trace_id=trace_id,
year=datetime.now().year,
)
await sender.send(
to=msg_body.payload.email,
subject="Код подтверждения",
subject='Экса — код подтверждения',
body=html,
plain=text,
inline_png=(EXA_LOGO_CID, logo_png) if logo_png else None,
)