Files
ACORE/apps/mail_connector/views.py
T
lokses 9c8d0d109a mail: несколько ящиков с переключателем; дислокация без скоупа локаций
Сотрудник может подключить несколько IMAP-ящиков и переключаться между ними.
Дислокация снова показывает всю сеть, а не только назначенные аптеки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:56:13 +03:00

716 lines
26 KiB
Python

"""Портал корпоративной почты (IMAP/SMTP) — подключение ящика самим сотрудником."""
import imaplib
import logging
import re
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_POST
from apps.mail_connector.access import (
can_use_portal_mail,
get_user_mailbox,
list_user_mailboxes,
mailbox_address,
set_active_mailbox,
suggested_mailbox_email,
)
from apps.mail_connector.autoconfig import AutoconfigResult, list_mail_candidates, source_label
from apps.mail_connector.imap_client import ImapMailClient, MailConnectionError
from apps.mail_connector.mail_auth import clear_mail_password, get_mail_password, is_mail_connected
from apps.mail_connector.mail_errors import humanize_mail_error
from apps.mail_connector.mail_settings import MailConfigError, MailSettings, get_mail_settings, resolve_mail_settings
from apps.mail_connector.models import UserMailbox
from apps.mail_connector.smtp_client import SmtpSendError, send_message
from core.portal_access import section_required
from core.utils.htmx import render_page
logger = logging.getLogger(__name__)
_EMAIL_ADDR_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
MAIL_ATTACH_MAX_FILES = 5
MAIL_ATTACH_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per file
MAIL_ATTACH_MAX_TOTAL = 15 * 1024 * 1024 # 15 MB total (Django upload limit)
def _collect_attachments(request) -> tuple[list[tuple[str, bytes, str]], list[str]]:
"""Зчитати вкладення з multipart-форми. Повертає (attachments, errors)."""
files = request.FILES.getlist('attachments')
if not files:
return [], []
errors: list[str] = []
if len(files) > MAIL_ATTACH_MAX_FILES:
errors.append(f'Не більше {MAIL_ATTACH_MAX_FILES} файлів.')
return [], errors
attachments: list[tuple[str, bytes, str]] = []
total = 0
for f in files:
name = (getattr(f, 'name', None) or 'file').strip() or 'file'
size = getattr(f, 'size', 0) or 0
if size <= 0:
continue
if size > MAIL_ATTACH_MAX_BYTES:
errors.append(f'«{name}» більший за 10 МБ.')
continue
total += size
if total > MAIL_ATTACH_MAX_TOTAL:
errors.append('Сумарний розмір вкладень більший за 15 МБ.')
break
content = f.read()
ctype = getattr(f, 'content_type', None) or 'application/octet-stream'
attachments.append((name, content, ctype))
return attachments, errors
def _email_guard(view_func):
@login_required
@section_required('email')
def _wrapped(request, *args, **kwargs):
if not can_use_portal_mail(request.user):
return HttpResponseForbidden('Нет доступа к почте.')
return view_func(request, *args, **kwargs)
return _wrapped
def _base_context(request, **extra):
try:
defaults = get_mail_settings()
mail_configured = True
mail_error = None
except MailConfigError as e:
defaults = None
mail_configured = False
mail_error = str(e)
mailboxes = list(list_user_mailboxes(request.user, connected_only=True))
return {
'mailbox': mailbox_address(request.user),
'mailboxes': mailboxes,
'suggested_email': suggested_mailbox_email(request.user),
'mail_connected': is_mail_connected(request),
'mail_configured': mail_configured,
'mail_error': mail_error,
'mail_defaults': defaults,
'adding_mailbox': False,
'active_folder': request.GET.get('folder', 'inbox'),
'folder_choices': [
('inbox', 'Вхідні'),
('sent', 'Відправлені'),
('drafts', 'Чернетки'),
('trash', 'Кошик'),
('starred', 'Важливі'),
],
**extra,
}
def _with_imap(request, fn):
"""Выполнить fn(client) или вернуть (error_key, error_msg)."""
box = get_user_mailbox(request.user)
address = mailbox_address(request.user)
password = get_mail_password(request)
if not address or not box:
return None, 'not_connected', None
if not password:
return None, 'not_connected', None
try:
settings = resolve_mail_settings(box)
except MailConfigError as e:
return None, 'not_configured', str(e)
try:
with ImapMailClient(settings, address, password) as client:
return fn(client), None, None
except (MailConnectionError, imaplib.IMAP4.error, OSError) as e:
logger.warning('IMAP %s: %s', address, e)
return None, 'connection_error', str(e)
MAIL_PAGE_SIZE = 40
@_email_guard
def email_index(request):
ctx = _base_context(request)
if not ctx['mail_configured']:
return render_page(request, 'email.html', ctx)
adding = request.GET.get('add') == '1'
if adding and ctx['mailboxes']:
ctx['mail_state'] = 'connect'
ctx['adding_mailbox'] = True
ctx['suggested_email'] = ''
return render_page(request, 'email.html', ctx)
if not ctx['mail_connected']:
ctx['mail_state'] = 'connect'
return render_page(request, 'email.html', ctx)
folder = ctx['active_folder']
starred = folder == 'starred'
search = request.GET.get('q', '').strip()
read_uid = request.GET.get('uid') if request.GET.get('view') == 'read' else None
def load(client):
messages, has_more = client.list_messages(
'inbox' if starred else folder,
limit=MAIL_PAGE_SIZE,
offset=0,
search_text=search,
starred_only=starred,
)
unread = client.unread_count('inbox')
message = None
if read_uid:
try:
message = client.fetch_message(read_uid, 'inbox' if starred else folder)
except MailConnectionError as e:
logger.warning('fetch_message uid=%s: %s', read_uid, e)
return messages, has_more, unread, None, str(e)
return messages, has_more, unread, message, None
result, err_key, err_msg = _with_imap(request, load)
if err_key:
ctx['mail_state'] = err_key
ctx['mail_error'] = err_msg
if err_key in ('not_connected', 'connection_error'):
if err_key == 'connection_error':
failed = get_user_mailbox(request.user)
host = (failed.imap_host if failed else '') or ''
port = failed.imap_port if failed else None
clear_mail_password(request)
if is_mail_connected(request):
# Есть другой ящик — переключаемся на него
response = HttpResponse('')
response['HX-Redirect'] = '/email/'
return response
ctx['connect_error'] = humanize_mail_error(
Exception(err_msg or 'connection failed'),
host=host,
port=port,
)
ctx['mail_state'] = 'connect'
ctx['mail_connected'] = False
ctx['mailbox'] = None
ctx['mailboxes'] = list(list_user_mailboxes(request.user, connected_only=True))
ctx['suggested_email'] = suggested_mailbox_email(request.user)
return render_page(request, 'email.html', ctx)
messages, has_more, unread, message, read_err = result
ctx.update({
'mail_state': 'ready',
'messages': messages,
'unread_count': unread,
'search_q': search,
'view': request.GET.get('view', 'list'),
'compose': request.GET.get('view') == 'compose',
'mail_has_more': has_more,
'mail_next_offset': MAIL_PAGE_SIZE if has_more else 0,
'mail_page_size': MAIL_PAGE_SIZE,
})
if message:
ctx['message'] = message
elif read_uid:
ctx['read_error'] = read_err or 'Не вдалося відкрити лист'
return render_page(request, 'email.html', ctx)
@_email_guard
def email_list_more(request):
"""HTMX: следующая страница списка писем."""
folder = request.GET.get('folder', 'inbox')
starred = folder == 'starred'
search = request.GET.get('q', '').strip()
try:
offset = max(0, int(request.GET.get('offset', 0)))
except (TypeError, ValueError):
offset = 0
def load(client):
return client.list_messages(
'inbox' if starred else folder,
limit=MAIL_PAGE_SIZE,
offset=offset,
search_text=search,
starred_only=starred,
)
result, err_key, err_msg = _with_imap(request, load)
if err_key:
return render(request, 'partials/email-list-more.html', {
'messages': [],
'mail_has_more': False,
'active_folder': folder,
'search_q': search,
'message': None,
'load_error': err_msg or 'Не вдалося завантажити',
})
messages, has_more = result or ([], False)
return render(request, 'partials/email-list-more.html', {
'messages': messages,
'mail_has_more': has_more,
'mail_next_offset': offset + MAIL_PAGE_SIZE if has_more else offset,
'active_folder': folder,
'search_q': search,
'message': None,
})
def _render_connect_panel(request, context):
ctx = {
'mail_defaults': _mail_defaults_or_none(),
'form_imap_host': '',
'form_imap_port': '',
'form_smtp_host': '',
'form_smtp_port': '',
'form_use_ssl': True,
'autoconfig_open': bool(context.get('connect_error')),
'autoconfig_message': '',
**context,
}
return render(request, 'partials/email-connect-panel.html', ctx)
def _mail_defaults_or_none():
try:
return get_mail_settings()
except MailConfigError:
return None
def _parse_optional_port(raw):
raw = (raw or '').strip()
if not raw:
return None
try:
port = int(raw)
except ValueError:
return None
if port < 1 or port > 65535:
return None
return port
def _candidate_settings(cand: AutoconfigResult) -> MailSettings:
return MailSettings(
imap_host=cand.imap_host,
imap_port=cand.imap_port,
smtp_host=cand.smtp_host,
smtp_port=cand.smtp_port,
use_ssl=cand.use_ssl,
verify_ssl=False,
)
def _build_connect_candidates(email, imap_host, imap_port, smtp_host, smtp_port, use_ssl):
"""Ручной ввод первым, затем автокандидаты (как Thunderbird пробует по очереди)."""
candidates: list[AutoconfigResult] = []
if imap_host or smtp_host:
try:
base = get_mail_settings()
except MailConfigError:
base = None
candidates.append(AutoconfigResult(
imap_host=imap_host or (base.imap_host if base else ''),
imap_port=imap_port or (base.imap_port if base else 993),
smtp_host=smtp_host or imap_host or (base.smtp_host if base else ''),
smtp_port=smtp_port or (base.smtp_port if base else 465),
use_ssl=use_ssl,
source='manual',
provider_id='manual',
score=200,
))
for cand in list_mail_candidates(email, include_portal=True):
if not cand.imap_host:
continue
candidates.append(cand)
# дедуп
seen = set()
out = []
for c in candidates:
k = c.key()
if k in seen or not c.imap_host:
continue
seen.add(k)
out.append(c)
return out
@_email_guard
@require_POST
def email_autoconfig(request):
"""HTMX: подобрать IMAP/SMTP по email и вернуть блок расширенных полей."""
email = (request.POST.get('email') or '').strip()
defaults = _mail_defaults_or_none()
ctx = {
'mail_defaults': defaults,
'form_imap_host': '',
'form_imap_port': '',
'form_smtp_host': '',
'form_smtp_port': '',
'form_use_ssl': True,
'autoconfig_open': False,
'autoconfig_message': '',
}
if not email or not _EMAIL_ADDR_RE.match(email):
ctx['autoconfig_message'] = 'Вкажіть коректний email для автовизначення сервера.'
return render(request, 'partials/email-autoconfig-fields.html', ctx)
candidates = list_mail_candidates(email, include_portal=True)
# для UI предпочитаем не-portal, если есть probe/ispdb
preferred = next(
(c for c in candidates if c.source not in ('portal', 'guess')),
candidates[0] if candidates else None,
)
if preferred is None and defaults:
ctx['autoconfig_message'] = (
f'Автоконфіг не знайдено — буде використано сервер порталу '
f'({defaults.imap_host}:{defaults.imap_port}).'
)
elif preferred is None:
ctx['autoconfig_open'] = True
ctx['autoconfig_message'] = 'Автоконфіг не знайдено. Вкажіть сервер вручну.'
else:
ctx.update(preferred.as_form_dict())
ctx['autoconfig_open'] = preferred.source != 'portal'
extras = [c for c in candidates if c.key() != preferred.key()][:3]
extra_txt = ''
if extras:
extra_txt = ' · також: ' + ', '.join(f'{c.imap_host}:{c.imap_port}' for c in extras)
ctx['autoconfig_message'] = (
f'Знайдено ({source_label(preferred.source)}): '
f'IMAP {preferred.imap_host}:{preferred.imap_port}, '
f'SMTP {preferred.smtp_host}:{preferred.smtp_port}'
f'{extra_txt}'
)
return render(request, 'partials/email-autoconfig-fields.html', ctx)
@_email_guard
@require_POST
def email_connect(request):
email = (request.POST.get('email') or '').strip()
password = request.POST.get('password', '')
imap_host = (request.POST.get('imap_host') or '').strip()
smtp_host = (request.POST.get('smtp_host') or '').strip()
imap_port = _parse_optional_port(request.POST.get('imap_port'))
smtp_port = _parse_optional_port(request.POST.get('smtp_port'))
use_ssl = (request.POST.get('use_ssl') or '1') != '0'
form_ctx = {
'suggested_email': email or suggested_mailbox_email(request.user),
'form_imap_host': imap_host,
'form_smtp_host': smtp_host,
'form_imap_port': request.POST.get('imap_port') or '',
'form_smtp_port': request.POST.get('smtp_port') or '',
'form_use_ssl': use_ssl,
'mail_defaults': _mail_defaults_or_none(),
'autoconfig_open': True,
'adding_mailbox': list_user_mailboxes(request.user, connected_only=True).exists(),
}
def _fail(msg, detail=''):
return _render_connect_panel(request, {
**form_ctx,
'connect_error': msg,
'connect_error_detail': detail,
})
if not email or not _EMAIL_ADDR_RE.match(email):
return _fail('Вкажіть коректну адресу поштової скриньки.')
if not password:
return _fail('Введіть пароль поштової скриньки.')
candidates = _build_connect_candidates(
email, imap_host, imap_port, smtp_host, smtp_port, use_ssl,
)
if not candidates:
return _fail(
'Не вдалося визначити поштовий сервер.',
'Вкажіть IMAP-хост у розширених налаштуваннях або увімкніть коннектор пошти в порталі.',
)
last_error: BaseException | None = None
last_cand: AutoconfigResult | None = None
tried: list[str] = []
for cand in candidates:
settings = _candidate_settings(cand)
tried.append(f'{cand.imap_host}:{cand.imap_port}')
try:
with ImapMailClient(settings, email, password):
pass
except MailConnectionError as e:
last_error, last_cand = e, cand
logger.info('IMAP probe fail %s@%s:%s — %s', email, cand.imap_host, cand.imap_port, e)
continue
except Exception as e:
last_error, last_cand = e, cand
logger.warning('IMAP probe unexpected %s@%s:%s — %s', email, cand.imap_host, cand.imap_port, e)
continue
# успех — не затираем другие ящики; уникальность (employee, email)
box, _ = UserMailbox.objects.update_or_create(
employee=request.user,
email=email,
defaults={
'password': password,
'imap_host': cand.imap_host,
'imap_port': cand.imap_port,
'smtp_host': cand.smtp_host,
'smtp_port': cand.smtp_port,
'use_ssl': cand.use_ssl,
'verify_ssl': False,
'is_connected': True,
'last_ok_at': timezone.now(),
},
)
set_active_mailbox(request.user, box)
response = _render_connect_panel(request, {
**form_ctx,
'suggested_email': email,
'form_imap_host': cand.imap_host,
'form_imap_port': cand.imap_port,
'form_smtp_host': cand.smtp_host,
'form_smtp_port': cand.smtp_port,
'form_use_ssl': cand.use_ssl,
'connect_success': True,
})
response['HX-Redirect'] = '/email/'
return response
host = last_cand.imap_host if last_cand else ''
port = last_cand.imap_port if last_cand else None
msg = humanize_mail_error(last_error or Exception('unknown'), host=host, port=port)
detail = ''
if len(tried) > 1:
detail = 'Спробовано: ' + ', '.join(tried[:6])
if len(tried) > 6:
detail += '…'
# показать в форме последний/лучший хост
if last_cand:
form_ctx.update({
'form_imap_host': last_cand.imap_host,
'form_imap_port': last_cand.imap_port,
'form_smtp_host': last_cand.smtp_host,
'form_smtp_port': last_cand.smtp_port,
'form_use_ssl': last_cand.use_ssl,
'autoconfig_open': True,
'autoconfig_message': 'Перевірте хост/пароль або оберіть інший сервер нижче.',
})
return _fail(msg, detail)
@_email_guard
@require_POST
def email_disconnect(request):
clear_mail_password(request)
response = _render_connect_panel(request, {
'suggested_email': suggested_mailbox_email(request.user),
'connect_success': False,
'adding_mailbox': False,
'mailboxes': list(list_user_mailboxes(request.user, connected_only=True)),
})
response['HX-Redirect'] = '/email/'
return response
@_email_guard
@require_POST
def email_switch(request):
"""Переключить активный ящик: mailbox_id = uuid или pk."""
raw = (request.POST.get('mailbox_id') or '').strip()
box = None
if raw:
qs = request.user.mailboxes.filter(is_connected=True).exclude(password='')
box = qs.filter(uuid=raw).first()
if box is None and raw.isdigit():
box = qs.filter(pk=int(raw)).first()
if box is None:
return HttpResponseForbidden('Скриньку не знайдено.')
set_active_mailbox(request.user, box)
response = HttpResponse('')
response['HX-Redirect'] = '/email/'
return response
@_email_guard
@require_POST
def email_send(request):
to_raw = request.POST.get('to', '').strip()
subject = request.POST.get('subject', '').strip()
body = request.POST.get('body', '').strip()
cc_raw = request.POST.get('cc', '').strip()
bcc_raw = request.POST.get('bcc', '').strip()
to_addrs = [a.strip() for a in to_raw.replace(';', ',').split(',') if a.strip()]
cc_addrs = [a.strip() for a in cc_raw.replace(';', ',').split(',') if a.strip()]
bcc_addrs = [a.strip() for a in bcc_raw.replace(';', ',').split(',') if a.strip()]
attachments, attach_errors = _collect_attachments(request)
errors = []
if not to_addrs:
errors.append('Вкажіть одержувача.')
if not subject:
errors.append('Вкажіть тему.')
if not body:
errors.append('Вкажіть текст листа.')
for addr in to_addrs + cc_addrs + bcc_addrs:
if not _EMAIL_ADDR_RE.match(addr):
errors.append(f'Некоректний email: {addr}')
errors.extend(attach_errors)
box = get_user_mailbox(request.user)
address = mailbox_address(request.user)
password = get_mail_password(request)
reply_uid = (request.POST.get('reply_uid') or '').strip()
reply_folder = request.POST.get('reply_folder') or 'inbox'
ctx = _base_context(
request,
compose=not bool(reply_uid),
form_to=to_raw,
form_cc=cc_raw,
form_bcc=bcc_raw,
form_subject=subject,
form_body=body,
active_folder=reply_folder if reply_uid else request.GET.get('folder', 'inbox'),
)
def _reload_reply(extra):
ctx['mail_state'] = 'ready'
ctx['view'] = 'read'
ctx['compose'] = False
ctx.update(extra)
starred = reply_folder == 'starred'
def load_one(client):
msg = client.fetch_message(reply_uid, 'inbox' if starred else reply_folder)
unread = client.unread_count('inbox')
return msg, unread
loaded, _err_key, err_msg = _with_imap(request, load_one)
if loaded:
ctx['message'], ctx['unread_count'] = loaded
else:
ctx['read_error'] = err_msg or 'Не вдалося відкрити лист'
return render_page(request, 'email.html', ctx)
def _reload_compose(extra):
ctx['mail_state'] = 'ready'
ctx['view'] = 'compose'
ctx['compose'] = True
ctx.update(extra)
folder = ctx.get('active_folder') or 'inbox'
starred = folder == 'starred'
def load_list(client):
messages, has_more = client.list_messages(
'inbox' if starred else folder,
limit=MAIL_PAGE_SIZE,
offset=0,
starred_only=starred,
)
unread = client.unread_count('inbox')
return messages, has_more, unread
loaded, _err_key, _err_msg = _with_imap(request, load_list)
if loaded:
messages, has_more, unread = loaded
ctx['messages'] = messages
ctx['unread_count'] = unread
ctx['mail_has_more'] = has_more
ctx['mail_next_offset'] = MAIL_PAGE_SIZE if has_more else 0
return render_page(request, 'email.html', ctx)
if not password or not box:
if reply_uid:
return _reload_reply({'send_errors': ['Підключіть поштову скриньку.']})
ctx['send_errors'] = ['Підключіть поштову скриньку.']
ctx['mail_state'] = 'connect'
return render_page(request, 'email.html', ctx)
if errors:
if reply_uid:
return _reload_reply({'send_errors': errors})
return _reload_compose({'send_errors': errors})
try:
settings = resolve_mail_settings(box)
send_message(
settings,
username=address,
password=password,
sender=address,
to_addrs=to_addrs,
subject=subject,
body=body,
cc_addrs=cc_addrs,
bcc_addrs=bcc_addrs,
attachments=attachments,
)
except MailConfigError as e:
if reply_uid:
return _reload_reply({'send_errors': [str(e)]})
return _reload_compose({'send_errors': [str(e)]})
except SmtpSendError as e:
if reply_uid:
return _reload_reply({'send_errors': [f'Помилка відправки: {e}']})
return _reload_compose({'send_errors': [f'Помилка відправки: {e}']})
if reply_uid:
return _reload_reply({'send_success': True, 'form_body': ''})
ctx['mail_state'] = 'ready'
ctx['send_success'] = True
ctx['view'] = 'list'
ctx['active_folder'] = 'sent'
ctx['compose'] = False
def load(client):
messages, has_more = client.list_messages('sent', limit=MAIL_PAGE_SIZE, offset=0)
unread = client.unread_count('inbox')
return messages, has_more, unread
result, err_key, err_msg = _with_imap(request, load)
if result:
messages, has_more, unread = result
ctx['messages'] = messages
ctx['unread_count'] = unread
ctx['mail_has_more'] = has_more
ctx['mail_next_offset'] = MAIL_PAGE_SIZE if has_more else 0
return render_page(request, 'email.html', ctx)
@_email_guard
def email_attachment(request):
from urllib.parse import quote
uid = (request.GET.get('uid') or '').strip()
folder = request.GET.get('folder', 'inbox')
if folder == 'starred':
folder = 'inbox'
try:
index = int(request.GET.get('i', ''))
except (TypeError, ValueError):
raise Http404('Файл не знайдено')
def load(client):
return client.fetch_attachment(uid, folder, index)
result, _err_key, _err_msg = _with_imap(request, load)
if not result:
raise Http404('Файл не знайдено')
name, payload, ctype = result
resp = HttpResponse(payload, content_type=ctype or 'application/octet-stream')
resp['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(name)}"
return resp