292 lines
11 KiB
Python
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
|