diff --git a/acore/urls.py b/acore/urls.py index d4cf8e8..cdaaf4c 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -23,6 +23,7 @@ from django.views.i18n import JavaScriptCatalog from core import views from core import directory_portal from core import user_groups_portal +from core.portal_search import portal_search_view from apps.hiring import recruiting from core.accounting_portal import accounting_portal_view from apps.it_manager.billing import internet_payment_update_view @@ -38,6 +39,7 @@ urlpatterns = [ path('i18n/', include('django.conf.urls.i18n')), # set_language для переключателя мов path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'), # переводы для JS path('api/attendance/', include('apps.visit_manager.api.urls')), + path('api/search', portal_search_view, name='portal_search'), path('', include('apps.employees.urls')), path('', include('apps.ad_connector.urls')), path('', include('apps.feed.urls')), diff --git a/apps/mail_connector/access.py b/apps/mail_connector/access.py index 4fe60a0..5db2e35 100644 --- a/apps/mail_connector/access.py +++ b/apps/mail_connector/access.py @@ -1,4 +1,6 @@ -"""Доступ к почте: только свой ящик (employee.email).""" +"""Доступ к почте: личный подключённый ящик сотрудника.""" +from django.core.exceptions import ObjectDoesNotExist + from core.portal_access import user_can_access @@ -6,8 +8,28 @@ def can_use_portal_mail(user) -> bool: return user_can_access(user, 'email') -def mailbox_address(user) -> str | None: +def get_user_mailbox(user): + if not user or not getattr(user, 'is_authenticated', False): + return None + try: + return user.mailbox + except ObjectDoesNotExist: + return None + + +def suggested_mailbox_email(user) -> str: + """Подсказка в форме: рабочий email из профиля или ранее подключённый адрес.""" + box = get_user_mailbox(user) + if box and box.email: + return box.email.strip() email = (getattr(user, 'email', None) or '').strip() if not email or email.endswith('@local.domain'): - return None + return '' return email + + +def mailbox_address(user) -> str | None: + box = get_user_mailbox(user) + if box and box.is_connected and box.email: + return box.email.strip() + return None diff --git a/apps/mail_connector/admin.py b/apps/mail_connector/admin.py index 71268b1..0090d32 100644 --- a/apps/mail_connector/admin.py +++ b/apps/mail_connector/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from unfold.admin import ModelAdmin -from .models import CarbonioSyncLog +from .models import CarbonioSyncLog, UserMailbox @admin.register(CarbonioSyncLog) @@ -19,3 +19,12 @@ class CarbonioSyncLogAdmin(ModelAdmin): def has_delete_permission(self, request, obj=None): return False + + +@admin.register(UserMailbox) +class UserMailboxAdmin(ModelAdmin): + list_display = ('employee', 'email', 'is_connected', 'imap_host', 'last_ok_at', 'updated_at') + list_filter = ('is_connected',) + search_fields = ('email', 'employee__username', 'employee__last_name', 'employee__first_name') + readonly_fields = ('last_ok_at', 'created_at', 'updated_at', 'uuid') + raw_id_fields = ('employee',) diff --git a/apps/mail_connector/autoconfig.py b/apps/mail_connector/autoconfig.py new file mode 100644 index 0000000..0a2e4e3 --- /dev/null +++ b/apps/mail_connector/autoconfig.py @@ -0,0 +1,410 @@ +"""Автоопределение IMAP/SMTP по адресу (как Thunderbird autoconfig). + +Порядок: +1. Mozilla ISPDB +2. autoconfig.{domain} / well-known XML +3. MX → ISPDB по доменам MX +4. DNS SRV (_imaps / _imap / _submission) — если есть dnspython +5. Эвристика хостов с проверкой DNS (+ лёгкий TCP probe): + mail.{domain}, imap.{domain}, {domain}, smtp.{domain} +""" +from __future__ import annotations + +import logging +import re +import socket +import xml.etree.ElementTree as ET +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + +_EMAIL_RE = re.compile(r'^[^@\s]+@([^@\s]+\.[^@\s]+)$', re.I) +_TIMEOUT = 3.5 +_PROBE_TIMEOUT = 1.2 + + +@dataclass(frozen=True) +class AutoconfigResult: + imap_host: str + imap_port: int + smtp_host: str + smtp_port: int + use_ssl: bool = True # IMAP SSL; SMTP SSL(465) или STARTTLS(587) + source: str = '' + provider_id: str = '' + score: int = 0 # выше = лучше кандидат + + def as_form_dict(self) -> dict: + return { + 'form_imap_host': self.imap_host, + 'form_imap_port': self.imap_port, + 'form_smtp_host': self.smtp_host, + 'form_smtp_port': self.smtp_port, + 'form_use_ssl': self.use_ssl, + 'autoconfig_source': self.source, + 'autoconfig_provider': self.provider_id, + } + + def key(self) -> tuple: + return (self.imap_host, self.imap_port, self.smtp_host, self.smtp_port, self.use_ssl) + + +def extract_domain(email: str) -> str | None: + m = _EMAIL_RE.match((email or '').strip()) + return m.group(1).lower() if m else None + + +def _socket_is_ssl(socket_type: str | None) -> bool: + st = (socket_type or '').strip().upper() + return st in ('SSL', 'SSL/TLS', 'TLS') + + +def _parse_client_config_xml(xml_text: str, source: str) -> AutoconfigResult | None: + try: + root = ET.fromstring(xml_text) + except ET.ParseError: + return None + + provider = root.find('emailProvider') + if provider is None: + return None + + provider_id = (provider.get('id') or '').strip() + incoming = None + for srv in provider.findall('incomingServer'): + if (srv.get('type') or '').lower() == 'imap': + incoming = srv + break + outgoing = None + for srv in provider.findall('outgoingServer'): + if (srv.get('type') or '').lower() == 'smtp': + outgoing = srv + break + if incoming is None or outgoing is None: + return None + + def _text(el, tag: str) -> str: + child = el.find(tag) + return (child.text or '').strip() if child is not None else '' + + imap_host = _text(incoming, 'hostname') + smtp_host = _text(outgoing, 'hostname') + if not imap_host or not smtp_host: + return None + + try: + imap_port = int(_text(incoming, 'port') or '993') + smtp_port = int(_text(outgoing, 'port') or '465') + except ValueError: + return None + + imap_ssl = _socket_is_ssl(_text(incoming, 'socketType')) or imap_port in (993,) + smtp_sock = _text(outgoing, 'socketType') + smtp_secure = ( + _socket_is_ssl(smtp_sock) + or smtp_sock.upper() == 'STARTTLS' + or smtp_port in (465, 587) + ) + use_ssl = bool(imap_ssl or smtp_secure) + + return AutoconfigResult( + imap_host=imap_host, + imap_port=imap_port, + smtp_host=smtp_host, + smtp_port=smtp_port, + use_ssl=use_ssl, + source=source, + provider_id=provider_id, + score=100, + ) + + +def _fetch_xml(url: str) -> str | None: + try: + with httpx.Client(timeout=_TIMEOUT, follow_redirects=True, verify=False) as client: + resp = client.get(url, headers={'Accept': 'application/xml, text/xml, */*'}) + if resp.status_code != 200: + return None + text = resp.text or '' + if ' AutoconfigResult | None: + for url, source in urls: + xml_text = _fetch_xml(url) + if not xml_text: + continue + result = _parse_client_config_xml(xml_text, source) + if result: + return result + return None + + +def _host_resolves(host: str) -> bool: + try: + socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) + return True + except OSError: + return False + + +def _port_open(host: str, port: int, timeout: float = _PROBE_TIMEOUT) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _mx_hosts(domain: str) -> list[str]: + """MX exchange hosts + parent domains для ISPDB.""" + try: + import dns.resolver + except ImportError: + return [] + + found: list[str] = [] + try: + answers = dns.resolver.resolve(domain, 'MX') + hosts = sorted( + ((r.preference, str(r.exchange).rstrip('.').lower()) for r in answers), + key=lambda x: x[0], + ) + except Exception as e: + logger.debug('MX lookup %s: %s', domain, e) + return [] + + for _, mx_host in hosts: + if mx_host and mx_host not in found: + found.append(mx_host) + labels = mx_host.split('.') + for i in range(1, len(labels) - 1): + cand = '.'.join(labels[i:]) + if cand == domain or cand in found or '.' not in cand: + continue + found.append(cand) + if len(found) >= 10: + return found + return found + + +def _srv_candidates(domain: str) -> list[AutoconfigResult]: + try: + import dns.resolver + except ImportError: + return [] + + def lookup(name: str) -> list[tuple[int, str]]: + try: + answers = dns.resolver.resolve(name, 'SRV') + rows = sorted( + ( + (int(r.priority), int(r.weight), int(r.port), str(r.target).rstrip('.').lower()) + for r in answers + ), + key=lambda x: (x[0], -x[1]), + ) + return [(port, target) for _p, _w, port, target in rows if target] + except Exception: + return [] + + imaps = lookup(f'_imaps._tcp.{domain}') + imap = lookup(f'_imap._tcp.{domain}') + smtp = lookup(f'_submissions._tcp.{domain}') or lookup(f'_submission._tcp.{domain}') + + results: list[AutoconfigResult] = [] + imap_pick = imaps[0] if imaps else (imap[0] if imap else None) + smtp_pick = smtp[0] if smtp else None + if imap_pick: + imap_port, imap_host = imap_pick + use_ssl = imap_port == 993 or bool(imaps) + if smtp_pick: + smtp_port, smtp_host = smtp_pick + else: + smtp_host = imap_host + smtp_port = 465 if use_ssl else 587 + results.append(AutoconfigResult( + imap_host=imap_host, + imap_port=imap_port, + smtp_host=smtp_host, + smtp_port=smtp_port, + use_ssl=use_ssl, + source='dns-srv', + provider_id=domain, + score=90, + )) + return results + + +def _heuristic_candidates(domain: str) -> list[AutoconfigResult]: + """Типовые имена + MX-хосты; приоритет у тех, кто резолвится / отвечает на порту.""" + mx = _mx_hosts(domain) + imap_names: list[str] = [] + for name in [f'mail.{domain}', f'imap.{domain}', domain, f'imapmail.{domain}', *mx[:3]]: + if name and name not in imap_names: + imap_names.append(name) + + smtp_names: list[str] = [] + for name in [f'mail.{domain}', f'smtp.{domain}', domain, f'relay.{domain}', *mx[:3]]: + if name and name not in smtp_names: + smtp_names.append(name) + + results: list[AutoconfigResult] = [] + for imap_host in imap_names: + resolves = _host_resolves(imap_host) + if not resolves: + continue + # предпочтительно SSL/993, иначе STARTTLS/143 + port_ssl_open = _port_open(imap_host, 993) + port_plain_open = False if port_ssl_open else _port_open(imap_host, 143) + if not port_ssl_open and not port_plain_open: + # DNS есть — всё равно предлагаем 993 (файрвол может резать probe) + imap_port, use_ssl, score = 993, True, 40 + elif port_ssl_open: + imap_port, use_ssl, score = 993, True, 70 + else: + imap_port, use_ssl, score = 143, False, 50 + + smtp_host = next((h for h in smtp_names if _host_resolves(h)), imap_host) + if use_ssl: + smtp_port = 465 if _port_open(smtp_host, 465) or not _port_open(smtp_host, 587) else 587 + else: + smtp_port = 587 + + # mail.domain чуть предпочтительнее голого domain + if imap_host.startswith('mail.'): + score += 5 + elif imap_host == domain: + score += 2 + + results.append(AutoconfigResult( + imap_host=imap_host, + imap_port=imap_port, + smtp_host=smtp_host, + smtp_port=smtp_port, + use_ssl=use_ssl, + source='probe' if (port_ssl_open or port_plain_open) else 'guess', + provider_id=domain, + score=score, + )) + + # Если ничего не резолвится — слабые догадки без DNS (как раньше) + if not results: + results.append(AutoconfigResult( + imap_host=f'mail.{domain}', + imap_port=993, + smtp_host=f'mail.{domain}', + smtp_port=465, + use_ssl=True, + source='guess', + provider_id=domain, + score=10, + )) + results.append(AutoconfigResult( + imap_host=f'imap.{domain}', + imap_port=993, + smtp_host=f'smtp.{domain}', + smtp_port=465, + use_ssl=True, + source='guess', + provider_id=domain, + score=5, + )) + return results + + +def _dedupe(results: list[AutoconfigResult]) -> list[AutoconfigResult]: + seen: set[tuple] = set() + out: list[AutoconfigResult] = [] + for r in sorted(results, key=lambda x: -x.score): + k = r.key() + if k in seen: + continue + seen.add(k) + out.append(r) + return out + + +def list_mail_candidates(email: str, *, include_portal: bool = True) -> list[AutoconfigResult]: + """Все кандидаты (от лучших к худшим) для UI и пробного логина.""" + domain = extract_domain(email) + if not domain: + return [] + + found: list[AutoconfigResult] = [] + + urls = [ + (f'https://autoconfig.thunderbird.net/v1.1/{domain}', 'ispdb'), + (f'https://autoconfig.{domain}/mail/config-v1.1.xml', 'autoconfig-https'), + (f'http://autoconfig.{domain}/mail/config-v1.1.xml', 'autoconfig-http'), + (f'https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml', 'well-known'), + (f'http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml', 'well-known-http'), + ] + xml_hit = _try_urls(urls) + if xml_hit: + found.append(xml_hit) + else: + for mx_domain in _mx_hosts(domain): + # только «доменные» варианты для ISPDB, не FQDN MX вроде aspmx.l.google.com целиком как path — + # ISPDB ждёт domain; пробуем parent-и из _mx_hosts + if mx_domain.count('.') < 1: + continue + hit = _try_urls([ + (f'https://autoconfig.thunderbird.net/v1.1/{mx_domain}', f'ispdb-mx:{mx_domain}'), + ]) + if hit: + found.append(hit) + break + + found.extend(_srv_candidates(domain)) + found.extend(_heuristic_candidates(domain)) + + if include_portal: + try: + from apps.mail_connector.mail_settings import get_mail_settings + base = get_mail_settings() + found.append(AutoconfigResult( + imap_host=base.imap_host, + imap_port=base.imap_port, + smtp_host=base.smtp_host, + smtp_port=base.smtp_port, + use_ssl=base.use_ssl, + source='portal', + provider_id='portal', + score=55, + )) + except Exception: + pass + + return _dedupe(found) + + +def discover_mail_config(email: str) -> AutoconfigResult | None: + """Лучший кандидат для подсказки в форме.""" + candidates = list_mail_candidates(email) + return candidates[0] if candidates else None + + +def source_label(source: str) -> str: + labels = { + 'ispdb': 'Mozilla ISPDB', + 'autoconfig-https': 'autoconfig домену', + 'autoconfig-http': 'autoconfig домену', + 'well-known': 'well-known домену', + 'well-known-http': 'well-known домену', + 'dns-srv': 'DNS SRV', + 'probe': 'перевірка портів', + 'guess': 'типові імена хостів', + 'portal': 'налаштування порталу', + 'manual': 'вручну', + } + if source.startswith('ispdb-mx:'): + return f'Mozilla ISPDB (MX {source.split(":", 1)[1]})' + return labels.get(source, source or 'авто') diff --git a/apps/mail_connector/imap_client.py b/apps/mail_connector/imap_client.py index 1459502..042a466 100644 --- a/apps/mail_connector/imap_client.py +++ b/apps/mail_connector/imap_client.py @@ -4,12 +4,14 @@ from __future__ import annotations import email import imaplib import logging +import quopri import re import ssl from dataclasses import dataclass from datetime import datetime from email.header import decode_header from email.utils import parseaddr, parsedate_to_datetime +from html import unescape from apps.mail_connector.mail_settings import MailSettings @@ -27,6 +29,87 @@ class MailConnectionError(Exception): pass +def _imap_quote(value: str) -> str: + """Безопасная строка для IMAP SEARCH (без кавычек/управляющих).""" + return value.replace('\\', ' ').replace('"', ' ').replace('\r', ' ').replace('\n', ' ').strip() + + +def _looks_like_domain(q: str) -> str | None: + """Вернуть domain.com если запрос похож на домен / @домен.""" + domain = (q or '').strip().lstrip('@').lower() + if ( + domain + and '.' in domain + and ' ' not in domain + and '@' not in domain + and re.fullmatch(r'[a-z0-9][a-z0-9.-]*\.[a-z]{2,}', domain) + ): + return domain + return None + + +def build_search_strategies(search_text: str = '', *, starred_only: bool = False) -> list[str]: + """ + Несколько вариантов IMAP SEARCH — от простого к узкому. + Глубокий вложенный OR + BODY на Carbonio/Zimbra часто даёт NO/пусто. + Плоский OR (как Roundcube) и TEXT работают стабильнее. + """ + prefix = 'FLAGGED ' if starred_only else '' + q = _imap_quote(search_text or '') + if not q: + return [f'{prefix}ALL'.strip()] + + quoted = f'"{q}"' + strategies = [ + # 1) TEXT — заголовки + тело (лучше для домена в адресе) + f'{prefix}TEXT {quoted}'.strip(), + # 2) плоский OR по ключевым полям (без BODY) + f'{prefix}OR OR OR SUBJECT {quoted} FROM {quoted} TO {quoted} CC {quoted}'.strip(), + # 3) только адреса + f'{prefix}OR FROM {quoted} TO {quoted}'.strip(), + # 4) тема + f'{prefix}SUBJECT {quoted}'.strip(), + ] + + domain = _looks_like_domain(q) + if domain: + at_q = f'"@{domain}"' + dom_q = f'"{domain}"' + strategies[0:0] = [ + f'{prefix}OR OR FROM {at_q} TO {at_q} CC {at_q}'.strip(), + f'{prefix}OR OR HEADER From {dom_q} HEADER To {dom_q} HEADER Cc {dom_q}'.strip(), + f'{prefix}TEXT {at_q}'.strip(), + ] + return strategies + + +def build_search_criteria(search_text: str = '', *, starred_only: bool = False) -> str: + """Основной критерий (первый из стратегий) — для тестов/совместимости.""" + return build_search_strategies(search_text, starred_only=starred_only)[0] + + +def _header_matches_query(subject: str, sender: str, query: str, from_addr: str = '') -> bool: + """Локальная проверка: тема / from / email / домен.""" + q = (query or '').strip().lower() + if not q: + return True + hay = f'{subject or ""} {sender or ""} {from_addr or ""}'.lower() + if q in hay: + return True + domain = _looks_like_domain(q) + if domain and domain in hay: + return True + if q.startswith('@') and q[1:] in hay: + return True + if '@' in q and ' ' not in q: + if q in hay: + return True + _, _, dom = q.partition('@') + if dom and dom in hay: + return True + return False + + @dataclass class MailSummary: uid: str @@ -35,6 +118,7 @@ class MailSummary: date: datetime | None preview: str is_unread: bool + from_addr: str = '' # raw email for search/domain match def _decode_mime(value: str | None) -> str: @@ -49,42 +133,204 @@ def _decode_mime(value: str | None) -> str: return ''.join(parts).strip() -def _preview_from_message(msg: email.message.Message, limit: int = 120) -> str: - if msg.is_multipart(): - for part in msg.walk(): - if part.get_content_type() == 'text/plain' and part.get_content_disposition() != 'attachment': - payload = part.get_payload(decode=True) or b'' - charset = part.get_content_charset() or 'utf-8' - text = payload.decode(charset, errors='replace') - return ' '.join(text.split())[:limit] +def _decode_bytes(payload: bytes, charset: str | None) -> str: + for enc in (charset, 'utf-8', 'cp1251', 'latin-1'): + if not enc: + continue + try: + return payload.decode(enc) + except (LookupError, UnicodeDecodeError): + continue + return payload.decode('utf-8', errors='replace') + + +def _part_payload_text(part: email.message.Message) -> str: + """Декодировать тело MIME-части (base64 / quoted-printable → unicode).""" + payload = part.get_payload(decode=True) + if payload is None: + raw = part.get_payload() + if isinstance(raw, str): + return raw return '' - payload = msg.get_payload(decode=True) or b'' - charset = msg.get_content_charset() or 'utf-8' - text = payload.decode(charset, errors='replace') - return ' '.join(text.split())[:limit] + if not isinstance(payload, (bytes, bytearray)): + return str(payload) + return _decode_bytes(bytes(payload), part.get_content_charset()) -def _message_body(msg: email.message.Message) -> str: - if msg.is_multipart(): - html_part = None - text_part = None - for part in msg.walk(): - ctype = part.get_content_type() - if part.get_content_disposition() == 'attachment': +def _html_to_visible_text(html: str) -> str: + """HTML → читаемый текст (без CSS/скриптов).""" + text = html or '' + text = re.sub(r'(?is)<(script|style|head|title|noscript)[^>]*>.*?', ' ', text) + text = re.sub(r'(?is)', ' ', text) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'(?i)', '\n\n', text) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'(?s)<[^>]+>', ' ', text) + text = unescape(text) + text = text.replace('\xa0', ' ') + text = re.sub(r'[ \t]+\n', '\n', text) + text = re.sub(r'\n{3,}', '\n\n', text) + text = re.sub(r'[ \t]{2,}', ' ', text) + return text.strip() + + +def _sanitize_email_html(html: str) -> str: + """Минимальная санация для iframe sandbox (без script/handlers).""" + text = html or '' + text = re.sub(r'(?is)]*>.*?', '', text) + text = re.sub(r'(?is)]*>.*?', '', text) + text = re.sub(r'(?is)]*>.*?', '', text) + text = re.sub(r'(?is)]*>', '', text) + text = re.sub(r'(?is)\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)', '', text) + text = re.sub(r'(?i)javascript:', '', text) + text = re.sub(r'(?i)vbscript:', '', text) + # относительные cid: картинки не загрузятся — ок + return text + + +def _maybe_decode_quoted_printable(raw: str) -> str: + if not raw: + return '' + # типичные маркеры QP в превью BODY.PEEK[TEXT] + if '=3D' in raw or '=\r\n' in raw or '=\n' in raw or re.search(r'=[0-9A-F]{2}', raw): + try: + return quopri.decodestring(raw.encode('latin-1', errors='replace')).decode('utf-8', errors='replace') + except Exception: + return raw + return raw + + +def _clean_preview_snippet(raw: str, limit: int = 140) -> str: + text = _maybe_decode_quoted_printable(raw or '') + if 'Content-Type:' in text[:500] or text.lstrip().startswith('--') or 'multipart message' in text[:120].lower(): + parts = re.split(r'\r?\n\r?\n', text, maxsplit=3) + # взять последний «похожий на тело» кусок + for part in reversed(parts): + cand = part.strip() + if not cand or cand.lower().startswith('content-'): continue - if ctype == 'text/plain' and text_part is None: - text_part = part - elif ctype == 'text/html' and html_part is None: - html_part = part - part = text_part or html_part - if not part: - return '' - payload = part.get_payload(decode=True) or b'' - charset = part.get_content_charset() or 'utf-8' - return payload.decode(charset, errors='replace') - payload = msg.get_payload(decode=True) or b'' - charset = msg.get_content_charset() or 'utf-8' - return payload.decode(charset, errors='replace') + if cand.startswith('--'): + continue + text = cand + break + text = re.sub(r'(?im)^(Content-|MIME-|--+|_NextPart).*$', ' ', text) + low = text.lstrip()[:300].lower() + if ( + low.startswith('' in text[:300]: + text = _html_to_visible_text(text) + text = ' '.join(text.split()) + if len(text) > limit: + return text[: limit - 1].rstrip() + '…' + return text + + +def _preview_from_peek_bytes(chunk: bytes, limit: int = 140) -> str: + """Превью из куска RFC822 / TEXT: сначала полный MIME-парс, иначе эвристика.""" + if not chunk: + return '' + + # 1) Попробовать как начало полного письма (BODY.PEEK[]) + try: + msg = email.message_from_bytes(chunk) + # если есть Content-Type или multipart — парсер понял структуру + if msg.get_content_maintype() != 'text' or msg.get_content_type() == 'text/html' or msg.is_multipart(): + preview = _preview_from_message(msg) + if preview: + return preview[:limit] + ('…' if len(preview) > limit else '') + # text/plain без multipart + if msg.get_content_type() == 'text/plain': + text = _part_payload_text(msg) + if text.strip(): + return ' '.join(text.split())[:limit] + except Exception: + pass + + # 2) Обернуть TEXT-peek (без заголовков письма) в минимальный MIME + try: + raw = chunk.decode('utf-8', errors='replace') + except Exception: + raw = chunk.decode('latin-1', errors='replace') + + if raw.lstrip().startswith('--') or 'Content-Type:' in raw[:800]: + wrapped = ( + 'MIME-Version: 1.0\r\n' + 'Content-Type: multipart/mixed; boundary="ACORE-PEEK"\r\n\r\n' + '--ACORE-PEEK\r\n' + f'{raw}\r\n' + '--ACORE-PEEK--\r\n' + ).encode('utf-8', errors='replace') + try: + msg = email.message_from_bytes(wrapped) + preview = _preview_from_message(msg) + if preview: + return preview[:limit] + ('…' if len(preview) > limit else '') + except Exception: + pass + + return _clean_preview_snippet(raw, limit=limit) + + +def _extract_bodies(msg: email.message.Message) -> tuple[str | None, str | None]: + """Вернуть (text/plain, text/html) с декодированием.""" + text_plain = None + text_html = None + + if msg.is_multipart(): + for part in msg.walk(): + if part.is_multipart(): + continue + disp = (part.get_content_disposition() or '').lower() + if disp == 'attachment': + continue + ctype = (part.get_content_type() or '').lower() + if ctype == 'text/plain' and text_plain is None: + text_plain = _part_payload_text(part) + elif ctype == 'text/html' and text_html is None: + text_html = _part_payload_text(part) + else: + ctype = (msg.get_content_type() or '').lower() + raw = _part_payload_text(msg) + if ctype == 'text/html': + text_html = raw + else: + text_plain = raw + + return text_plain, text_html + + +def _preview_from_message(msg: email.message.Message, limit: int = 140) -> str: + text_plain, text_html = _extract_bodies(msg) + if text_plain and text_plain.strip(): + return ' '.join(text_plain.split())[:limit] + if text_html: + return _html_to_visible_text(text_html)[:limit] + return '' + + +def _message_content(msg: email.message.Message) -> tuple[str, str | None, bool]: + """(body_text, body_html_sanitized|None, is_html).""" + text_plain, text_html = _extract_bodies(msg) + plain = (text_plain or '').strip() + html_raw = (text_html or '').strip() + + if html_raw: + sanitized = _sanitize_email_html(html_raw) + # если plain пустой или это «отписка одной строкой» — показываем HTML + if not plain or len(plain) < 40: + return _html_to_visible_text(html_raw), sanitized, True + return plain, sanitized, True + + return plain, None, False def _iter_file_parts(msg: email.message.Message): @@ -112,6 +358,18 @@ def extract_reply_address(header: str) -> str: return (addr or '').strip() +def display_address(header: str) -> str: + """Имя отправителя для списка (без угловых скобок, если есть display-name).""" + name, addr = parseaddr(header or '') + name = _decode_mime(name).strip() if name else '' + if name: + # убрать кавычки вокруг имени + if len(name) >= 2 and name[0] == name[-1] and name[0] in '\"\'': + name = name[1:-1].strip() + return name or addr or '—' + return (addr or (header or '').strip() or '—') + + def reply_subject(subject: str) -> str: text = (subject or '').strip() if text.lower().startswith('re:'): @@ -151,12 +409,23 @@ class ImapMailClient: self._conn = imaplib.IMAP4_SSL(s.imap_host, s.imap_port, ssl_context=ctx) else: self._conn = imaplib.IMAP4(s.imap_host, s.imap_port) + # imaplib по умолчанию ascii — пароли/логины с Unicode дают UnicodeEncodeError. + self._conn._encoding = 'utf-8' typ, data = self._conn.login(self.username, self.password) if typ != 'OK': - raise MailConnectionError(data[0].decode() if data else 'IMAP login failed') + detail = data[0].decode() if data and isinstance(data[0], (bytes, bytearray)) else (data[0] if data else 'IMAP login failed') + raise MailConnectionError(str(detail)) + except MailConnectionError: + self.close() + raise except imaplib.IMAP4.error as e: + self.close() raise MailConnectionError(str(e)) from e - except OSError as e: + except (OSError, UnicodeEncodeError, ssl.SSLError, TimeoutError) as e: + self.close() + raise MailConnectionError(str(e)) from e + except Exception as e: + self.close() raise MailConnectionError(str(e)) from e def close(self) -> None: @@ -173,45 +442,130 @@ class ImapMailClient: if typ != 'OK': raise MailConnectionError(f'Папка недоступна: {folder}') + def _uid_search(self, criteria: str) -> list[bytes] | None: + """Выполнить UID SEARCH. None = ошибка сервера; [] = пусто.""" + assert self._conn + try: + # UTF-8 для кириллицы в теме/адресах + if any(ord(c) > 127 for c in criteria): + typ, data = self._conn.uid('SEARCH', 'UTF-8', criteria) + else: + typ, data = self._conn.uid('SEARCH', None, criteria) + except (imaplib.IMAP4.error, OSError) as e: + logger.info('IMAP SEARCH failed (%s): %s', criteria[:80], e) + return None + if typ != 'OK': + logger.info('IMAP SEARCH not OK (%s): %s %s', criteria[:80], typ, data) + return None + if not data or not data[0]: + return [] + return data[0].split() + + def _search_uids(self, search_text: str, *, starred_only: bool) -> list[bytes]: + """Пробуем стратегии SEARCH; при пустоте/ошибке — ALL (локальный фильтр дальше).""" + strategies = build_search_strategies(search_text, starred_only=starred_only) + for crit in strategies: + uids = self._uid_search(crit) + if uids is None: + continue + if uids: + return list(reversed(uids)) + # ничего не нашли сервером — отдаём все UID, отфильтруем по заголовкам + if search_text.strip(): + uids = self._uid_search('FLAGGED' if starred_only else 'ALL') + if uids: + return list(reversed(uids)) + return [] + def list_messages( self, folder_key: str = 'inbox', *, limit: int = 40, + offset: int = 0, search_text: str = '', starred_only: bool = False, - ) -> list[MailSummary]: + ) -> tuple[list[MailSummary], bool]: + """Список писем (новые сверху). Возвращает (messages, has_more).""" assert self._conn + offset = max(0, int(offset or 0)) + limit = max(1, min(int(limit or 40), 100)) + query = (search_text or '').strip() + if starred_only: imap_folder = PORTAL_FOLDERS['inbox'] self._select_folder(imap_folder) - criteria = '(FLAGGED)' else: imap_folder = PORTAL_FOLDERS.get(folder_key, 'INBOX') self._select_folder(imap_folder) - criteria = 'ALL' - if search_text.strip(): - safe = search_text.replace('"', ' ').strip() - criteria = f'(OR SUBJECT "{safe}" FROM "{safe}" BODY "{safe}")' - typ, data = self._conn.uid('search', None, criteria) - if typ != 'OK' or not data or not data[0]: - return [] + all_uids = self._search_uids(query, starred_only=starred_only) + if not all_uids: + return [], False - uids = data[0].split() - uids = uids[-limit:] - uids.reverse() - if not uids: - return [] + # Локальный фильтр по заголовкам (тема/from/домен), если есть запрос: + # серверный SEARCH часто игнорит домен или отдаёт пусто на Carbonio. + if query: + # сканируем пачками до разумного лимита, чтобы набрать страницу + scan_cap = min(len(all_uids), max(500, offset + limit * 10)) + matched: list[bytes] = [] + chunk_size = 100 + for i in range(0, scan_cap, chunk_size): + chunk = all_uids[i:i + chunk_size] + headers = self._fetch_headers(chunk) + for uid in chunk: + key = uid.decode() if isinstance(uid, bytes) else str(uid) + summary = headers.get(key) + if summary and _header_matches_query( + summary.subject, summary.sender, query, summary.from_addr, + ): + matched.append(uid) + if len(matched) >= offset + limit + 20: + break + page_source = matched + else: + page_source = all_uids - uid_set = b','.join(uids).decode() + has_more = (offset + limit) < len(page_source) + page_uids = page_source[offset: offset + limit] + if not page_uids: + return [], False + + by_uid = self._fetch_headers(page_uids) + summaries = [ + by_uid[u.decode() if isinstance(u, bytes) else str(u)] + for u in page_uids + if (u.decode() if isinstance(u, bytes) else str(u)) in by_uid + ] + + for summary in summaries: + try: + typ, body_data = self._conn.uid('fetch', summary.uid, '(BODY.PEEK[]<0.16384>)') + if typ == 'OK' and body_data: + chunk = b'' + for part in body_data: + if isinstance(part, tuple) and len(part) >= 2 and isinstance(part[1], (bytes, bytearray)): + chunk = bytes(part[1]) + break + if chunk: + summary.preview = _preview_from_peek_bytes(chunk) + except Exception: + logger.debug('preview fetch failed uid=%s', summary.uid, exc_info=True) + + return summaries, has_more + + def _fetch_headers(self, page_uids: list[bytes]) -> dict[str, MailSummary]: + assert self._conn + if not page_uids: + return {} + uid_set = b','.join(page_uids).decode() typ, fetched = self._conn.uid( 'fetch', uid_set, '(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] FLAGS)', ) if typ != 'OK' or not fetched: - return [] + return {} - summaries: list[MailSummary] = [] + by_uid: dict[str, MailSummary] = {} for item in fetched: if not isinstance(item, tuple) or len(item) < 2: continue @@ -228,26 +582,18 @@ class ImapMailClient: dt = parsedate_to_datetime(date_raw) if date_raw else None except (TypeError, ValueError): dt = None - summaries.append(MailSummary( + from_raw = _decode_mime(msg.get('From')) or '' + _name, addr = parseaddr(from_raw) + by_uid[uid] = MailSummary( uid=uid, subject=_decode_mime(msg.get('Subject')) or '(без теми)', - sender=_decode_mime(msg.get('From')) or '—', + sender=display_address(from_raw), date=dt, preview='', is_unread='\\Seen' not in flags, - )) - - for summary in summaries: - try: - typ, body_data = self._conn.uid('fetch', summary.uid, '(BODY.PEEK[TEXT]<0.200>)') - if typ == 'OK' and body_data and body_data[0]: - chunk = body_data[0][1] if isinstance(body_data[0], tuple) else b'' - if isinstance(chunk, bytes): - summary.preview = chunk.decode('utf-8', errors='replace').replace('\r', ' ').replace('\n', ' ')[:120] - except Exception: - pass - - return summaries + from_addr=(addr or '').strip().lower(), + ) + return by_uid def fetch_message(self, uid: str, folder_key: str = 'inbox') -> dict: assert self._conn @@ -267,18 +613,16 @@ class ImapMailClient: if '\\Seen' not in flags: self._conn.uid('store', uid, '+FLAGS', '(\\Seen)') + body_text, body_html, is_html = _message_content(msg) return { 'uid': uid, 'subject': _decode_mime(msg.get('Subject')) or '(без теми)', 'sender': _decode_mime(msg.get('From')) or '—', 'to': _decode_mime(msg.get('To')) or '—', 'date': _decode_mime(msg.get('Date')) or '', - 'body': _message_body(msg), - 'is_html': msg.get_content_type() == 'text/html' or bool( - msg.is_multipart() and any( - p.get_content_type() == 'text/html' for p in msg.walk() - ) - ), + 'body': body_text, + 'body_html': body_html, + 'is_html': is_html, 'attachments': _attachment_summaries(msg), 'reply_to': extract_reply_address( _decode_mime(msg.get('Reply-To')) or _decode_mime(msg.get('From')) diff --git a/apps/mail_connector/login_hooks.py b/apps/mail_connector/login_hooks.py index 384fdb8..8bcb9a1 100644 --- a/apps/mail_connector/login_hooks.py +++ b/apps/mail_connector/login_hooks.py @@ -1,26 +1,13 @@ -"""Автоподключение корпоративной почты при входе в портал.""" -from apps.mail_connector.access import mailbox_address -from apps.mail_connector.mail_auth import ( - activate_mail_password_from_pending, - stash_pending_mail_password, -) -from core.security import user_requires_mandatory_2fa +"""Хуки входа: почта больше не привязывается автоматически к паролю портала.""" -def on_portal_login(request, user, password: str) -> None: - """Сохранить пароль логина для IMAP, если у сотрудника есть рабочий email.""" - if not password or not mailbox_address(user): - return - stash_pending_mail_password(request, password) +def on_portal_login(request, user, password: str) -> None: # noqa: ARG001 + return None -def on_portal_auth_complete(request, user) -> None: - """Активировать почтовую связку после полного входа (без 2FA или после неё).""" - if not mailbox_address(user): - return - activate_mail_password_from_pending(request) +def on_portal_auth_complete(request, user) -> None: # noqa: ARG001 + return None -def should_defer_mail_activation(user) -> bool: - """Ждём 2FA перед активацией пароля почты.""" - return user_requires_mandatory_2fa(user) and user.totp_enabled +def should_defer_mail_activation(user) -> bool: # noqa: ARG001 + return False diff --git a/apps/mail_connector/mail_auth.py b/apps/mail_connector/mail_auth.py index 1aeeb53..5dee735 100644 --- a/apps/mail_connector/mail_auth.py +++ b/apps/mail_connector/mail_auth.py @@ -1,57 +1,44 @@ -"""Сессионное хранение пароля IMAP для корпоративного ящика пользователя.""" -from django.core.signing import BadSignature, Signer +"""Статус подключения почты: пароль хранится в UserMailbox (Fernet), не из логина портала.""" +from __future__ import annotations -SESSION_KEY = 'mail_imap_cred' -PENDING_KEY = 'mail_imap_pending' -SIGNER_SALT = 'acore-mail-imap-v1' - - -def _signer() -> Signer: - return Signer(salt=SIGNER_SALT) - - -def set_mail_password(request, password: str) -> None: - request.session[SESSION_KEY] = _signer().sign(password) - request.session.pop(PENDING_KEY, None) - request.session.modified = True - - -def stash_pending_mail_password(request, password: str) -> None: - """Пароль с формы логина — активируется после полного входа (2FA при необходимости).""" - request.session[PENDING_KEY] = _signer().sign(password) - request.session.modified = True - - -def activate_mail_password_from_pending(request) -> bool: - token = request.session.get(PENDING_KEY) - if not token: - return False - try: - password = _signer().unsign(token) - except BadSignature: - request.session.pop(PENDING_KEY, None) - request.session.modified = True - return False - set_mail_password(request, password) - return True +from apps.mail_connector.access import get_user_mailbox def get_mail_password(request) -> str | None: - token = request.session.get(SESSION_KEY) - if not token: + box = get_user_mailbox(request.user) + if not box or not box.is_connected: return None - try: - return _signer().unsign(token) - except BadSignature: - clear_mail_password(request) - return None - - -def clear_mail_password(request) -> None: - request.session.pop(SESSION_KEY, None) - request.session.pop(PENDING_KEY, None) - request.session.modified = True + password = box.password or '' + return password or None def is_mail_connected(request) -> bool: return get_mail_password(request) is not None + + +def clear_mail_password(request) -> None: + """Отключить ящик: очистить сохранённый пароль (запись остаётся для повторного входа).""" + box = get_user_mailbox(request.user) + if not box: + return + box.password = '' + box.is_connected = False + box.save(update_fields=['password', 'is_connected', 'updated_at']) + + +# Совместимость со старым session-API (тесты / вызовы могут ещё импортировать). +SESSION_KEY = 'mail_imap_cred' +PENDING_KEY = 'mail_imap_pending' + + +def set_mail_password(request, password: str) -> None: # noqa: ARG001 + """Устарело: пароль сохраняется только через UserMailbox при connect.""" + return None + + +def stash_pending_mail_password(request, password: str) -> None: # noqa: ARG001 + return None + + +def activate_mail_password_from_pending(request) -> bool: # noqa: ARG001 + return False diff --git a/apps/mail_connector/mail_errors.py b/apps/mail_connector/mail_errors.py new file mode 100644 index 0000000..e505f3c --- /dev/null +++ b/apps/mail_connector/mail_errors.py @@ -0,0 +1,48 @@ +"""Человекочитаемые ошибки подключения почты.""" +from __future__ import annotations + +import re + + +def humanize_mail_error(exc: BaseException, *, host: str = '', port: int | None = None) -> str: + """Короткое сообщение для UI (украинский).""" + raw = str(exc) or exc.__class__.__name__ + low = raw.lower() + target = f'{host}:{port}' if host and port else (host or 'поштового сервера') + + if isinstance(exc, UnicodeEncodeError) or 'ordinal not in range' in low or 'ascii' in low and 'codec' in low: + return ( + 'Пароль містить символи, які IMAP-клієнт не зміг передати. ' + 'Спробуйте ще раз — якщо помилка лишиться, змініть пароль скриньки на латиницю/цифри.' + ) + + if any(x in low for x in ('authenticationfailed', 'login failed', 'auth', 'invalid credentials', 'invalid user')): + return 'Невірний email або пароль скриньки. Перевірте дані та спробуйте знову.' + + if any(x in low for x in ('name or service not known', 'getaddrinfo', 'nodename nor servname', 'temporary failure in name resolution')): + return ( + f'Сервер «{host or "?"}» не знайдено в DNS. ' + 'Відкрийте «Розширені налаштування» і вкажіть правильний IMAP-хост ' + '(часто mail.ваш-домен).' + ) + + if any(x in low for x in ('timed out', 'timeout', 'connection timed out')): + return f'Час очікування вичерпано при з\'єднанні з {target}. Перевірте хост/порт або мережу.' + + if any(x in low for x in ('connection refused', 'actively refused')): + return f'Сервер {target} відхилив з\'єднання. Можливо, інший порт (993 SSL або 143).' + + if any(x in low for x in ('network is unreachable', 'no route to host')): + return f'Немає маршруту до {target}. Перевірте інтернет або VPN.' + + if any(x in low for x in ('certificate', 'ssl', 'tls', 'handshake')): + return ( + f'Помилка SSL/TLS з {target}. Спробуйте інший порт ' + 'або зверніться до адміністратора пошти.' + ) + + # обрезать слишком длинные/технические ответы сервера + cleaned = re.sub(r'\s+', ' ', raw).strip() + if len(cleaned) > 180: + cleaned = cleaned[:177] + '…' + return f'Не вдалося підключити скриньку ({target}): {cleaned}' diff --git a/apps/mail_connector/mail_settings.py b/apps/mail_connector/mail_settings.py index 6b9c840..47ab652 100644 --- a/apps/mail_connector/mail_settings.py +++ b/apps/mail_connector/mail_settings.py @@ -1,4 +1,4 @@ -"""Настройки IMAP/SMTP из ConnectorConfig (mail или carbonio).""" +"""Настройки IMAP/SMTP из ConnectorConfig (mail или carbonio) + переопределения ящика.""" from __future__ import annotations from dataclasses import dataclass @@ -56,3 +56,52 @@ def get_mail_settings() -> MailSettings: 'Почта не настроена. Включите коннектор «Почта (IMAP/SMTP)» или Carbonio CE ' 'в админке → Настройки коннекторов.' ) + + +def resolve_mail_settings(mailbox=None) -> MailSettings: + """Глобальные настройки сервера + необязательные переопределения из UserMailbox. + + Если портальный коннектор не настроен, но у ящика указаны хосты — используем их. + """ + try: + base = get_mail_settings() + except MailConfigError: + base = None + + if mailbox is None: + if base is None: + raise MailConfigError( + 'Почта не настроена. Включите коннектор «Почта (IMAP/SMTP)» или Carbonio CE ' + 'в админке → Настройки коннекторов.' + ) + return base + + imap_host = (mailbox.imap_host or '').strip() + smtp_host = (mailbox.smtp_host or '').strip() + if base is not None: + imap_host = imap_host or base.imap_host + smtp_host = smtp_host or base.smtp_host + imap_port = int(mailbox.imap_port or base.imap_port or 993) + smtp_port = int(mailbox.smtp_port or base.smtp_port or 465) + use_ssl = bool(mailbox.use_ssl) if mailbox.imap_host or mailbox.smtp_host else base.use_ssl + verify_ssl = bool(mailbox.verify_ssl) if mailbox.imap_host or mailbox.smtp_host else base.verify_ssl + else: + if not imap_host: + raise MailConfigError( + 'Не вказано IMAP-сервер. Увімкніть коннектор пошти в порталі ' + 'або вкажіть хост вручну / дочекайтесь автоконфігу.' + ) + imap_port = int(mailbox.imap_port or 993) + smtp_port = int(mailbox.smtp_port or 465) + smtp_host = smtp_host or imap_host + use_ssl = bool(mailbox.use_ssl) + verify_ssl = bool(mailbox.verify_ssl) + + return MailSettings( + imap_host=imap_host, + imap_port=imap_port, + smtp_host=smtp_host or imap_host, + smtp_port=smtp_port, + use_ssl=use_ssl, + verify_ssl=verify_ssl, + ) diff --git a/apps/mail_connector/migrations/0002_usermailbox.py b/apps/mail_connector/migrations/0002_usermailbox.py new file mode 100644 index 0000000..fe28762 --- /dev/null +++ b/apps/mail_connector/migrations/0002_usermailbox.py @@ -0,0 +1,48 @@ +# Generated manually for UserMailbox + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + +import core.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('mail_connector', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserMailbox', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('email', models.EmailField(max_length=254, verbose_name='Адрес ящика')), + ('password', core.models.fields.EncryptedCharField(max_length=512, verbose_name='Пароль ящика')), + ('imap_host', models.CharField(blank=True, max_length=255, verbose_name='IMAP-хост')), + ('imap_port', models.PositiveIntegerField(blank=True, null=True, verbose_name='IMAP-порт')), + ('smtp_host', models.CharField(blank=True, max_length=255, verbose_name='SMTP-хост')), + ('smtp_port', models.PositiveIntegerField(blank=True, null=True, verbose_name='SMTP-порт')), + ('use_ssl', models.BooleanField(default=True, verbose_name='SSL/TLS')), + ('verify_ssl', models.BooleanField(default=False, verbose_name='Проверять SSL-сертификат')), + ('is_connected', models.BooleanField(default=True, verbose_name='Подключено')), + ('last_ok_at', models.DateTimeField(blank=True, null=True, verbose_name='Последний успешный вход')), + ('employee', models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='mailbox', + to=settings.AUTH_USER_MODEL, + verbose_name='Сотрудник', + )), + ], + options={ + 'verbose_name': 'Почтовый ящик сотрудника', + 'verbose_name_plural': 'Почтовые ящики сотрудников', + }, + ), + ] diff --git a/apps/mail_connector/models.py b/apps/mail_connector/models.py index d2bcfdb..5b6da9c 100644 --- a/apps/mail_connector/models.py +++ b/apps/mail_connector/models.py @@ -1,7 +1,9 @@ +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +from core.models.fields import EncryptedCharField class CarbonioSyncLog(BaseModel): @@ -33,3 +35,31 @@ class CarbonioSyncLog(BaseModel): def __str__(self): return f'{self.pharmacy} — {self.get_action_display()} — {self.get_status_display()}' + + +class UserMailbox(BaseModel): + """Личное IMAP/SMTP-подключение сотрудника (как «подключить почту» в Bitrix).""" + + employee = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='mailbox', + verbose_name=_('Сотрудник'), + ) + email = models.EmailField(_('Адрес ящика'), max_length=254) + password = EncryptedCharField(_('Пароль ящика'), max_length=512) + imap_host = models.CharField(_('IMAP-хост'), max_length=255, blank=True) + imap_port = models.PositiveIntegerField(_('IMAP-порт'), null=True, blank=True) + smtp_host = models.CharField(_('SMTP-хост'), max_length=255, blank=True) + smtp_port = models.PositiveIntegerField(_('SMTP-порт'), null=True, blank=True) + use_ssl = models.BooleanField(_('SSL/TLS'), default=True) + verify_ssl = models.BooleanField(_('Проверять SSL-сертификат'), default=False) + is_connected = models.BooleanField(_('Подключено'), default=True) + last_ok_at = models.DateTimeField(_('Последний успешный вход'), null=True, blank=True) + + class Meta: + verbose_name = _('Почтовый ящик сотрудника') + verbose_name_plural = _('Почтовые ящики сотрудников') + + def __str__(self): + return f'{self.employee} — {self.email}' diff --git a/apps/mail_connector/smtp_client.py b/apps/mail_connector/smtp_client.py index 0823286..7ee2791 100644 --- a/apps/mail_connector/smtp_client.py +++ b/apps/mail_connector/smtp_client.py @@ -1,8 +1,12 @@ """Отправка писем через SMTP.""" from __future__ import annotations +import mimetypes import smtplib import ssl +from email import encoders +from email.header import Header +from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -13,6 +17,10 @@ class SmtpSendError(Exception): pass +# filename, content bytes, content_type +Attachment = tuple[str, bytes, str] + + def send_message( settings: MailSettings, *, @@ -23,17 +31,30 @@ def send_message( subject: str, body: str, cc_addrs: list[str] | None = None, + bcc_addrs: list[str] | None = None, + attachments: list[Attachment] | None = None, ) -> None: cc_addrs = cc_addrs or [] - recipients = [a for a in to_addrs + cc_addrs if a] + bcc_addrs = bcc_addrs or [] + attachments = attachments or [] + recipients = [a for a in to_addrs + cc_addrs + bcc_addrs if a] + + if attachments: + msg = MIMEMultipart('mixed') + body_part = MIMEMultipart('alternative') + body_part.attach(MIMEText(body, 'plain', 'utf-8')) + msg.attach(body_part) + for filename, content, content_type in attachments: + msg.attach(_make_attachment_part(filename, content, content_type)) + else: + msg = MIMEMultipart('alternative') + msg.attach(MIMEText(body, 'plain', 'utf-8')) - msg = MIMEMultipart('alternative') msg['Subject'] = subject msg['From'] = sender msg['To'] = ', '.join(to_addrs) if cc_addrs: msg['Cc'] = ', '.join(cc_addrs) - msg.attach(MIMEText(body, 'plain', 'utf-8')) try: if settings.use_ssl and settings.smtp_port == 465: @@ -58,3 +79,29 @@ def send_message( raise SmtpSendError(str(e)) from e except OSError as e: raise SmtpSendError(str(e)) from e + + +def _make_attachment_part(filename: str, content: bytes, content_type: str) -> MIMEBase: + ctype = (content_type or '').split(';')[0].strip() + if not ctype or ctype == 'application/octet-stream': + guessed, _ = mimetypes.guess_type(filename) + ctype = guessed or 'application/octet-stream' + maintype, _, subtype = ctype.partition('/') + if not subtype: + maintype, subtype = 'application', 'octet-stream' + + part = MIMEBase(maintype, subtype) + part.set_payload(content) + encoders.encode_base64(part) + + # ASCII filename preferred; otherwise RFC2047-encoded fallback + try: + filename.encode('ascii') + part.add_header('Content-Disposition', 'attachment', filename=filename) + except UnicodeEncodeError: + part.add_header( + 'Content-Disposition', + 'attachment', + filename=str(Header(filename, 'utf-8')), + ) + return part diff --git a/apps/mail_connector/tests.py b/apps/mail_connector/tests.py index 2405f31..dd92ab0 100644 --- a/apps/mail_connector/tests.py +++ b/apps/mail_connector/tests.py @@ -2,13 +2,14 @@ from datetime import date, datetime, timezone from unittest import mock from django.contrib.auth.models import Group -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from apps.employees.models import Employee -from apps.mail_connector.access import can_use_portal_mail, mailbox_address -from apps.mail_connector.imap_client import ImapMailClient, MailConnectionError, MailSummary -from apps.mail_connector.mail_auth import clear_mail_password, get_mail_password, is_mail_connected, set_mail_password -from apps.mail_connector.mail_settings import MailConfigError, MailSettings, get_mail_settings +from apps.mail_connector.access import can_use_portal_mail, mailbox_address, suggested_mailbox_email +from apps.mail_connector.imap_client import MailConnectionError, MailSummary +from apps.mail_connector.mail_auth import clear_mail_password, get_mail_password, is_mail_connected +from apps.mail_connector.mail_settings import MailConfigError, get_mail_settings +from apps.mail_connector.models import UserMailbox from core.models import ConnectorConfig from core.test_utils import login_2fa @@ -37,11 +38,16 @@ def grant_email_access(user): delattr(user, attr) -def set_client_mail_password(client, password: str): - from apps.mail_connector.mail_auth import SESSION_KEY, _signer - session = client.session - session[SESSION_KEY] = _signer().sign(password) - session.save() +def connect_mailbox(user, email=None, password='imap-secret', **extra): + return UserMailbox.objects.update_or_create( + employee=user, + defaults={ + 'email': email or user.email, + 'password': password, + 'is_connected': True, + **extra, + }, + )[0] class MailAccessTests(TestCase): @@ -51,13 +57,19 @@ class MailAccessTests(TestCase): grant_email_access(user) self.assertTrue(can_use_portal_mail(user)) - def test_mailbox_address_rejects_local_domain(self): - user = make_employee(email='x@local.domain') - self.assertIsNone(mailbox_address(user)) - - def test_mailbox_address_returns_work_email(self): + def test_mailbox_address_none_until_connected(self): user = make_employee(email='ivan@aks-apteka.com.ua') - self.assertEqual(mailbox_address(user), 'ivan@aks-apteka.com.ua') + self.assertIsNone(mailbox_address(user)) + self.assertEqual(suggested_mailbox_email(user), 'ivan@aks-apteka.com.ua') + + def test_mailbox_address_from_user_mailbox(self): + user = make_employee(email='profile@aks-apteka.com.ua') + connect_mailbox(user, email='work@aks-apteka.com.ua') + self.assertEqual(mailbox_address(user), 'work@aks-apteka.com.ua') + + def test_suggested_skips_local_domain(self): + user = make_employee(email='x@local.domain') + self.assertEqual(suggested_mailbox_email(user), '') class MailSettingsTests(TestCase): @@ -90,34 +102,33 @@ class MailSettingsTests(TestCase): self.assertEqual(settings.imap_host, 'carbonio.example.com') -class MailSessionAuthTests(TestCase): +class MailboxAuthTests(TestCase): def test_password_roundtrip(self): user = make_employee() grant_email_access(user) login_2fa(self.client, user) - session = self.client.session - req = mock.Mock(session=session) - set_mail_password(req, 'mailbox-pass') + connect_mailbox(user, password='mailbox-pass') + user.refresh_from_db() + req = mock.Mock(user=user) self.assertTrue(is_mail_connected(req)) self.assertEqual(get_mail_password(req), 'mailbox-pass') clear_mail_password(req) + user.refresh_from_db() + req = mock.Mock(user=user) + # related object still exists but disconnected + box = UserMailbox.objects.get(employee=user) + self.assertFalse(box.is_connected) self.assertFalse(is_mail_connected(req)) - def test_pending_activation(self): - req = mock.Mock() - req.session = self.client.session - from apps.mail_connector.mail_auth import ( - PENDING_KEY, - activate_mail_password_from_pending, - stash_pending_mail_password, - ) - stash_pending_mail_password(req, 'from-login') + def test_login_does_not_auto_connect(self): + user = make_employee() + grant_email_access(user) + user.set_password('portal-pass') + user.save(update_fields=['password']) + self.client.post('/login/', {'username': user.username, 'password': 'portal-pass'}) + req = mock.Mock(user=user) self.assertFalse(is_mail_connected(req)) - self.assertIn(PENDING_KEY, req.session) - self.assertTrue(activate_mail_password_from_pending(req)) - self.assertTrue(is_mail_connected(req)) - self.assertEqual(get_mail_password(req), 'from-login') - self.assertNotIn(PENDING_KEY, req.session) + self.assertIsNone(get_mail_password(req)) class MailViewTests(TestCase): @@ -144,46 +155,56 @@ class MailViewTests(TestCase): self.assertEqual(resp['Location'], '/dashboard/') self.assertEqual(resp['HX-Redirect'], '/dashboard/') - def test_connect_screen_when_not_logged_in_to_mailbox(self): + def test_connect_screen_when_not_linked(self): response = self.client.get('/email/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) self.assertContains(response, 'Підключення скриньки') - self.assertContains(response, 'автоматично після входу') + self.assertContains(response, 'автоматично') self.assertContains(response, 'worker@aks-apteka.com.ua') + self.assertNotContains(response, 'автоматично після входу') - def test_login_auto_connects_mail(self): - self.user.set_password('portal-pass') - self.user.save(update_fields=['password']) - self.client.logout() - self.client.post('/login/', {'username': self.user.username, 'password': 'portal-pass'}) - req = mock.Mock(session=self.client.session) - self.assertEqual(get_mail_password(req), 'portal-pass') - + @mock.patch('apps.mail_connector.views.list_mail_candidates') @mock.patch('apps.mail_connector.views.ImapMailClient') - def test_inbox_after_login_without_manual_connect(self, client_cls): - self.user.set_password('portal-pass') - self.user.save(update_fields=['password']) - self.client.logout() - self.client.post('/login/', {'username': self.user.username, 'password': 'portal-pass'}) - - mock_client = mock.Mock() - client_cls.return_value.__enter__.return_value = mock_client - client_cls.return_value.__exit__.return_value = False - mock_client.unread_count.return_value = 0 - mock_client.list_messages.return_value = [] - - response = self.client.get('/email/', HTTP_HX_REQUEST='true') - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, 'Підключення скриньки') - mock_client.list_messages.assert_called_once() - - @mock.patch('apps.mail_connector.views.ImapMailClient') - def test_connect_post_stores_session(self, client_cls): + def test_connect_post_saves_mailbox(self, client_cls, list_cands): + from apps.mail_connector.autoconfig import AutoconfigResult + list_cands.return_value = [ + AutoconfigResult( + imap_host='mail.test.local', imap_port=993, + smtp_host='mail.test.local', smtp_port=465, + use_ssl=True, source='portal', score=55, + ), + ] client_cls.return_value.__enter__.return_value = mock.Mock() client_cls.return_value.__exit__.return_value = False - response = self.client.post('/email/connect/', {'password': 'imap-secret'}, HTTP_HX_REQUEST='true') + response = self.client.post('/email/connect/', { + 'email': 'worker@aks-apteka.com.ua', + 'password': 'imap-secret', + }, HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) self.assertEqual(response['HX-Redirect'], '/email/') + box = UserMailbox.objects.get(employee=self.user) + self.assertEqual(box.email, 'worker@aks-apteka.com.ua') + self.assertEqual(box.password, 'imap-secret') + self.assertTrue(box.is_connected) + + @mock.patch('apps.mail_connector.views.list_mail_candidates') + @mock.patch('apps.mail_connector.views.ImapMailClient') + def test_connect_invalid_password(self, client_cls, list_cands): + from apps.mail_connector.autoconfig import AutoconfigResult + list_cands.return_value = [ + AutoconfigResult( + imap_host='mail.test.local', imap_port=993, + smtp_host='mail.test.local', smtp_port=465, + use_ssl=True, source='portal', score=55, + ), + ] + client_cls.return_value.__enter__.side_effect = MailConnectionError('AUTHENTICATIONFAILED') + response = self.client.post('/email/connect/', { + 'email': 'worker@aks-apteka.com.ua', + 'password': 'wrong', + }, HTTP_HX_REQUEST='true') + self.assertContains(response, 'Невірний email або пароль') + self.assertFalse(UserMailbox.objects.filter(employee=self.user).exists()) @mock.patch('apps.mail_connector.views.ImapMailClient') def test_inbox_after_connect(self, client_cls): @@ -191,17 +212,20 @@ class MailViewTests(TestCase): client_cls.return_value.__enter__.return_value = mock_client client_cls.return_value.__exit__.return_value = False mock_client.unread_count.return_value = 1 - mock_client.list_messages.return_value = [ - MailSummary( - uid='10', - subject='Тест', - sender='Boss ', - date=datetime(2026, 8, 31, 9, 0, tzinfo=timezone.utc), - preview='Привіт', - is_unread=True, - ), - ] - set_client_mail_password(self.client, 'imap-secret') + mock_client.list_messages.return_value = ( + [ + MailSummary( + uid='10', + subject='Тест', + sender='Boss ', + date=datetime(2026, 8, 31, 9, 0, tzinfo=timezone.utc), + preview='Привіт', + is_unread=True, + ), + ], + False, + ) + connect_mailbox(self.user, password='imap-secret') response = self.client.get('/email/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) @@ -215,8 +239,8 @@ class MailViewTests(TestCase): client_cls.return_value.__enter__.return_value = mock_client client_cls.return_value.__exit__.return_value = False mock_client.unread_count.return_value = 0 - mock_client.list_messages.return_value = [] - set_client_mail_password(self.client, 'imap-secret') + mock_client.list_messages.return_value = ([], False) + connect_mailbox(self.user, password='imap-secret') response = self.client.post('/email/send/', { 'to': 'client@example.com', @@ -241,12 +265,13 @@ class MailViewTests(TestCase): 'to': 'me@aks-apteka.com.ua', 'date': '', 'body': 'Привіт', + 'body_html': None, 'is_html': False, 'attachments': [], 'reply_to': 'boss@aks-apteka.com.ua', 'reply_subject': 'Re: Тест', } - set_client_mail_password(self.client, 'imap-secret') + connect_mailbox(self.user, password='imap-secret') response = self.client.post('/email/send/', { 'to': 'boss@aks-apteka.com.ua', 'subject': 'Re: Тест', @@ -259,11 +284,193 @@ class MailViewTests(TestCase): self.assertContains(response, 'Відповідь надіслано') self.assertContains(response, 'Надіслати відповідь') - @mock.patch('apps.mail_connector.views.ImapMailClient') - def test_connect_invalid_password(self, client_cls): - client_cls.return_value.__enter__.side_effect = MailConnectionError('AUTH failed') - response = self.client.post('/email/connect/', {'password': 'wrong'}, HTTP_HX_REQUEST='true') - self.assertContains(response, 'AUTH failed') + +class AutoconfigTests(TestCase): + SAMPLE_XML = ''' + + + example.com + + imap.example.com + 993 + SSL + %EMAILADDRESS% + + + smtp.example.com + 587 + STARTTLS + %EMAILADDRESS% + + +''' + + def test_parse_xml(self): + from apps.mail_connector.autoconfig import _parse_client_config_xml + result = _parse_client_config_xml(self.SAMPLE_XML, 'ispdb') + self.assertIsNotNone(result) + self.assertEqual(result.imap_host, 'imap.example.com') + self.assertEqual(result.imap_port, 993) + self.assertEqual(result.smtp_host, 'smtp.example.com') + self.assertEqual(result.smtp_port, 587) + self.assertTrue(result.use_ssl) + + @mock.patch('apps.mail_connector.autoconfig._fetch_xml') + def test_discover_uses_ispdb(self, fetch_xml): + from apps.mail_connector.autoconfig import discover_mail_config + + def side_effect(url): + if 'thunderbird.net' in url and url.endswith('/example.com'): + return self.SAMPLE_XML + return None + + fetch_xml.side_effect = side_effect + result = discover_mail_config('user@example.com') + self.assertEqual(result.source, 'ispdb') + self.assertEqual(result.imap_host, 'imap.example.com') + + @mock.patch('apps.mail_connector.autoconfig._fetch_xml', return_value=None) + @mock.patch('apps.mail_connector.autoconfig._mx_hosts', return_value=[]) + @mock.patch('apps.mail_connector.autoconfig._host_resolves', return_value=False) + @mock.patch('apps.mail_connector.autoconfig._port_open', return_value=False) + def test_discover_falls_back_to_guess(self, *_mocks): + from apps.mail_connector.autoconfig import discover_mail_config + result = discover_mail_config('user@corp.example') + self.assertIn(result.source, ('guess',)) + self.assertTrue(result.imap_host.endswith('corp.example')) + + def test_autoconfig_endpoint_fills_fields(self): + ConnectorConfig.objects.create( + connector_type='mail', + is_enabled=True, + host='mail.test.local', + username='admin', + password='secret', + ) + user = make_employee(email='worker@aks-apteka.com.ua') + grant_email_access(user) + login_2fa(self.client, user) + + xml = self.SAMPLE_XML + with mock.patch('apps.mail_connector.autoconfig._fetch_xml', return_value=xml): + # first URL is ispdb for domain — return xml for any fetch + resp = self.client.post( + '/email/autoconfig/', + {'email': 'me@example.com'}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'imap.example.com') + self.assertContains(resp, 'smtp.example.com') + self.assertContains(resp, 'Знайдено') + + +class MailErrorHumanizeTests(TestCase): + def test_auth_and_dns(self): + from apps.mail_connector.mail_errors import humanize_mail_error + self.assertIn('пароль', humanize_mail_error(Exception('AUTHENTICATIONFAILED'), host='x').lower()) + self.assertIn('DNS', humanize_mail_error(Exception('Name or service not known'), host='imap.bad')) + + def test_unicode_password_does_not_500(self): + ConnectorConfig.objects.create( + connector_type='mail', + is_enabled=True, + host='mail.test.local', + username='admin', + password='secret', + ) + user = make_employee(email='uni@aks-apteka.com.ua') + grant_email_access(user) + login_2fa(self.client, user) + from apps.mail_connector.autoconfig import AutoconfigResult + with mock.patch('apps.mail_connector.views.list_mail_candidates') as list_cands: + list_cands.return_value = [ + AutoconfigResult( + imap_host='mail.test.local', imap_port=993, + smtp_host='mail.test.local', smtp_port=465, + use_ssl=True, source='portal', score=55, + ), + ] + with mock.patch('apps.mail_connector.views.ImapMailClient') as client_cls: + # Симулируем старое поведение imaplib до фикса encoding + client_cls.return_value.__enter__.side_effect = MailConnectionError( + "ascii codec can't encode characters" + ) + resp = self.client.post('/email/connect/', { + 'email': 'uni@aks-apteka.com.ua', + 'password': 'пароль', + }, HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'символи') + + +class MailBodyParseTests(TestCase): + def test_html_preview_strips_css(self): + from apps.mail_connector.imap_client import _clean_preview_snippet, _message_content + raw = ( + 'Content-Type: text/html; charset=3D"utf-8"\r\n\r\n' + '' + '

New sign-in to your account

' + ) + preview = _clean_preview_snippet(raw) + self.assertNotIn('DOCTYPE', preview) + self.assertNotIn('color:red', preview) + self.assertIn('New sign-in', preview) + + def test_message_content_prefers_readable_html(self): + from email.message import EmailMessage + from apps.mail_connector.imap_client import _message_content + + msg = EmailMessage() + msg['Subject'] = 'Hello' + msg.set_content( + '

Welcome

Hello world

' + '', + subtype='html', + ) + body, html, is_html = _message_content(msg) + self.assertTrue(is_html) + self.assertIn('Welcome', body) + self.assertNotIn('alert', html or '') + self.assertIn('

Welcome

', html or '') + + +class MailSearchCriteriaTests(SimpleTestCase): + def test_empty_is_all(self): + from apps.mail_connector.imap_client import build_search_criteria + self.assertEqual(build_search_criteria(''), 'ALL') + + def test_strategies_include_text_and_from(self): + from apps.mail_connector.imap_client import build_search_strategies + strategies = build_search_strategies('invoice') + blob = ' | '.join(strategies) + self.assertIn('TEXT "invoice"', blob) + self.assertIn('FROM "invoice"', blob) + self.assertIn('TO "invoice"', blob) + self.assertNotIn('BODY ', blob) + + def test_domain_strategies(self): + from apps.mail_connector.imap_client import build_search_strategies + strategies = build_search_strategies('aks-apteka.com.ua') + blob = ' | '.join(strategies) + self.assertIn('FROM "@aks-apteka.com.ua"', blob) + self.assertIn('HEADER From "aks-apteka.com.ua"', blob) + self.assertIn('TEXT "@aks-apteka.com.ua"', blob) + + def test_local_match_uses_from_addr(self): + from apps.mail_connector.imap_client import _header_matches_query + self.assertTrue(_header_matches_query( + 'Hello', 'John', 'aks-apteka.com.ua', 'boss@aks-apteka.com.ua', + )) + self.assertFalse(_header_matches_query( + 'Hello', 'John', 'aks-apteka.com.ua', 'other@example.com', + )) + + def test_starred_with_query(self): + from apps.mail_connector.imap_client import build_search_criteria + crit = build_search_criteria('boss', starred_only=True) + self.assertIn('FLAGGED', crit) + self.assertIn('boss', crit) class MailAttachmentParseTests(TestCase): @@ -299,7 +506,7 @@ class MailAttachmentParseTests(TestCase): user = make_employee(email='att@aks-apteka.com.ua') grant_email_access(user) login_2fa(self.client, user) - set_client_mail_password(self.client, 'imap-secret') + connect_mailbox(user, password='imap-secret') r = self.client.get('/email/attachment/?folder=inbox&uid=12&i=0') self.assertEqual(r.status_code, 200) self.assertEqual(r.content, b'%PDF') diff --git a/apps/mail_connector/urls.py b/apps/mail_connector/urls.py index 39d0624..6fea692 100644 --- a/apps/mail_connector/urls.py +++ b/apps/mail_connector/urls.py @@ -4,6 +4,8 @@ from . import views urlpatterns = [ path('email/', views.email_index, name='email'), + path('email/more/', views.email_list_more, name='email_list_more'), + path('email/autoconfig/', views.email_autoconfig, name='email_autoconfig'), path('email/connect/', views.email_connect, name='email_connect'), path('email/disconnect/', views.email_disconnect, name='email_disconnect'), path('email/send/', views.email_send, name='email_send'), diff --git a/apps/mail_connector/views.py b/apps/mail_connector/views.py index 9aeafc9..ca83b98 100644 --- a/apps/mail_connector/views.py +++ b/apps/mail_connector/views.py @@ -1,4 +1,4 @@ -"""Портал корпоративной почты (IMAP/SMTP).""" +"""Портал корпоративной почты (IMAP/SMTP) — подключение ящика самим сотрудником.""" import imaplib import logging import re @@ -6,12 +6,21 @@ 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, mailbox_address +from apps.mail_connector.access import ( + can_use_portal_mail, + get_user_mailbox, + mailbox_address, + 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, set_mail_password -from apps.mail_connector.mail_settings import MailConfigError, get_mail_settings +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 @@ -20,6 +29,41 @@ 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 @@ -33,18 +77,21 @@ def _email_guard(view_func): def _base_context(request, **extra): try: - get_mail_settings() + defaults = get_mail_settings() mail_configured = True mail_error = None except MailConfigError as e: + defaults = None mail_configured = False mail_error = str(e) return { 'mailbox': mailbox_address(request.user), + 'suggested_email': suggested_mailbox_email(request.user), 'mail_connected': is_mail_connected(request), 'mail_configured': mail_configured, 'mail_error': mail_error, + 'mail_defaults': defaults, 'active_folder': request.GET.get('folder', 'inbox'), 'folder_choices': [ ('inbox', 'Вхідні'), @@ -59,14 +106,15 @@ def _base_context(request, **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: - return None, 'no_mailbox', 'У профілю не вказано робочий email.' + if not address or not box: + return None, 'not_connected', None if not password: return None, 'not_connected', None try: - settings = get_mail_settings() + settings = resolve_mail_settings(box) except MailConfigError as e: return None, 'not_configured', str(e) try: @@ -77,16 +125,15 @@ def _with_imap(request, fn): 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) - if not ctx['mailbox']: - ctx['mail_state'] = 'no_mailbox' - return render_page(request, 'email.html', ctx) - if not ctx['mail_connected']: ctx['mail_state'] = 'connect' return render_page(request, 'email.html', ctx) @@ -96,33 +143,38 @@ def email_index(request): search = request.GET.get('q', '').strip() def load(client): - messages = client.list_messages( + 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') - return messages, unread + return messages, has_more, unread 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 == 'not_connected': + if err_key in ('not_connected', 'connection_error'): + if err_key == 'connection_error': + clear_mail_password(request) + box = get_user_mailbox(request.user) + host = (box.imap_host if box else '') or '' + port = box.imap_port if box else None + ctx['connect_error'] = humanize_mail_error( + Exception(err_msg or 'connection failed'), + host=host, + port=port, + ) ctx['mail_state'] = 'connect' - elif err_key == 'connection_error': - clear_mail_password(request) - ctx['mail_state'] = 'connect' - ctx['connect_error'] = ( - err_msg or 'Не вдалося підключитися. ' - 'Можливо, пароль пошти відрізняється від пароля порталу.' - ) + ctx['mail_connected'] = False + ctx['mailbox'] = None + ctx['suggested_email'] = suggested_mailbox_email(request.user) return render_page(request, 'email.html', ctx) - messages, unread = result - unread_msgs = [m for m in messages if m.is_unread] - read_msgs = [m for m in messages if not m.is_unread] - messages = unread_msgs + read_msgs + messages, has_more, unread = result ctx.update({ 'mail_state': 'ready', 'messages': messages, @@ -130,6 +182,9 @@ def email_index(request): '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 request.GET.get('uid') and ctx['view'] == 'read': @@ -147,54 +202,300 @@ def email_index(request): return render_page(request, 'email.html', ctx) -def _render_connect_partial(request, context): - return render(request, 'partials/email-connect.html', context) +@_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, + } + + 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 _render_connect_partial(request, { - 'error': 'Введіть пароль поштової скриньки.', - 'mailbox': mailbox_address(request.user), - }) + return _fail('Введіть пароль поштової скриньки.') - address = mailbox_address(request.user) - if not address: - return _render_connect_partial(request, { - 'error': 'Робочий email не налаштовано.', - 'mailbox': None, - }) + candidates = _build_connect_candidates( + email, imap_host, imap_port, smtp_host, smtp_port, use_ssl, + ) + if not candidates: + return _fail( + 'Не вдалося визначити поштовий сервер.', + 'Вкажіть IMAP-хост у розширених налаштуваннях або увімкніть коннектор пошти в порталі.', + ) - try: - settings = get_mail_settings() - with ImapMailClient(settings, address, password): - pass - except MailConfigError as e: - return _render_connect_partial(request, { - 'error': str(e), 'mailbox': address, - }) - except MailConnectionError as e: - return _render_connect_partial(request, { - 'error': f'Не вдалося увійти: {e}', 'mailbox': address, - }) + last_error: BaseException | None = None + last_cand: AutoconfigResult | None = None + tried: list[str] = [] - set_mail_password(request, password) - response = _render_connect_partial(request, { - 'success': True, 'mailbox': address, - }) - response['HX-Redirect'] = '/email/' - return response + 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 + + # успех + UserMailbox.objects.update_or_create( + employee=request.user, + defaults={ + 'email': email, + '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(), + }, + ) + 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_partial(request, { - 'disconnected': True, 'mailbox': mailbox_address(request.user), + response = _render_connect_panel(request, { + 'suggested_email': suggested_mailbox_email(request.user), + 'connect_success': False, }) response['HX-Redirect'] = '/email/' return response @@ -207,9 +508,12 @@ def email_send(request): 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: @@ -218,10 +522,12 @@ def email_send(request): errors.append('Вкажіть тему.') if not body: errors.append('Вкажіть текст листа.') - for addr in to_addrs + cc_addrs: + 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() @@ -231,6 +537,7 @@ def email_send(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'), @@ -255,7 +562,34 @@ def email_send(request): ctx['read_error'] = err_msg or 'Не вдалося відкрити лист' return render_page(request, 'email.html', ctx) - if not password: + 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'] = ['Підключіть поштову скриньку.'] @@ -265,12 +599,10 @@ def email_send(request): if errors: if reply_uid: return _reload_reply({'send_errors': errors}) - ctx['send_errors'] = errors - ctx['mail_state'] = 'ready' - return render_page(request, 'email.html', ctx) + return _reload_compose({'send_errors': errors}) try: - settings = get_mail_settings() + settings = resolve_mail_settings(box) send_message( settings, username=address, @@ -280,19 +612,17 @@ def email_send(request): 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)]}) - ctx['send_errors'] = [str(e)] - ctx['mail_state'] = 'ready' - return render_page(request, 'email.html', ctx) + return _reload_compose({'send_errors': [str(e)]}) except SmtpSendError as e: if reply_uid: return _reload_reply({'send_errors': [f'Помилка відправки: {e}']}) - ctx['send_errors'] = [f'Помилка відправки: {e}'] - ctx['mail_state'] = 'ready' - return render_page(request, 'email.html', ctx) + return _reload_compose({'send_errors': [f'Помилка відправки: {e}']}) if reply_uid: return _reload_reply({'send_success': True, 'form_body': ''}) @@ -304,11 +634,17 @@ def email_send(request): ctx['compose'] = False def load(client): - return client.list_messages('sent'), client.unread_count('inbox') + 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: - ctx['messages'], ctx['unread_count'] = 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) diff --git a/apps/mattermost_connector/models.py b/apps/mattermost_connector/models.py index ceb659b..632188c 100755 --- a/apps/mattermost_connector/models.py +++ b/apps/mattermost_connector/models.py @@ -1,4 +1,6 @@ # models.py +import logging + from django.contrib.postgres.fields import ArrayField from django.db import models from django.contrib.auth import get_user_model @@ -7,6 +9,8 @@ from django.utils import timezone from core.models import BaseModel from core.models.fields import EncryptedCharField +logger = logging.getLogger(__name__) + # Типы локаций сотрудника (Employee.LOCATION_TYPES) — для фильтра синхронизации. LOCATION_TYPE_CHOICES = [ ('pharmacy', 'Аптека'), @@ -99,9 +103,22 @@ class MattermostUser(models.Model): from core.utils.crypto import encrypt_secret self.access_token_encrypted = encrypt_secret(plain) - def get_access_token(self) -> str: + def get_access_token(self) -> str | None: + """Расшифровать PAT. None, если пусто или ключ шифрования не подходит (ротация).""" from core.utils.crypto import decrypt_secret - return decrypt_secret(self.access_token_encrypted) + + if not self.access_token_encrypted: + return None + try: + return decrypt_secret(self.access_token_encrypted) + except Exception: + logger.warning( + 'MattermostUser id=%s: не удалось расшифровать access_token ' + '(FIELD_ENCRYPTION_KEY?). Токен будет пересоздан.', + self.pk, + exc_info=True, + ) + return None class MattermostTeam(models.Model): """Команды Mattermost""" diff --git a/apps/mattermost_connector/services/token_service.py b/apps/mattermost_connector/services/token_service.py index 2671484..79ffc8d 100644 --- a/apps/mattermost_connector/services/token_service.py +++ b/apps/mattermost_connector/services/token_service.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) def ensure_personal_access_token(employee) -> str | None: """Возвращает персональный access token сотрудника в Mattermost, - создавая его при первом обращении.""" + создавая его при первом обращении или после смены ключа шифрования.""" if not employee.mattermost_user_id: return None @@ -17,14 +17,23 @@ def ensure_personal_access_token(employee) -> str | None: return None if mm_user.access_token_encrypted: - return mm_user.get_access_token() + token = mm_user.get_access_token() + if token: + return token + # Ciphertext не читается текущим ключом — сбрасываем и создаём новый PAT. + logger.warning( + 'Сброс повреждённого Mattermost PAT для %s (ошибка расшифровки)', + employee.email, + ) + mm_user.access_token_encrypted = '' + mm_user.save(update_fields=['access_token_encrypted', 'updated_at']) try: service = MattermostService() data = service.create_user_access_token(employee.mattermost_user_id, 'ACORE Portal Chat') mm_user.set_access_token(data['token']) - mm_user.save(update_fields=['access_token_encrypted']) + mm_user.save(update_fields=['access_token_encrypted', 'updated_at']) return data['token'] except Exception as e: logger.error(f"Не удалось создать Mattermost access token для {employee.email}: {e}") - return None + return None diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index 117c315..522a60d 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -257,6 +257,22 @@ class EnsurePersonalAccessTokenTests(TestCase): self.assertEqual(token, 'already-stored-token') service.create_user_access_token.assert_not_called() + def test_recreates_token_when_ciphertext_unreadable(self): + employee = make_employee(mattermost_user_id='mm-abc') + mm_user = MattermostUser.objects.create( + django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email, + access_token_encrypted='gAAAAABnot-a-valid-fernet-token', + ) + service = mock.Mock() + service.create_user_access_token.return_value = {'token': 'fresh-token'} + with mock.patch.object(token_service, 'MattermostService', return_value=service): + token = token_service.ensure_personal_access_token(employee) + + self.assertEqual(token, 'fresh-token') + service.create_user_access_token.assert_called_once() + mm_user.refresh_from_db() + self.assertEqual(mm_user.get_access_token(), 'fresh-token') + class TaskDoneActionAuthTests(TestCase): """task_done_action требует MATTERMOST_INTERACTIVE_TOKEN + подпись.""" diff --git a/core/portal_search.py b/core/portal_search.py new file mode 100644 index 0000000..32f404e --- /dev/null +++ b/core/portal_search.py @@ -0,0 +1,255 @@ +"""Глобальный поиск по порталу (хедер → /api/search).""" +from __future__ import annotations + +from dataclasses import dataclass + +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.shortcuts import render + +from core.location_access import accessible_pharmacies +from core.portal_access import user_can_access +from core.portal_sections import PORTAL_SECTIONS + +MIN_QUERY_LEN = 2 +PER_GROUP = 5 + + +@dataclass(frozen=True) +class SearchHit: + title: str + subtitle: str + url: str + icon: str = 'file' + + +@dataclass(frozen=True) +class SearchGroup: + key: str + label: str + hits: list[SearchHit] + + +@login_required +def portal_search_view(request): + """HTMX partial для дропдауна в хедере.""" + q = (request.GET.get('q') or '').strip() + groups = portal_search(request.user, q) if len(q) >= MIN_QUERY_LEN else [] + return render(request, 'partials/portal-search-results.html', { + 'query': q, + 'groups': groups, + 'min_len': MIN_QUERY_LEN, + }) + + +def portal_search(user, query: str) -> list[SearchGroup]: + """Собрать группы результатов с учётом ACL разделов.""" + q = (query or '').strip() + if len(q) < MIN_QUERY_LEN: + return [] + + groups: list[SearchGroup] = [] + + sections = _search_sections(user, q) + if sections: + groups.append(SearchGroup('sections', 'Разделы', sections)) + + if user_can_access(user, 'employees') or user_can_access(user, 'hr_portal'): + hits = _search_employees(q) + if hits: + groups.append(SearchGroup('employees', 'Сотрудники', hits)) + + if user_can_access(user, 'pharmacies') or user_can_access(user, 'dislocation'): + hits = _search_pharmacies(user, q) + if hits: + groups.append(SearchGroup('pharmacies', 'Аптеки', hits)) + + if user_can_access(user, 'tasks'): + hits = _search_tasks(user, q) + if hits: + groups.append(SearchGroup('tasks', 'Задачи', hits)) + + if user_can_access(user, 'help'): + hits = _search_help(q) + if hits: + groups.append(SearchGroup('help', 'Справка', hits)) + + if user_can_access(user, 'news'): + hits = _search_news(q) + if hits: + groups.append(SearchGroup('news', 'Новости', hits)) + + if user_can_access(user, 'it_support') or user_can_access(user, 'it_manager'): + hits = _search_tickets(user, q) + if hits: + groups.append(SearchGroup('tickets', 'Заявки IT', hits)) + + return groups + + +def _search_sections(user, q: str) -> list[SearchHit]: + ql = q.lower() + hits: list[SearchHit] = [] + for key, section in PORTAL_SECTIONS.items(): + if section.nav == 'none' and key not in ('profile',): + continue + if key == 'pharmacy_detail': + continue + if not user_can_access(user, key): + continue + hay = f'{section.label} {section.path} {key}'.lower() + if ql not in hay: + continue + hits.append(SearchHit( + title=section.label, + subtitle=section.path, + url=section.path, + icon='section', + )) + if len(hits) >= PER_GROUP: + break + return hits + + +def _search_employees(q: str) -> list[SearchHit]: + from apps.employees.models import Employee + + qs = ( + Employee.objects.filter(is_active=True) + .filter( + Q(last_name__icontains=q) + | Q(first_name__icontains=q) + | Q(middle_name__icontains=q) + | Q(username__icontains=q) + | Q(email__icontains=q) + ) + .select_related('post', 'department') + .order_by('last_name', 'first_name')[:PER_GROUP] + ) + hits = [] + for emp in qs: + bits = [p for p in (getattr(emp.post, 'name', None), getattr(emp.department, 'name', None)) if p] + hits.append(SearchHit( + title=str(emp), + subtitle=' · '.join(bits) or (emp.email or emp.username), + url=f'/employees/{emp.pk}/?tab=overview', + icon='employees', + )) + return hits + + +def _search_pharmacies(user, q: str) -> list[SearchHit]: + from apps.locations.models.base import pharmacy_object_code + + qs = accessible_pharmacies(user).select_related('organization') + filters = ( + Q(address__icontains=q) + | Q(street__icontains=q) + | Q(organization__name__icontains=q) + ) + if q.isdigit(): + filters |= Q(pharmacy_number=int(q)) + else: + digits = ''.join(ch for ch in q if ch.isdigit()) + if digits: + try: + filters |= Q(pharmacy_number=int(digits)) + except ValueError: + pass + rows = list(qs.filter(filters).order_by('pharmacy_number')[:PER_GROUP]) + hits = [] + for ph in rows: + code = pharmacy_object_code(ph) or f'№{ph.pharmacy_number}' + addr = ph.get_full_address() or ph.address or '' + org = getattr(ph.organization, 'name', '') or '' + hits.append(SearchHit( + title=f'{code}' + (f' · {org}' if org else ''), + subtitle=addr[:120], + url=f'/pharmacies/{ph.pk}/', + icon='pharmacy', + )) + return hits + + +def _search_tasks(user, q: str) -> list[SearchHit]: + from apps.tasks.models import Task + + qs = ( + Task.objects.for_user(user) + .filter(Q(title__icontains=q) | Q(description__icontains=q)) + .exclude(status='cancelled') + .order_by('-created_at')[:PER_GROUP] + ) + return [ + SearchHit( + title=t.title, + subtitle=t.get_status_display(), + url=f'/tasks/{t.pk}/', + icon='task', + ) + for t in qs + ] + + +def _search_help(q: str) -> list[SearchHit]: + from apps.help.models import HelpArticle + + qs = ( + HelpArticle.objects.filter(is_published=True) + .filter(Q(title__icontains=q) | Q(body__icontains=q)) + .select_related('category') + .order_by('sort_order', 'title')[:PER_GROUP] + ) + return [ + SearchHit( + title=a.title, + subtitle=getattr(a.category, 'title', '') or 'Справка', + url=f'/help/a/{a.slug}/', + icon='help', + ) + for a in qs + ] + + +def _search_news(q: str) -> list[SearchHit]: + from apps.feed.models import Post + + qs = ( + Post.objects.filter(is_published=True) + .filter(Q(title__icontains=q) | Q(body__icontains=q)) + .order_by('-created_at')[:PER_GROUP] + ) + return [ + SearchHit( + title=p.title, + subtitle=p.get_category_display(), + url=f'/news/{p.pk}/', + icon='news', + ) + for p in qs + ] + + +def _search_tickets(user, q: str) -> list[SearchHit]: + from apps.it_manager.models.tickets import Ticket + + qs = Ticket.objects.all() + if not user_can_access(user, 'it_manager'): + qs = qs.filter(Q(requester=user) | Q(assignee=user)) + qs = ( + qs.filter(Q(title__icontains=q) | Q(number__icontains=q)) + .select_related('status') + .order_by('-created_at')[:PER_GROUP] + ) + base_it = user_can_access(user, 'it_manager') + hits = [] + for t in qs: + status = getattr(t.status, 'name', '') or '' + url = f'/it-manager/tickets/{t.pk}/update/' if base_it else '/it-support/' + hits.append(SearchHit( + title=f'{t.number} · {t.title}', + subtitle=status, + url=url, + icon='ticket', + )) + return hits diff --git a/core/tests_portal_search.py b/core/tests_portal_search.py new file mode 100644 index 0000000..f987656 --- /dev/null +++ b/core/tests_portal_search.py @@ -0,0 +1,76 @@ +"""Тесты глобального поиска портала.""" +from datetime import date + +from django.contrib.auth.models import Group +from django.test import TestCase + +from apps.employees.models import Employee +from apps.help.models import HelpArticle, HelpCategory +from apps.tasks.models import Task +from core.test_utils import login_2fa + + +def _make_employee(**overrides): + n = Employee.objects.count() + 1 + defaults = { + 'username': f'search.user{n}', + 'email': f'search.user{n}@aks-apteka.com.ua', + 'first_name': 'Иван', + 'last_name': 'Тестов', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'is_active': True, + } + defaults.update(overrides) + return Employee.objects.create_user(password='pass', **defaults) + + +class PortalSearchViewTests(TestCase): + def setUp(self): + self.user = _make_employee(last_name='Поисковый', first_name='Юзер') + # Открытые разделы (без perm) требуют хотя бы одну группу + group = Group.objects.create(name='search-testers') + self.user.groups.add(group) + login_2fa(self.client, self.user) + + def test_requires_login(self): + self.client.logout() + resp = self.client.get('/api/search', {'q': 'тест'}) + self.assertIn(resp.status_code, (302, 401, 403)) + + def test_short_query_hint(self): + resp = self.client.get('/api/search', {'q': 'а'}, HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'минимум') + + def test_finds_employee_and_section(self): + other = _make_employee(last_name='Коваленко', first_name='Мария') + Task.objects.create(title='Проверить поиск по задачам', creator=self.user) + + resp = self.client.get('/api/search', {'q': 'Коваленко'}, HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Коваленко') + self.assertContains(resp, f'/employees/{other.pk}/') + + resp2 = self.client.get('/api/search', {'q': 'задач'}, HTTP_HX_REQUEST='true') + self.assertContains(resp2, 'Задачи') + self.assertContains(resp2, '/tasks/') + + def test_finds_help_article(self): + cat = HelpCategory.objects.create(title='Общее', slug='general') + HelpArticle.objects.create( + category=cat, + title='Как сменить пароль портала', + body='Инструкция по смене пароля', + slug='change-password', + is_published=True, + ) + resp = self.client.get('/api/search', {'q': 'пароль'}, HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'сменить пароль') + self.assertContains(resp, '/help/a/change-password/') + + def test_empty_result(self): + resp = self.client.get('/api/search', {'q': 'zzzznotfound999'}, HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Ничего не найдено') diff --git a/core/views.py b/core/views.py index 465b381..9ece93e 100644 --- a/core/views.py +++ b/core/views.py @@ -19,6 +19,9 @@ from core.portal_access import hr_manage_required, section_required, user_can_ma from core.utils.htmx import render_page from core.utils.pagination import paginate from django.utils.translation import gettext_lazy as _ +import logging + +logger = logging.getLogger(__name__) # Create your views here. @@ -783,44 +786,48 @@ def _build_chat_context(request): from apps.mattermost_connector.models import MattermostUser from apps.mattermost_connector.services.token_service import ensure_personal_access_token - cfg = ConnectorConfig.get_for('mattermost') - public_url = '' - if cfg: - public_url = (cfg.extra_config or {}).get('public_url', '').strip().rstrip('/') + try: + cfg = ConnectorConfig.get_for('mattermost') + public_url = '' + if cfg: + public_url = (cfg.extra_config or {}).get('public_url', '').strip().rstrip('/') - if not public_url: - return {'chat_state': 'not_configured'} + if not public_url: + return {'chat_state': 'not_configured'} - if not request.user.mattermost_user_id: - return {'chat_state': 'not_synced'} + if not request.user.mattermost_user_id: + return {'chat_state': 'not_synced'} - token = ensure_personal_access_token(request.user) - if not token: - return {'chat_state': 'token_error'} + token = ensure_personal_access_token(request.user) + if not token: + return {'chat_state': 'token_error'} - colleagues = [ - { - 'mattermostId': row['mattermost_id'], - 'name': (f"{row['django_user__first_name']} {row['django_user__last_name']}".strip() - or row['django_user__username']), + colleagues = [ + { + 'mattermostId': row['mattermost_id'], + 'name': (f"{row['django_user__first_name']} {row['django_user__last_name']}".strip() + or row['django_user__username']), + } + for row in MattermostUser.objects.filter(is_active=True) + .exclude(django_user=request.user) + .select_related('django_user') + .values('mattermost_id', 'django_user__first_name', 'django_user__last_name', 'django_user__username') + ] + + ws_url = public_url.replace('https://', 'wss://', 1).replace('http://', 'ws://', 1) + '/api/v4/websocket' + return { + 'chat_state': 'ready', + 'chat_config': { + 'apiBase': f'{public_url}/api/v4', + 'wsUrl': ws_url, + 'token': token, + 'currentUserId': request.user.mattermost_user_id, + }, + 'chat_colleagues': colleagues, } - for row in MattermostUser.objects.filter(is_active=True) - .exclude(django_user=request.user) - .select_related('django_user') - .values('mattermost_id', 'django_user__first_name', 'django_user__last_name', 'django_user__username') - ] - - ws_url = public_url.replace('https://', 'wss://', 1).replace('http://', 'ws://', 1) + '/api/v4/websocket' - return { - 'chat_state': 'ready', - 'chat_config': { - 'apiBase': f'{public_url}/api/v4', - 'wsUrl': ws_url, - 'token': token, - 'currentUserId': request.user.mattermost_user_id, - }, - 'chat_colleagues': colleagues, - } + except Exception: + logger.exception('Ошибка построения контекста чата для user_id=%s', getattr(request.user, 'pk', None)) + return {'chat_state': 'token_error'} @login_required diff --git a/static/assets/css/input.css b/static/assets/css/input.css index ed10ac8..c9f25f6 100644 --- a/static/assets/css/input.css +++ b/static/assets/css/input.css @@ -669,6 +669,504 @@ min-height: 0; } + /* ─── Пошта (Rainloop-style) ─── */ + #main-content:has(.mail-app) { + padding: 0 !important; + overflow: hidden !important; + display: flex; + flex-direction: column; + } + .mail-app { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; + background: var(--color-card); + color: var(--color-foreground); + } + .mail-notice { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.75rem; + padding: 1.5rem; + font-size: 0.875rem; + color: var(--color-muted-foreground); + } + .mail-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-muted) 55%, var(--color-card)); + flex-shrink: 0; + flex-wrap: wrap; + } + .mail-toolbar-left, + .mail-toolbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + } + .mail-toolbar-brand { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-foreground); + max-width: 14rem; + } + .mail-toolbar-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mail-toolbar-search { + flex: 1; + min-width: 10rem; + max-width: 22rem; + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.25rem 0.55rem; + color: var(--color-muted-foreground); + } + .mail-toolbar-search input { + flex: 1; + border: 0; + outline: none; + background: transparent; + font-size: 0.8125rem; + color: var(--color-foreground); + min-width: 0; + } + .mail-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid transparent; + border-radius: 0.375rem; + padding: 0.35rem 0.7rem; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + background: transparent; + color: var(--color-foreground); + white-space: nowrap; + } + .mail-btn-compose { + background: var(--color-primary); + color: white; + } + .mail-btn-compose:hover { filter: brightness(1.05); } + .mail-btn-ghost { + border-color: var(--color-border); + background: var(--color-card); + color: var(--color-muted-foreground); + } + .mail-btn-ghost:hover { + color: var(--color-foreground); + background: var(--color-muted); + } + .mail-layout { + flex: 1 1 auto; + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: 11.5rem minmax(16rem, 22rem) 1fr; + overflow: hidden; + } + .mail-folders { + border-right: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-muted) 40%, var(--color-card)); + padding: 0.5rem 0.35rem; + overflow-y: auto; + } + .mail-folder { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + border-radius: 0.375rem; + text-decoration: none; + color: var(--color-foreground); + font-size: 0.8125rem; + margin-bottom: 1px; + } + .mail-folder:hover { background: var(--color-muted); } + .mail-folder.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); + color: var(--color-primary); + font-weight: 600; + } + .mail-folder-icon { + display: inline-flex; + opacity: 0.75; + flex-shrink: 0; + } + .mail-folder.is-active .mail-folder-icon { opacity: 1; } + .mail-folder-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } + .mail-folder-badge { + background: var(--color-primary); + color: white; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 700; + padding: 0.05rem 0.4rem; + line-height: 1.2; + } + + .mail-list { + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + background: var(--color-card); + } + .mail-list-head { + flex-shrink: 0; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + } + .mail-list-head em { + font-style: normal; + margin-left: 0.35rem; + color: var(--color-primary); + } + .mail-list-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + .mail-list-empty { + padding: 2rem 1rem; + text-align: center; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + } + .mail-load-more-wrap { + padding: 0.75rem 0.85rem 1rem; + display: flex; + justify-content: center; + } + .mail-load-more-btn { + width: 100%; + font-size: 0.8125rem; + } + .mail-row { + display: flex; + flex-direction: column; + gap: 0.12rem; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + text-decoration: none; + color: inherit; + background: transparent; + min-width: 0; + } + .mail-row:hover { background: var(--color-muted); } + .mail-row.is-selected { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + box-shadow: inset 3px 0 0 var(--color-primary); + } + .mail-row.is-unread .mail-row-from, + .mail-row.is-unread .mail-row-subject { font-weight: 700; } + .mail-row-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; + } + .mail-row-from { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .mail-row-date { + font-size: 0.6875rem; + color: var(--color-muted-foreground); + white-space: nowrap; + flex-shrink: 0; + } + .mail-row-subject { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mail-row-preview { + display: block; + font-size: 0.75rem; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; + } + .mail-reader { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + background: var(--color-background); + } + .mail-reader-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: var(--color-muted-foreground); + font-size: 0.875rem; + padding: 2rem; + } + .mail-reader-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1.15rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-card); + flex-shrink: 0; + } + .mail-reader-head h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; + } + .mail-message, + .mail-compose { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + } + .mail-message-meta { + padding: 0.75rem 1.15rem; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + border-bottom: 1px solid var(--color-border); + background: var(--color-card); + display: flex; + flex-direction: column; + gap: 0.2rem; + flex-shrink: 0; + } + .mail-message-body { + padding: 0; + font-size: 0.875rem; + line-height: 1.6; + flex: 0 0 auto; + display: flex; + flex-direction: column; + } + .mail-message-text { + padding: 1.15rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .mail-html-frame { + display: block; + width: 100%; + min-height: 70vh; + height: 70vh; + border: 0; + background: #fff; + } + .mail-attachments { + padding: 0.75rem 1.15rem 1rem; + border-top: 1px solid var(--color-border); + } + .mail-attachments-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + margin-bottom: 0.45rem; + } + .mail-attachments-list { display: flex; flex-wrap: wrap; gap: 0.4rem; } + .mail-attachment { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border-radius: 0.375rem; + background: var(--color-muted); + color: var(--color-foreground); + text-decoration: none; + font-size: 0.8125rem; + } + .mail-attachment span { color: var(--color-muted-foreground); font-size: 0.75rem; } + .mail-reply { + margin: 0 1.15rem 1.15rem; + padding-top: 0.85rem; + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .mail-reply-label { font-size: 0.8125rem; color: var(--color-muted-foreground); } + .mail-reply textarea, + .mail-compose-form textarea { + width: 100%; + resize: vertical; + min-height: 5.5rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.55rem 0.7rem; + font: inherit; + font-size: 0.875rem; + background: var(--color-card); + color: var(--color-foreground); + box-sizing: border-box; + } + .mail-compose-form { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 0.85rem 1.15rem 1.15rem; + flex: 1 1 auto; + min-height: 0; + } + .mail-compose-form textarea { + flex: 1 1 auto; + min-height: 12rem; + } + .mail-field { + display: grid; + grid-template-columns: 4.5rem 1fr; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + } + .mail-field span { color: var(--color-muted-foreground); } + .mail-field input { + width: 100%; + border: 0; + border-bottom: 1px solid var(--color-border); + background: transparent; + padding: 0.4rem 0; + font: inherit; + font-size: 0.875rem; + color: var(--color-foreground); + outline: none; + } + .mail-field input:focus { border-bottom-color: var(--color-primary); } + .mail-compose-actions { display: flex; gap: 0.5rem; margin-top: 0.25rem; align-items: center; flex-wrap: wrap; } + .mail-attach { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding-top: 0.15rem; + } + .mail-attach-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + width: fit-content; + cursor: pointer; + font-size: 0.8125rem; + color: var(--color-primary); + font-weight: 500; + } + .mail-attach-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + overflow: hidden; + } + .mail-attach-hint { + font-size: 0.75rem; + color: var(--color-muted-foreground); + } + .mail-attach-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + } + .mail-attach-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + background: var(--color-muted); + border-radius: 0.375rem; + } + .mail-attach-list li span { color: var(--color-muted-foreground); font-size: 0.75rem; } + .mail-alert { + margin: 0.75rem 1.15rem 0; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + } + .mail-alert-error { + background: color-mix(in srgb, var(--color-destructive) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); + color: var(--color-destructive); + } + .mail-alert-ok { + background: color-mix(in srgb, hsl(152 55% 45%) 12%, transparent); + border: 1px solid color-mix(in srgb, hsl(152 55% 45%) 35%, transparent); + color: hsl(152 55% 30%); + } + @media (max-width: 900px) { + .mail-layout { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(12rem, 40%) 1fr; + } + .mail-folders { + display: flex; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--color-border); + padding: 0.35rem; + gap: 0.15rem; + } + .mail-folder { flex-shrink: 0; } + .mail-folder-label { max-width: 6rem; } + .mail-layout-compose { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(10rem, 35%) 1fr; + } + .mail-layout-compose .mail-folders { display: flex; } + } + /* ─── Документы (Nextcloud) ─── */ .nc-file-toolbar { display: flex; @@ -788,6 +1286,106 @@ .tr-hover:hover { background-color: var(--color-muted); } .li-hover:hover { background-color: var(--color-muted); } + /* ─── Header global search ─── */ + .header-search { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--color-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.4375rem 0.75rem; + width: 18rem; + } + .header-search-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; + } + .header-search-input { + border: none; + background: transparent; + padding: 0; + box-shadow: none; + width: 100%; + font: inherit; + font-size: 0.8125rem; + color: var(--color-foreground); + outline: none; + } + .portal-search-results { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + min-width: 22rem; + max-height: min(70vh, 28rem); + overflow-y: auto; + z-index: 80; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); + } + .portal-search-panel { padding: 0.35rem 0; } + .portal-search-hint, + .portal-search-empty { + padding: 0.85rem 1rem; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + } + .portal-search-group { padding: 0.25rem 0 0.35rem; } + .portal-search-group + .portal-search-group { + border-top: 1px solid var(--color-border); + } + .portal-search-group-label { + padding: 0.35rem 0.85rem 0.2rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + } + .portal-search-hit { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding: 0.45rem 0.85rem; + text-decoration: none; + color: inherit; + } + .portal-search-hit:hover, + .portal-search-hit:focus { + background: var(--color-muted); + outline: none; + } + .portal-search-hit-icon { + display: inline-flex; + margin-top: 0.15rem; + color: var(--color-muted-foreground); + flex-shrink: 0; + } + .portal-search-hit-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + } + .portal-search-hit-title { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .portal-search-hit-sub { + font-size: 0.75rem; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* ─── Task row ─── */ .task-row { display: flex; @@ -925,6 +1523,10 @@ #main-content:has(.oo-editor-page) { padding: 0 !important; } + #main-content:has(.mail-app) { + padding: 0 !important; + padding-bottom: 0 !important; + } /* Task rows: meta wraps to second line */ .task-row { @@ -957,6 +1559,8 @@ .header-search { display: none !important; } + display: none !important; + } /* ─── Bottom navigation ─── */ .bottom-nav { diff --git a/static/assets/css/main.css b/static/assets/css/main.css index 9191878..b0393ad 100644 --- a/static/assets/css/main.css +++ b/static/assets/css/main.css @@ -1308,6 +1308,502 @@ flex: 1; min-height: 0; } + #main-content:has(.mail-app) { + padding: 0 !important; + overflow: hidden !important; + display: flex; + flex-direction: column; + } + .mail-app { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; + background: var(--color-card); + color: var(--color-foreground); + } + .mail-notice { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.75rem; + padding: 1.5rem; + font-size: 0.875rem; + color: var(--color-muted-foreground); + } + .mail-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-muted) 55%, var(--color-card)); + flex-shrink: 0; + flex-wrap: wrap; + } + .mail-toolbar-left, + .mail-toolbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + } + .mail-toolbar-brand { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-foreground); + max-width: 14rem; + } + .mail-toolbar-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mail-toolbar-search { + flex: 1; + min-width: 10rem; + max-width: 22rem; + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.25rem 0.55rem; + color: var(--color-muted-foreground); + } + .mail-toolbar-search input { + flex: 1; + border: 0; + outline: none; + background: transparent; + font-size: 0.8125rem; + color: var(--color-foreground); + min-width: 0; + } + .mail-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid transparent; + border-radius: 0.375rem; + padding: 0.35rem 0.7rem; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + background: transparent; + color: var(--color-foreground); + white-space: nowrap; + } + .mail-btn-compose { + background: var(--color-primary); + color: white; + } + .mail-btn-compose:hover { filter: brightness(1.05); } + .mail-btn-ghost { + border-color: var(--color-border); + background: var(--color-card); + color: var(--color-muted-foreground); + } + .mail-btn-ghost:hover { + color: var(--color-foreground); + background: var(--color-muted); + } + .mail-layout { + flex: 1 1 auto; + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: 11.5rem minmax(16rem, 22rem) 1fr; + overflow: hidden; + } + .mail-folders { + border-right: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-muted) 40%, var(--color-card)); + padding: 0.5rem 0.35rem; + overflow-y: auto; + } + .mail-folder { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + border-radius: 0.375rem; + text-decoration: none; + color: var(--color-foreground); + font-size: 0.8125rem; + margin-bottom: 1px; + } + .mail-folder:hover { background: var(--color-muted); } + .mail-folder.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); + color: var(--color-primary); + font-weight: 600; + } + .mail-folder-icon { + display: inline-flex; + opacity: 0.75; + flex-shrink: 0; + } + .mail-folder.is-active .mail-folder-icon { opacity: 1; } + .mail-folder-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } + .mail-folder-badge { + background: var(--color-primary); + color: white; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 700; + padding: 0.05rem 0.4rem; + line-height: 1.2; + } + + .mail-list { + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + background: var(--color-card); + } + .mail-list-head { + flex-shrink: 0; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + } + .mail-list-head em { + font-style: normal; + margin-left: 0.35rem; + color: var(--color-primary); + } + .mail-list-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + .mail-list-empty { + padding: 2rem 1rem; + text-align: center; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + } + .mail-load-more-wrap { + padding: 0.75rem 0.85rem 1rem; + display: flex; + justify-content: center; + } + .mail-load-more-btn { + width: 100%; + font-size: 0.8125rem; + } + .mail-row { + display: flex; + flex-direction: column; + gap: 0.12rem; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + text-decoration: none; + color: inherit; + background: transparent; + min-width: 0; + } + .mail-row:hover { background: var(--color-muted); } + .mail-row.is-selected { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + box-shadow: inset 3px 0 0 var(--color-primary); + } + .mail-row.is-unread .mail-row-from, + .mail-row.is-unread .mail-row-subject { font-weight: 700; } + .mail-row-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; + } + .mail-row-from { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .mail-row-date { + font-size: 0.6875rem; + color: var(--color-muted-foreground); + white-space: nowrap; + flex-shrink: 0; + } + .mail-row-subject { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mail-row-preview { + display: block; + font-size: 0.75rem; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; + } + .mail-reader { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + background: var(--color-background); + } + .mail-reader-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: var(--color-muted-foreground); + font-size: 0.875rem; + padding: 2rem; + } + .mail-reader-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1.15rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-card); + flex-shrink: 0; + } + .mail-reader-head h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; + } + .mail-message, + .mail-compose { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + } + .mail-message-meta { + padding: 0.75rem 1.15rem; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + border-bottom: 1px solid var(--color-border); + background: var(--color-card); + display: flex; + flex-direction: column; + gap: 0.2rem; + flex-shrink: 0; + } + .mail-message-body { + padding: 0; + font-size: 0.875rem; + line-height: 1.6; + flex: 0 0 auto; + display: flex; + flex-direction: column; + } + .mail-message-text { + padding: 1.15rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .mail-html-frame { + display: block; + width: 100%; + min-height: 70vh; + height: 70vh; + border: 0; + background: #fff; + } + .mail-attachments { + padding: 0.75rem 1.15rem 1rem; + border-top: 1px solid var(--color-border); + } + .mail-attachments-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + margin-bottom: 0.45rem; + } + .mail-attachments-list { display: flex; flex-wrap: wrap; gap: 0.4rem; } + .mail-attachment { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border-radius: 0.375rem; + background: var(--color-muted); + color: var(--color-foreground); + text-decoration: none; + font-size: 0.8125rem; + } + .mail-attachment span { color: var(--color-muted-foreground); font-size: 0.75rem; } + .mail-reply { + margin: 0 1.15rem 1.15rem; + padding-top: 0.85rem; + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .mail-reply-label { font-size: 0.8125rem; color: var(--color-muted-foreground); } + .mail-reply textarea, + .mail-compose-form textarea { + width: 100%; + resize: vertical; + min-height: 5.5rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.55rem 0.7rem; + font: inherit; + font-size: 0.875rem; + background: var(--color-card); + color: var(--color-foreground); + box-sizing: border-box; + } + .mail-compose-form { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 0.85rem 1.15rem 1.15rem; + flex: 1 1 auto; + min-height: 0; + } + .mail-compose-form textarea { + flex: 1 1 auto; + min-height: 12rem; + } + .mail-field { + display: grid; + grid-template-columns: 4.5rem 1fr; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + } + .mail-field span { color: var(--color-muted-foreground); } + .mail-field input { + width: 100%; + border: 0; + border-bottom: 1px solid var(--color-border); + background: transparent; + padding: 0.4rem 0; + font: inherit; + font-size: 0.875rem; + color: var(--color-foreground); + outline: none; + } + .mail-field input:focus { border-bottom-color: var(--color-primary); } + .mail-compose-actions { display: flex; gap: 0.5rem; margin-top: 0.25rem; align-items: center; flex-wrap: wrap; } + .mail-attach { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding-top: 0.15rem; + } + .mail-attach-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + width: fit-content; + cursor: pointer; + font-size: 0.8125rem; + color: var(--color-primary); + font-weight: 500; + } + .mail-attach-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + overflow: hidden; + } + .mail-attach-hint { + font-size: 0.75rem; + color: var(--color-muted-foreground); + } + .mail-attach-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + } + .mail-attach-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + background: var(--color-muted); + border-radius: 0.375rem; + } + .mail-attach-list li span { color: var(--color-muted-foreground); font-size: 0.75rem; } + .mail-alert { + margin: 0.75rem 1.15rem 0; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + } + .mail-alert-error { + background: color-mix(in srgb, var(--color-destructive) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); + color: var(--color-destructive); + } + .mail-alert-ok { + background: color-mix(in srgb, hsl(152 55% 45%) 12%, transparent); + border: 1px solid color-mix(in srgb, hsl(152 55% 45%) 35%, transparent); + color: hsl(152 55% 30%); + } + @media (max-width: 900px) { + .mail-layout { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(12rem, 40%) 1fr; + } + .mail-folders { + display: flex; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--color-border); + padding: 0.35rem; + gap: 0.15rem; + } + .mail-folder { flex-shrink: 0; } + .mail-folder-label { max-width: 6rem; } + .mail-layout-compose { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(10rem, 35%) 1fr; + } + .mail-layout-compose .mail-folders { display: flex; } + } .nc-file-toolbar { display: flex; flex-wrap: wrap; @@ -1426,6 +1922,104 @@ .li-hover:hover { background-color: var(--color-muted); } + .header-search { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--color-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.4375rem 0.75rem; + width: 18rem; + } + .header-search-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; + } + .header-search-input { + border: none; + background: transparent; + padding: 0; + box-shadow: none; + width: 100%; + font: inherit; + font-size: 0.8125rem; + color: var(--color-foreground); + outline: none; + } + .portal-search-results { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + min-width: 22rem; + max-height: min(70vh, 28rem); + overflow-y: auto; + z-index: 80; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); + } + .portal-search-panel { padding: 0.35rem 0; } + .portal-search-hint, + .portal-search-empty { + padding: 0.85rem 1rem; + font-size: 0.8125rem; + color: var(--color-muted-foreground); + } + .portal-search-group { padding: 0.25rem 0 0.35rem; } + .portal-search-group + .portal-search-group { + border-top: 1px solid var(--color-border); + } + .portal-search-group-label { + padding: 0.35rem 0.85rem 0.2rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); + } + .portal-search-hit { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding: 0.45rem 0.85rem; + text-decoration: none; + color: inherit; + } + .portal-search-hit:hover, + .portal-search-hit:focus { + background: var(--color-muted); + outline: none; + } + .portal-search-hit-icon { + display: inline-flex; + margin-top: 0.15rem; + color: var(--color-muted-foreground); + flex-shrink: 0; + } + .portal-search-hit-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + } + .portal-search-hit-title { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .portal-search-hit-sub { + font-size: 0.75rem; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .task-row { display: flex; align-items: center; @@ -1570,6 +2164,10 @@ #main-content:has(.oo-editor-page) { padding: 0 !important; } + #main-content:has(.mail-app) { + padding: 0 !important; + padding-bottom: 0 !important; + } .task-row { flex-wrap: wrap; align-items: flex-start; diff --git a/templates/index.html b/templates/index.html index 01ba28f..0333bdc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -516,18 +516,25 @@ - @@ -849,6 +856,36 @@ } setActiveNavItem(); + (function initPortalSearch() { + var wrap = document.getElementById('header-search'); + var input = document.getElementById('portal-search-input'); + var panel = document.getElementById('search-results'); + if (!wrap || !input || !panel) return; + + function openSearch() { + panel.hidden = false; + input.setAttribute('aria-expanded', 'true'); + } + function closeSearch() { + panel.hidden = true; + input.setAttribute('aria-expanded', 'false'); + } + window.closePortalSearch = closeSearch; + + input.addEventListener('focus', openSearch); + document.addEventListener('click', function (e) { + if (!wrap.contains(e.target)) closeSearch(); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeSearch(); + }); + document.body.addEventListener('htmx:afterSwap', function (e) { + if (e.detail && e.detail.target && e.detail.target.id === 'search-results') { + openSearch(); + } + }); + })(); + // Восстановление темы и фона var savedTheme = localStorage.getItem('theme') || 'light'; document.querySelectorAll('.theme-option').forEach(function(btn) { diff --git a/templates/pages/email.html b/templates/pages/email.html index 2352935..93b36b0 100644 --- a/templates/pages/email.html +++ b/templates/pages/email.html @@ -1,139 +1,205 @@ +{# Rainloop-style webmail layout #} +{% if not mail_configured %}
+
{{ mail_error }}
+
-
-
-

