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 from pathlib import Path from flask import current_app, render_template from app.services.ticket_service import generate_ticket_image from app.utils.paths import event_qr_folder 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 = 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: 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-кодом и билетом во вложении.""" config = current_app.config if event_name is None: event_name = guest.event.name if guest.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}") html_body = render_template( "email/invitation.html", guest=guest, event_name=event_name, additional_text=additional_text, qr_cid="qr_code_image", ticket_cid="ticket_image" if ticket_path else None, ) attachments = [] # QR-код qr_path = event_qr_folder(guest.event_id) / guest.qr_filename if not qr_path.exists(): qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename if 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", }) _send_email( to_address=guest.email, subject=f"Приглашение на мероприятие: {event_name}", html_body=html_body, attachments=attachments, ) guest.invitation_sent = True 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