Initial commit: InviteMe production redesign

This commit is contained in:
InviteMe Developer
2026-09-01 11:04:21 +03:00
commit 6060c22ff3
71 changed files with 6912 additions and 0 deletions
+291
View File
@@ -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