Електронна пошта

-

- {% if mailbox %}Скринька: {{ mailbox }}{% else %}Внутрішня корпоративна пошта{% endif %} -

+{% elif mail_state == 'connect' %} +
+ {% include "partials/email-connect-panel.html" %} +
+ +{% else %} +
+
+
+ + + {{ mailbox }} + + + + Написати +
- {% if mail_state == 'ready' and not compose and view != 'read' %} - - - Написати - - {% endif %} - {% if mail_connected %} -
- {% csrf_token %} - + + + +
- {% endif %} -
- - {% if not mail_configured %} -
- {{ mail_error }} -
- - {% elif mail_state == 'no_mailbox' %} -
- У вашому профілі не вказано робочий email. Зверніться до адміністратора. -
- - {% elif mail_state == 'connect' %} -
-

Підключення скриньки

-

- Зазвичай пошта підключається автоматично після входу в портал. - Якщо пароль поштової скриньки {{ mailbox }} відрізняється від пароля порталу — введіть його вручну. -

- {% if connect_error %} -
- {{ connect_error }} +
+ + + +
+ {% csrf_token %} + +
- {% endif %} -
- {% csrf_token %} - - - -
-
-
+ - {% else %} -
- -
-
- {% for key, label in folder_choices %} - - {{ label }} - {% if key == 'inbox' and unread_count %} - {{ unread_count }} +
+ {# ── Folders ── #} + + + {# ── Message list ── #} +
+
+ + {% for key, label in folder_choices %}{% if active_folder == key %}{{ label }}{% endif %}{% endfor %} + {% if unread_count and active_folder == 'inbox' %}{{ unread_count }}{% endif %} +
-
- -
- - {% if compose %} -
-

Новий лист

- {% if send_errors %} -
- {% for err in send_errors %}
{{ err }}
{% endfor %} +
+ {% if messages %} + {% for msg in messages %} + {% include "partials/email-list-row.html" %} + {% endfor %} + {% if mail_has_more %} +
+ +
+ {% endif %} + {% else %} +
+ {% if search_q %}Нічого не знайдено.{% else %}Немає листів у цій папці.{% endif %}
{% endif %} +
+ + + {# ── Reader / Compose ── #} +
+ {% if compose %} +
+
+

Новий лист

+ Скасувати +
+ {% if send_errors %} +
{% for err in send_errors %}
{{ err }}
{% endfor %}
+ {% endif %} {% if send_success %} -
- Лист відправлено. -
+
Лист відправлено.
{% endif %} -
+ {% csrf_token %} -
- - + + + + + +
+ +
До 5 файлів, кожен ≤ 10 МБ
+
-
- - -
-
- - -
-
- - -
-
- - Скасувати +
+
{% elif view == 'read' and message %} -
- ← Назад до списку -

{{ message.subject }}

-
+
+
+

{{ message.subject|default:"(без теми)" }}

+
+
Від: {{ message.sender }}
Кому: {{ message.to }}
{% if message.date %}
Дата: {{ message.date }}
{% endif %}
-
- {% if message.is_html %}{{ message.body|striptags|linebreaksbr }}{% else %}{{ message.body|linebreaksbr }}{% endif %} +
+ {% if message.body_html %} + + {% elif message.body %} +
{{ message.body|linebreaksbr }}
+ {% else %} +
Порожній лист
+ {% endif %}
{% if message.attachments %} -
-
Вкладення
-
+
+
Вкладення
+ @@ -141,100 +207,77 @@ {% endif %} {% if send_success %} -
- Відповідь надіслано. -
+
Відповідь надіслано.
{% endif %} {% if send_errors %} -
- {% for err in send_errors %}
{{ err }}
{% endfor %} -
+
{% for err in send_errors %}
{{ err }}
{% endfor %}
{% endif %} {% if message.reply_to %} -
+ {% csrf_token %} -
- Відповідь для {{ message.reply_to }} -
- -
- -
+
Відповідь для {{ message.reply_to }}
+ +
{% endif %}
{% elif view == 'read' and read_error %} -
{{ read_error }}
+
{{ read_error }}
+ + {% elif send_success %} +
+
Лист відправлено.
+
{% else %} - {% if send_success %} -
- Лист відправлено. +
+ +

Оберіть лист зі списку

{% endif %} -
- - - -
- - {% if messages %} - {% regroup messages by is_unread as grouped %} - {% for group in grouped %} - {% if group.grouper %} -
- Непрочитані{% if active_folder == 'inbox' %} · {{ unread_count }}{% endif %} -
- {% elif not forloop.first %} -
- Прочитані -
- {% endif %} - {% for msg in group.list %} - -
- {{ msg.sender|slice:":2"|upper }} -
-
-
- {{ msg.sender }} - - {% if msg.date %}{{ msg.date|date:"d.m H:i" }}{% endif %} - -
-
{{ msg.subject }}
- {% if msg.preview %} -

{{ msg.preview }}

- {% endif %} -
-
- {% endfor %} - {% endfor %} - {% else %} -
- {% if search_q %}Нічого не знайдено.{% else %}Немає листів у цій папці.{% endif %} -
- {% endif %} - {% endif %} - -
+
- {% endif %} -
+ +{% endif %} diff --git a/templates/partials/email-autoconfig-fields.html b/templates/partials/email-autoconfig-fields.html new file mode 100644 index 0000000..af64853 --- /dev/null +++ b/templates/partials/email-autoconfig-fields.html @@ -0,0 +1,41 @@ +{% if autoconfig_message %} +
+ {{ autoconfig_message }} +
+{% else %} +
+{% endif %} +
+ Розширені налаштування сервера +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
diff --git a/templates/partials/email-connect-panel.html b/templates/partials/email-connect-panel.html new file mode 100644 index 0000000..3092ade --- /dev/null +++ b/templates/partials/email-connect-panel.html @@ -0,0 +1,38 @@ +
+

Підключення скриньки

+

+ Підключіть свою пошту: вкажіть адресу та пароль. + Параметри сервера визначаються автоматично (як у Thunderbird); + {% if mail_defaults %}якщо автоконфіг не знайдено — спробуємо {{ mail_defaults.imap_host }}.{% endif %} +

+ {% if connect_error %} +
+ {{ connect_error }} + {% if connect_error_detail %} +
{{ connect_error_detail }}
+ {% endif %} +
+ {% endif %} +
+ {% csrf_token %} + + + + +
+ {% include "partials/email-autoconfig-fields.html" %} +
+ +
+ {% if connect_success %} +
Підключено. Перенаправлення…
+ {% endif %} +
diff --git a/templates/partials/email-list-more.html b/templates/partials/email-list-more.html new file mode 100644 index 0000000..09e66fe --- /dev/null +++ b/templates/partials/email-list-more.html @@ -0,0 +1,17 @@ +{% for msg in messages %} +{% include "partials/email-list-row.html" %} +{% endfor %} +{% if mail_has_more %} +
+ +
+{% elif load_error %} +
{{ load_error }}
+{% endif %} diff --git a/templates/partials/email-list-row.html b/templates/partials/email-list-row.html new file mode 100644 index 0000000..ba1666d --- /dev/null +++ b/templates/partials/email-list-row.html @@ -0,0 +1,15 @@ + +
+ {{ msg.sender }} + {% if msg.date %}{{ msg.date|date:"d.m H:i" }}{% endif %} +
+
{{ msg.subject|default:"(без теми)" }}
+ {% if msg.preview %} +
{{ msg.preview }}
+ {% endif %} +
diff --git a/templates/partials/portal-search-results.html b/templates/partials/portal-search-results.html new file mode 100644 index 0000000..f4db03d --- /dev/null +++ b/templates/partials/portal-search-results.html @@ -0,0 +1,48 @@ +{# HTMX partial: результаты глобального поиска в хедере #} +
+ {% if query|length < min_len %} +
Введите минимум {{ min_len }} символа…
+ {% elif not groups %} +
Ничего не найдено по «{{ query }}»
+ {% else %} + {% for group in groups %} + + {% endfor %} + {% endif %} +