Files
2026-09-01 11:05:14 +03:00

292 lines
11 KiB
Python

import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.utils import formataddr, formatdate
from pathlib import Path
from flask import current_app, has_request_context, render_template, url_for
from app.services.ticket_service import generate_ticket_image
from app.utils.paths import event_qr_folder, safe_child_path
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
def _build_mime_message(to_address, subject, html_body, sender_name, attachments):
"""Собирает MIME-сообщение с inline-вложениями."""
config = current_app.config
sender_address = config.get("MAIL_USERNAME", "")
if not sender_address:
raise ValueError("MAIL_USERNAME должен быть указан в конфигурации")
# Имя отправителя — отдельно от email-адреса
display_name = sender_name or config.get("MAIL_DEFAULT_SENDER") or ""
msg = MIMEMultipart("related")
msg["Subject"] = subject
msg["From"] = formataddr((display_name, sender_address))
msg["To"] = to_address
msg["Date"] = formatdate(localtime=True)
msg.attach(MIMEText(html_body, "html", "utf-8"))
for att in attachments:
path = att["path"]
cid = att.get("cid")
filename = att.get("filename", path.name)
with open(path, "rb") as img_file:
img = MIMEImage(img_file.read())
if cid:
img.add_header("Content-ID", f"<{cid}>")
img.add_header("Content-Disposition", "inline", filename=filename)
msg.attach(img)
return msg
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через SMTP с inline-вложениями."""
config = current_app.config
server = config.get("MAIL_SERVER", "")
port = config.get("MAIL_PORT", 465)
username = config.get("MAIL_USERNAME", "")
password = config.get("MAIL_PASSWORD", "")
use_ssl = config.get("MAIL_USE_SSL", True)
if not username or not password:
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
if not server or not port:
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
msg = _build_mime_message(to_address, subject, html_body, sender_name, attachments)
if use_ssl:
with smtplib.SMTP_SSL(server, port) as smtp:
smtp.login(username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(server, port) as smtp:
smtp.starttls()
smtp.login(username, password)
smtp.send_message(msg)
return msg
def _send_outlook_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через локальный Outlook (MAPI/COM)."""
try:
import win32com.client
except ImportError as e:
raise RuntimeError("Для отправки через Outlook необходима библиотека pywin32") from e
try:
outlook = win32com.client.Dispatch("Outlook.Application")
except Exception as e:
raise RuntimeError("Не удалось подключиться к Outlook. Убедитесь, что Outlook установлен и запущен.") from e
mail = outlook.CreateItem(0) # olMailItem
mail.To = to_address
mail.Subject = subject
mail.HTMLBody = html_body
for att in attachments:
path = att["path"]
cid = att.get("cid")
filename = att.get("filename", path.name)
# 1 = olByValue, 0 = позиция (inline)
attachment = mail.Attachments.Add(str(path), 1, 0, filename)
if cid:
attachment.PropertyAccessor.SetProperty(OUTLOOK_CONTENT_ID_PROP, cid)
mail.Send()
def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через Exchange Web Services (EWS)."""
try:
from exchangelib import Credentials, Configuration, Account, Message, Mailbox, HTMLBody, FileAttachment
except ImportError as e:
raise RuntimeError("Для отправки через EWS необходима библиотека exchangelib") from e
config = current_app.config
ews_url = config.get("EWS_URL", "")
username = config.get("MAIL_USERNAME", "")
password = config.get("MAIL_PASSWORD", "")
if not ews_url or not username or not password:
raise ValueError("Для EWS должны быть указаны EWS URL, логин и пароль")
try:
credentials = Credentials(username=username, password=password)
ews_config = Configuration(service_endpoint=ews_url, credentials=credentials)
account = Account(
primary_smtp_address=username,
config=ews_config,
autodiscover=False,
access_type="delegate",
)
msg = Message(
account=account,
subject=subject,
body=HTMLBody(html_body),
to_recipients=[Mailbox(email_address=to_address)],
)
for att in attachments:
path = att["path"]
filename = att.get("filename", path.name)
cid = att.get("cid")
with open(path, "rb") as f:
content = f.read()
file_att = FileAttachment(name=filename, content=content, content_id=cid)
msg.attach(file_att)
msg.send()
except Exception as e:
raise RuntimeError(f"Ошибка отправки через EWS: {e}") from e
def _save_imap_copy(to_address, subject, html_body, sender_name, attachments):
"""Сохраняет копию отправленного письма в IMAP-папку (обычно Sent)."""
import imaplib
config = current_app.config
if not config.get("IMAP_SAVE_COPY"):
return
server = config.get("IMAP_SERVER", "")
port = config.get("IMAP_PORT", 993)
username = config.get("IMAP_USERNAME", "")
password = config.get("IMAP_PASSWORD", "")
folder = config.get("IMAP_FOLDER", "Sent")
use_ssl = config.get("IMAP_USE_SSL", True)
if not server or not username or not password:
raise ValueError("Для сохранения копии в IMAP должны быть указаны сервер, логин и пароль")
msg = _build_mime_message(to_address, subject, html_body, sender_name, attachments)
msg_bytes = msg.as_bytes()
date_str = imaplib.Time2Internaldate(time.time())
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(server, port)
else:
mail = imaplib.IMAP4(server, port)
try:
mail.starttls()
except Exception:
pass
mail.login(username, password)
mail.select(folder)
result, data = mail.append(folder, "", date_str, msg_bytes)
if result != "OK":
raise RuntimeError(f"IMAP append failed: {data}")
mail.close()
mail.logout()
except Exception as e:
raise RuntimeError(f"Ошибка сохранения копии в IMAP: {e}") from e
def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachments=None):
"""Маршрутизирует отправку через SMTP, EWS или локальный Outlook."""
attachments = attachments or []
if current_app.config.get("MAIL_USE_LOCAL_OUTLOOK"):
_send_outlook_email(to_address, subject, html_body, sender_name, attachments)
elif current_app.config.get("MAIL_USE_EWS"):
_send_ews_email(to_address, subject, html_body, sender_name, attachments)
else:
_send_smtp_email(to_address, subject, html_body, sender_name, attachments)
# Сохраняем копию в IMAP, если включено
if current_app.config.get("IMAP_SAVE_COPY"):
_save_imap_copy(to_address, subject, html_body, sender_name, attachments)
def send_invitation(guest, event_name=None, additional_text=""):
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
from app.models import utcnow
event = guest.event
guest.invitation_status = "sending"
guest.invitation_attempts = (guest.invitation_attempts or 0) + 1
guest.invitation_last_attempt_at = utcnow()
guest.invitation_last_error = None
if event_name is None:
event_name = event.name if event else "Мероприятие"
# Генерируем билет
try:
ticket_path = generate_ticket_image(guest, guest.event)
except Exception as e:
ticket_path = None
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
base_url = (current_app.config.get("BASE_URL") or "").rstrip("/")
if base_url:
invitation_url = f"{base_url}/invite/{guest.token}"
elif has_request_context():
invitation_url = url_for("main.public_invitation", token=guest.token, _external=True)
else:
invitation_url = f"http://localhost:5000/invite/{guest.token}"
html_body = render_template(
"email/invitation.html",
guest=guest,
event_name=event_name,
additional_text=additional_text,
invitation_message=event.invitation_message if event else None,
invitation_url=invitation_url,
qr_cid="qr_code_image",
ticket_cid="ticket_image" if ticket_path else None,
)
attachments = []
# QR-код
qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename)
if qr_path and qr_path.exists():
attachments.append({"path": qr_path, "cid": "qr_code_image", "filename": guest.qr_filename})
# Билет
if ticket_path and ticket_path.exists():
attachments.append({
"path": ticket_path,
"cid": "ticket_image",
"filename": f"ticket_{guest.token}.png",
})
try:
_send_email(
to_address=guest.email,
subject=(event.invitation_subject if event and event.invitation_subject else f"Приглашение на мероприятие: {event_name}"),
html_body=html_body,
attachments=attachments,
)
except Exception as exc:
guest.invitation_status = "failed"
guest.invitation_last_error = str(exc)[:1000]
raise
guest.invitation_sent = True
guest.invitation_status = "sent"
guest.invitation_sent_at = utcnow()
guest.invitation_last_error = None
return True
def send_test_email(to_address):
"""Отправляет тестовое письмо для проверки настроек."""
html_body = render_template("email/test_email.html")
_send_email(
to_address=to_address,
subject="Тестовое письмо InviteMe",
html_body=html_body,
)
return True