Добавлена опция сохранения копии отправленных писем в IMAP Sent

- Добавлены поля IMAP в модель SmtpSettings и миграция колонок
- Реализовано сохранение копии MIME-сообщения через imaplib после SMTP/EWS/Outlook отправки
- Обновлен routes.py: чтение и сохранение IMAP-настроек из формы
- Обновлен шаблон admin/email_settings.html: блок настроек IMAP с подсказками
This commit is contained in:
InviteMe Developer
2026-08-31 14:51:14 +03:00
parent c5fd029818
commit a65cb35b30
5 changed files with 154 additions and 14 deletions
+9
View File
@@ -152,6 +152,15 @@ def _migrate_existing_data(app):
_add_column_if_missing("smtp_settings", "use_ews", "BOOLEAN DEFAULT 0")
_add_column_if_missing("smtp_settings", "ews_url", "VARCHAR(500)")
# Настройки IMAP для сохранения копий отправленных писем
_add_column_if_missing("smtp_settings", "imap_server", "VARCHAR(255)")
_add_column_if_missing("smtp_settings", "imap_port", "INTEGER DEFAULT 993")
_add_column_if_missing("smtp_settings", "imap_use_ssl", "BOOLEAN DEFAULT 1")
_add_column_if_missing("smtp_settings", "imap_username", "VARCHAR(255)")
_add_column_if_missing("smtp_settings", "imap_password", "VARCHAR(255)")
_add_column_if_missing("smtp_settings", "imap_folder", "VARCHAR(255)")
_add_column_if_missing("smtp_settings", "imap_save_copy", "BOOLEAN DEFAULT 0")
# Переносим файлы в папки по мероприятиям
_migrate_files_to_event_folders()
+18
View File
@@ -198,6 +198,16 @@ class SmtpSettings(db.Model):
use_local_outlook = db.Column(db.Boolean, nullable=False, default=False)
use_ews = db.Column(db.Boolean, nullable=False, default=False)
ews_url = db.Column(db.String(500), nullable=True)
# IMAP copy of sent messages
imap_server = db.Column(db.String(255), nullable=True)
imap_port = db.Column(db.Integer, nullable=False, default=993)
imap_use_ssl = db.Column(db.Boolean, nullable=False, default=True)
imap_username = db.Column(db.String(255), nullable=True)
imap_password = db.Column(db.String(255), nullable=True)
imap_folder = db.Column(db.String(255), nullable=True)
imap_save_copy = db.Column(db.Boolean, nullable=False, default=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@property
@@ -233,6 +243,14 @@ class SmtpSettings(db.Model):
app.config["MAIL_USE_LOCAL_OUTLOOK"] = settings.use_local_outlook
app.config["MAIL_USE_EWS"] = settings.use_ews
app.config["EWS_URL"] = settings.ews_url or ""
app.config["IMAP_SERVER"] = settings.imap_server or ""
app.config["IMAP_PORT"] = settings.imap_port or 993
app.config["IMAP_USE_SSL"] = settings.imap_use_ssl
app.config["IMAP_USERNAME"] = settings.imap_username or settings.mail_username or ""
app.config["IMAP_PASSWORD"] = settings.imap_password or settings.mail_password or ""
app.config["IMAP_FOLDER"] = settings.imap_folder or "Sent"
app.config["IMAP_SAVE_COPY"] = settings.imap_save_copy
except Exception:
pass
+15
View File
@@ -1100,6 +1100,21 @@ def admin_email_settings():
if password:
settings.mail_password = password
# IMAP: копия отправленных писем (общая для всех режимов отправки)
settings.imap_save_copy = bool(request.form.get("imap_save_copy"))
settings.imap_server = request.form.get("imap_server", "").strip()
try:
settings.imap_port = int(request.form.get("imap_port", "993").strip())
except ValueError:
settings.imap_port = 993
settings.imap_use_ssl = bool(request.form.get("imap_use_ssl"))
settings.imap_username = request.form.get("imap_username", "").strip()
settings.imap_folder = request.form.get("imap_folder", "Sent").strip() or "Sent"
imap_password = request.form.get("imap_password", "").strip()
if imap_password:
settings.imap_password = imap_password
db.session.commit()
SmtpSettings.apply_to_app(current_app)
+74 -14
View File
@@ -1,4 +1,5 @@
import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
@@ -13,26 +14,16 @@ from app.utils.paths import event_qr_folder
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через SMTP с inline-вложениями."""
def _build_mime_message(to_address, subject, html_body, sender_name, attachments):
"""Собирает MIME-сообщение с 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)
sender = config.get("MAIL_DEFAULT_SENDER") or username
if not username or not password:
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
if not server or not port:
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
sender = config.get("MAIL_DEFAULT_SENDER") or config.get("MAIL_USERNAME", "")
msg = MIMEMultipart("related")
msg["Subject"] = subject
msg["From"] = formataddr((sender_name, sender))
msg["To"] = to_address
msg["Date"] = email.utils.formatdate(localtime=True)
msg.attach(MIMEText(html_body, "html", "utf-8"))
for att in attachments:
@@ -46,6 +37,26 @@ def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
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)
@@ -56,6 +67,8 @@ def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
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)."""
@@ -132,6 +145,49 @@ def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
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 []
@@ -142,6 +198,10 @@ def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachme
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-кодом и билетом во вложении."""
+38
View File
@@ -76,6 +76,44 @@
Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере.
</div>
<hr class="my-4">
<h5 class="mb-3">Копия отправленных писем в IMAP</h5>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="imap_save_copy" name="imap_save_copy" value="1" {% if settings.imap_save_copy %}checked{% endif %}>
<label class="form-check-label" for="imap_save_copy">Сохранять копию отправленного письма в папку IMAP</label>
<div class="form-text text-muted">Работает после успешной отправки через SMTP/EWS/Outlook. Для отправки писем по-прежнему используется выбранный выше способ.</div>
</div>
<div id="imap-fields">
<div class="row">
<div class="col-md-8 mb-3">
<label for="imap_server" class="form-label">IMAP-сервер</label>
<input type="text" class="form-control" id="imap_server" name="imap_server" value="{{ settings.imap_server or '' }}" placeholder="mail.integrita.ru">
</div>
<div class="col-md-4 mb-3">
<label for="imap_port" class="form-label">Порт</label>
<input type="number" class="form-control" id="imap_port" name="imap_port" value="{{ settings.imap_port or 993 }}">
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="imap_use_ssl" name="imap_use_ssl" value="1" {% if settings.imap_use_ssl %}checked{% endif %}>
<label class="form-check-label" for="imap_use_ssl">Использовать SSL/TLS</label>
</div>
<div class="mb-3">
<label for="imap_username" class="form-label">IMAP логин (если пустой — используется логин отправки)</label>
<input type="text" class="form-control" id="imap_username" name="imap_username" value="{{ settings.imap_username or '' }}">
</div>
<div class="mb-3">
<label for="imap_password" class="form-label">IMAP пароль (если пустой — используется пароль отправки)</label>
<input type="password" class="form-control" id="imap_password" name="imap_password" placeholder="Оставьте пустым, чтобы не менять">
</div>
<div class="mb-4">
<label for="imap_folder" class="form-label">Папка для сохранения копий</label>
<input type="text" class="form-control" id="imap_folder" name="imap_folder" value="{{ settings.imap_folder or 'Sent' }}" placeholder="Sent">
<div class="form-text text-muted">Название папки на сервере: обычно <code>Sent</code>, <code>Sent Items</code> или <code>Отправленные</code>.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
<a href="{{ url_for('main.admin_email_send') }}" class="btn btn-outline-primary">Перейти к рассылке</a>