Initial commit: InviteMe production redesign
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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
|
||||
Reference in New Issue
Block a user