Files
ACORE/apps/mail_connector/autoconfig.py
T
lokses a2fe4a49e3 mail + portal: самоподключение почты, веб-клиент и глобальный поиск
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 12:22:53 +03:00

411 lines
13 KiB
Python

"""Автоопределение 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 '<clientConfig' not in text and '<emailProvider' not in text:
return None
return text
except httpx.HTTPError as e:
logger.debug('autoconfig fetch %s: %s', url, e)
return None
def _try_urls(urls: list[tuple[str, str]]) -> 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 'авто')