116 lines
No EOL
4.1 KiB
Python
116 lines
No EOL
4.1 KiB
Python
import asyncio
|
|
import socket
|
|
import ssl
|
|
import smtplib
|
|
from dataclasses import dataclass
|
|
from typing import List, Dict, Any
|
|
import dns.resolver
|
|
from app.config import settings
|
|
from datetime import datetime
|
|
|
|
@dataclass
|
|
class MXRecord:
|
|
preference: int
|
|
exchange: str
|
|
|
|
async def resolve_mx(domain: str) -> List[MXRecord]:
|
|
def _sync() -> List[MXRecord]:
|
|
r = dns.resolver.Resolver()
|
|
r.timeout = settings.DNS_TIMEOUT
|
|
r.lifetime = settings.DNS_LIFETIME
|
|
answers = r.resolve(domain, 'MX')
|
|
recs = []
|
|
for rr in answers:
|
|
recs.append(MXRecord(preference=int(rr.preference), exchange=str(rr.exchange).rstrip('.')))
|
|
recs.sort(key=lambda x: x.preference)
|
|
return recs
|
|
return await asyncio.to_thread(_sync)
|
|
|
|
async def resolve_txt(domain: str) -> List[str]:
|
|
def _sync() -> List[str]:
|
|
r = dns.resolver.Resolver()
|
|
r.timeout = settings.DNS_TIMEOUT
|
|
r.lifetime = settings.DNS_LIFETIME
|
|
try:
|
|
answers = r.resolve(domain, 'TXT')
|
|
except Exception:
|
|
return []
|
|
out = []
|
|
for rr in answers:
|
|
s = ''.join(part.decode('utf-8') if isinstance(part, bytes) else str(part) for part in getattr(rr, 'strings', []))
|
|
if not s:
|
|
s = str(rr)
|
|
s = s.strip('"')
|
|
out.append(s)
|
|
return out
|
|
return await asyncio.to_thread(_sync)
|
|
|
|
def parse_spf(txt_records: List[str]) -> Dict[str, Any]:
|
|
spf_records = [t for t in txt_records if t.lower().startswith('v=spf1')]
|
|
details = []
|
|
for spf in spf_records:
|
|
tokens = spf.split()
|
|
mechanisms = [t for t in tokens[1:]]
|
|
qualifier = next((t for t in tokens if t.endswith('all')), None)
|
|
details.append({
|
|
'record': spf,
|
|
'mechanisms': mechanisms,
|
|
'has_all': any(tok.endswith('all') for tok in tokens),
|
|
'all_qualifier': qualifier,
|
|
})
|
|
return {
|
|
'present': len(spf_records) > 0,
|
|
'records': spf_records,
|
|
'parsed': details,
|
|
}
|
|
|
|
async def smtp_starttls_probe(host: str, port: int = 25) -> Dict[str, Any]:
|
|
def _sync() -> Dict[str, Any]:
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = True
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
result: Dict[str, Any] = {
|
|
'host': host,
|
|
'port': port,
|
|
'ehlo': False,
|
|
'starttls_advertised': False,
|
|
'tls_established': False,
|
|
'tls_version': None,
|
|
'cipher': None,
|
|
'cert': None,
|
|
}
|
|
try:
|
|
with smtplib.SMTP(host=host, port=port, timeout=settings.SMTP_TIMEOUT) as server:
|
|
code, _ = server.ehlo()
|
|
result['ehlo'] = (200 <= code < 400)
|
|
if 'starttls' in (server.esmtp_features or {}):
|
|
result['starttls_advertised'] = True
|
|
code, _ = server.starttls(context=context)
|
|
if 200 <= code < 400:
|
|
result['tls_established'] = True
|
|
server.ehlo()
|
|
try:
|
|
sslobj = server.sock
|
|
result['tls_version'] = getattr(sslobj, 'version', lambda: None)()
|
|
result['cipher'] = getattr(sslobj, 'cipher', lambda: None)()
|
|
try:
|
|
cert = sslobj.getpeercert()
|
|
result['cert'] = cert
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
else:
|
|
result['starttls_advertised'] = False
|
|
except (ssl.SSLError, smtplib.SMTPException, OSError, socket.error) as e:
|
|
result['error'] = str(e)
|
|
return result
|
|
return await asyncio.to_thread(_sync)
|
|
|
|
# Cert date parsing (OpenSSL style: 'Jun 5 12:00:00 2025 GMT')
|
|
def parse_cert_time(s: str) -> datetime | None:
|
|
try:
|
|
norm = ' '.join(s.split())
|
|
return datetime.strptime(norm, '%b %d %H:%M:%S %Y %Z')
|
|
except Exception:
|
|
return None |