a2fe4a49e3
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата. Co-authored-by: Cursor <cursoragent@cursor.com>
411 lines
13 KiB
Python
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 'авто')
|