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)[^>]*>.*?\1>', ' ', 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)