first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Backend service modules."""
|
||||
176
backend/app/services/dns_verification.py
Normal file
176
backend/app/services/dns_verification.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Live DNS checks for company domain onboarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
|
||||
|
||||
def _normalize_txt(value: str) -> str:
|
||||
return " ".join(value.split()).strip().lower()
|
||||
|
||||
|
||||
def _lookup_txt(name: str) -> list[str]:
|
||||
try:
|
||||
answers = dns.resolver.resolve(name, "TXT")
|
||||
except (
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer,
|
||||
dns.resolver.NoNameservers,
|
||||
dns.exception.Timeout,
|
||||
dns.resolver.LifetimeTimeout,
|
||||
):
|
||||
return []
|
||||
records: list[str] = []
|
||||
for answer in answers:
|
||||
parts = []
|
||||
for chunk in answer.strings:
|
||||
if isinstance(chunk, bytes):
|
||||
parts.append(chunk.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
parts.append(str(chunk))
|
||||
records.append("".join(parts))
|
||||
return records
|
||||
|
||||
|
||||
def _lookup_mx(domain_name: str) -> list[tuple[int, str]]:
|
||||
try:
|
||||
answers = dns.resolver.resolve(domain_name, "MX")
|
||||
except (
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer,
|
||||
dns.resolver.NoNameservers,
|
||||
dns.exception.Timeout,
|
||||
dns.resolver.LifetimeTimeout,
|
||||
):
|
||||
return []
|
||||
hosts: list[tuple[int, str]] = []
|
||||
for answer in answers:
|
||||
host = str(answer.exchange).rstrip(".").lower()
|
||||
hosts.append((int(answer.preference), host))
|
||||
return hosts
|
||||
|
||||
|
||||
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
|
||||
expected = expected_host.rstrip(".").lower()
|
||||
hosts = _lookup_mx(domain_name)
|
||||
if not hosts:
|
||||
return False, f"No MX records found for {domain_name}."
|
||||
hostnames = [host for _, host in hosts]
|
||||
if expected in hostnames:
|
||||
return True, f"MX points to {expected_host}."
|
||||
return False, f"Expected MX host {expected_host}; found {', '.join(hostnames)}."
|
||||
|
||||
|
||||
def verify_spf(domain_name: str, expected_include: str) -> tuple[bool, str]:
|
||||
include_token = expected_include.rstrip(".").lower()
|
||||
records = [_normalize_txt(record) for record in _lookup_txt(domain_name)]
|
||||
spf_records = [record for record in records if record.startswith("v=spf1")]
|
||||
if not spf_records:
|
||||
return False, f"No SPF TXT record found at {domain_name}."
|
||||
needle = f"include:{include_token}"
|
||||
for record in spf_records:
|
||||
if needle in record:
|
||||
return True, f"SPF includes {expected_include}."
|
||||
return False, f"SPF record must include {expected_include}."
|
||||
|
||||
|
||||
def _lookup_a(name: str) -> list[str]:
|
||||
try:
|
||||
answers = dns.resolver.resolve(name, "A")
|
||||
except (
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer,
|
||||
dns.resolver.NoNameservers,
|
||||
dns.exception.Timeout,
|
||||
dns.resolver.LifetimeTimeout,
|
||||
):
|
||||
return []
|
||||
return [str(answer).strip() for answer in answers]
|
||||
|
||||
|
||||
def verify_platform_spf(platform_spf_host: str, mail_host: str) -> tuple[bool, str]:
|
||||
"""Ensure the shared SPF include target exists and authorizes the mail host."""
|
||||
records = [_normalize_txt(record) for record in _lookup_txt(platform_spf_host)]
|
||||
spf_records = [record for record in records if record.startswith("v=spf1")]
|
||||
if not spf_records:
|
||||
return (
|
||||
False,
|
||||
f"No SPF TXT at {platform_spf_host}. On getall.help add Host _spf with "
|
||||
f"v=spf1 ip4:<mail-server-ip> ~all.",
|
||||
)
|
||||
|
||||
spf_value = spf_records[0]
|
||||
mail_ips = _lookup_a(mail_host.rstrip("."))
|
||||
for ip in mail_ips:
|
||||
if f"ip4:{ip}" in spf_value or f"ip6:{ip}" in spf_value:
|
||||
return True, f"{platform_spf_host} authorizes {mail_host} ({ip})."
|
||||
|
||||
if "ip4:" in spf_value or "ip6:" in spf_value or " a " in f" {spf_value} ":
|
||||
return True, f"{platform_spf_host} SPF record is present."
|
||||
|
||||
return (
|
||||
False,
|
||||
f"{platform_spf_host} exists but does not appear to authorize the mail server.",
|
||||
)
|
||||
|
||||
|
||||
def verify_dkim(
|
||||
domain_name: str,
|
||||
selector: str,
|
||||
expected_public_key: str,
|
||||
) -> tuple[bool, str]:
|
||||
name = f"{selector}._domainkey.{domain_name}"
|
||||
records = [_normalize_txt(record) for record in _lookup_txt(name)]
|
||||
if not records:
|
||||
return False, f"No DKIM TXT record found at {name}."
|
||||
key_token = expected_public_key.strip().lower()
|
||||
for record in records:
|
||||
if key_token in record.replace(" ", ""):
|
||||
return True, "DKIM public key matches."
|
||||
return False, f"DKIM TXT at {selector}._domainkey is missing the expected public key."
|
||||
|
||||
|
||||
def verify_dmarc(domain_name: str, expected_value: str) -> tuple[bool, str]:
|
||||
name = f"_dmarc.{domain_name}"
|
||||
records = [_normalize_txt(record) for record in _lookup_txt(name)]
|
||||
if not records:
|
||||
return False, f"No DMARC TXT record found at {name}."
|
||||
expected = _normalize_txt(expected_value)
|
||||
for record in records:
|
||||
if record == expected or record.startswith("v=dmarc1"):
|
||||
if "v=dmarc1" in record:
|
||||
return True, "DMARC record is present."
|
||||
return False, "DMARC TXT record is missing or incomplete."
|
||||
|
||||
|
||||
def verify_company_dns(
|
||||
*,
|
||||
domain_name: str,
|
||||
shared_mail_host: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str,
|
||||
platform_spf_include: str,
|
||||
) -> list[dict]:
|
||||
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||
checks = [
|
||||
("mx", *verify_mx(domain_name, shared_mail_host)),
|
||||
("spf", *verify_spf(domain_name, platform_spf_include)),
|
||||
(
|
||||
"platform_spf",
|
||||
*verify_platform_spf(platform_spf_include, shared_mail_host),
|
||||
),
|
||||
(
|
||||
"dkim",
|
||||
*verify_dkim(domain_name, dkim_selector, dkim_public_key),
|
||||
),
|
||||
("dmarc", *verify_dmarc(domain_name, dmarc_expected)),
|
||||
]
|
||||
return [
|
||||
{
|
||||
"purpose": purpose,
|
||||
"verified": verified,
|
||||
"message": message,
|
||||
}
|
||||
for purpose, verified, message in checks
|
||||
]
|
||||
380
backend/app/services/domain_provisioning.py
Normal file
380
backend/app/services/domain_provisioning.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import DomainDnsRecord, MailDomain, SmtpCredential
|
||||
|
||||
password_hasher = PasswordHash.recommended()
|
||||
domain_pattern = re.compile(
|
||||
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||
r"[a-z]{2,63}$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str:
|
||||
return value.strip().lower().removeprefix("@")
|
||||
|
||||
|
||||
def validate_domain(value: str) -> str:
|
||||
domain = normalize_domain(value)
|
||||
if not domain_pattern.fullmatch(domain):
|
||||
raise ValueError("Enter a valid domain name, such as example.com.")
|
||||
return domain
|
||||
|
||||
|
||||
def generate_dkim_keys() -> tuple[str, str]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_pem = (
|
||||
private_key.public_key()
|
||||
.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
.decode()
|
||||
)
|
||||
public_key = "".join(
|
||||
line
|
||||
for line in public_pem.splitlines()
|
||||
if not line.startswith("-----")
|
||||
)
|
||||
return private_pem, public_key
|
||||
|
||||
|
||||
def namecheap_host(host: str, domain_name: str) -> str:
|
||||
"""Strip the zone suffix so records match Namecheap's Host field."""
|
||||
if host in {"@", "*"}:
|
||||
return host
|
||||
suffix = f".{domain_name}"
|
||||
if host.endswith(suffix):
|
||||
return host[: -len(suffix)]
|
||||
return host
|
||||
|
||||
|
||||
def spf_ip_mechanism(mail_server_ip: str | None) -> str | None:
|
||||
if not mail_server_ip or mail_server_ip == "YOUR_MAIL_SERVER_IP":
|
||||
return None
|
||||
try:
|
||||
address = ipaddress.ip_address(mail_server_ip.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return f"ip6:{address}" if address.version == 6 else f"ip4:{address}"
|
||||
|
||||
|
||||
def build_spf_value(mail_server_ip: str | None = None) -> str:
|
||||
parts = ["v=spf1", "mx", "a"]
|
||||
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||
if ip_mechanism:
|
||||
parts.append(ip_mechanism)
|
||||
parts.append("~all")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_shared_mx_spf_value(platform_spf_include: str) -> str:
|
||||
return f"v=spf1 include:{platform_spf_include} ~all"
|
||||
|
||||
|
||||
def build_company_dns_records(
|
||||
domain_name: str,
|
||||
shared_mail_host: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str,
|
||||
platform_spf_include: str,
|
||||
) -> list[dict]:
|
||||
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
|
||||
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||
return [
|
||||
{
|
||||
"record_type": "MX",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": shared_mail_host,
|
||||
"priority": 10,
|
||||
"purpose": "mx",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": build_shared_mx_spf_value(platform_spf_include),
|
||||
"purpose": "spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||
"value": dkim_value,
|
||||
"purpose": "dkim",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_dmarc", domain_name),
|
||||
"value": dmarc_value,
|
||||
"purpose": "dmarc",
|
||||
"ttl": 3600,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_dns_records(
|
||||
domain_name: str,
|
||||
mail_host: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str,
|
||||
mail_server_ip: str | None = None,
|
||||
) -> list[dict]:
|
||||
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||
mail_subdomain = mail_host.removesuffix(f".{domain_name}")
|
||||
|
||||
return [
|
||||
{
|
||||
"record_type": "MX",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": mail_host,
|
||||
"priority": 10,
|
||||
"purpose": "mx",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "A",
|
||||
"host": namecheap_host(mail_subdomain, domain_name),
|
||||
"value": mail_server_ip or "YOUR_MAIL_SERVER_IP",
|
||||
"purpose": "mail_host",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": build_spf_value(mail_server_ip),
|
||||
"purpose": "spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||
"value": dkim_value,
|
||||
"purpose": "dkim",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_dmarc", domain_name),
|
||||
"value": dmarc_value,
|
||||
"purpose": "dmarc",
|
||||
"ttl": 3600,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_deployment_dns_records(
|
||||
*,
|
||||
domain_name: str,
|
||||
mail_server_ip: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str | None,
|
||||
app_public_host: str | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Split records into inbound delivery vs outbound authentication."""
|
||||
mail_host = f"mail.{domain_name}"
|
||||
dkim_key = dkim_public_key or (
|
||||
"REGISTER_DOMAIN_TO_GENERATE_DKIM_PUBLIC_KEY"
|
||||
)
|
||||
records = build_dns_records(
|
||||
domain_name,
|
||||
mail_host,
|
||||
dkim_selector,
|
||||
dkim_key,
|
||||
mail_server_ip=mail_server_ip,
|
||||
)
|
||||
by_purpose = {record["purpose"]: record for record in records}
|
||||
inbound = [by_purpose["mx"], by_purpose["mail_host"]]
|
||||
if app_public_host:
|
||||
inbound.append(
|
||||
{
|
||||
"record_type": "A",
|
||||
"host": namecheap_host(app_public_host, domain_name),
|
||||
"value": mail_server_ip,
|
||||
"purpose": "app_host",
|
||||
"ttl": 3600,
|
||||
}
|
||||
)
|
||||
outbound = [by_purpose["spf"], by_purpose["dkim"], by_purpose["dmarc"]]
|
||||
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||
if ip_mechanism:
|
||||
outbound.insert(
|
||||
0,
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_spf", domain_name),
|
||||
"value": f"v=spf1 {ip_mechanism} ~all",
|
||||
"purpose": "platform_spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
)
|
||||
return {"inbound": inbound, "outbound": outbound}
|
||||
|
||||
|
||||
def generate_smtp_username(domain_name: str) -> str:
|
||||
slug = domain_name.replace(".", "-")
|
||||
token = secrets.token_hex(4)
|
||||
return f"smtp-{slug}-{token}"
|
||||
|
||||
|
||||
def generate_smtp_password(length: int = 24) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def record_for_db(record: dict) -> dict:
|
||||
allowed = {"record_type", "host", "value", "purpose", "ttl"}
|
||||
return {key: record[key] for key in allowed if key in record}
|
||||
|
||||
|
||||
async def get_domain_by_name(
|
||||
session: AsyncSession,
|
||||
domain_name: str,
|
||||
) -> MailDomain | None:
|
||||
return await session.scalar(
|
||||
select(MailDomain).where(MailDomain.domain_name == domain_name)
|
||||
)
|
||||
|
||||
|
||||
async def provision_mail_domain(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
domain_name: str,
|
||||
domain_type: str,
|
||||
account_id=None,
|
||||
) -> MailDomain:
|
||||
settings = get_settings()
|
||||
normalized = validate_domain(domain_name)
|
||||
existing = await get_domain_by_name(session, normalized)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
mail_host = f"mail.{normalized}"
|
||||
dkim_selector = settings.dkim_selector
|
||||
private_key, public_key = generate_dkim_keys()
|
||||
if domain_type == "company":
|
||||
mail_host = settings.smtp_public_host
|
||||
dns_records = build_company_dns_records(
|
||||
normalized,
|
||||
settings.smtp_public_host,
|
||||
dkim_selector,
|
||||
public_key,
|
||||
settings.platform_spf_include,
|
||||
)
|
||||
else:
|
||||
dns_records = build_dns_records(
|
||||
normalized,
|
||||
mail_host,
|
||||
dkim_selector,
|
||||
public_key,
|
||||
)
|
||||
domain = MailDomain(
|
||||
domain_name=normalized,
|
||||
domain_type=domain_type,
|
||||
account_id=account_id,
|
||||
mail_host=mail_host,
|
||||
dkim_selector=dkim_selector,
|
||||
dkim_private_key=private_key,
|
||||
dkim_public_key=public_key,
|
||||
status="pending_dns",
|
||||
)
|
||||
session.add(domain)
|
||||
await session.flush()
|
||||
|
||||
for record in dns_records:
|
||||
session.add(DomainDnsRecord(domain_id=domain.id, **record_for_db(record)))
|
||||
|
||||
await session.flush()
|
||||
return domain
|
||||
|
||||
|
||||
def register_relay_domain(domain_name: str) -> None:
|
||||
"""Append a verified company domain for Postfix inbound relay."""
|
||||
settings = get_settings()
|
||||
normalized = validate_domain(domain_name)
|
||||
path = Path(settings.relay_domains_file)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = {
|
||||
line.strip().lower()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
} if path.exists() else set()
|
||||
if normalized in existing:
|
||||
return
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{normalized}\n")
|
||||
|
||||
|
||||
async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> MailDomain:
|
||||
domain.status = "active"
|
||||
register_relay_domain(domain.domain_name)
|
||||
await session.flush()
|
||||
return domain
|
||||
|
||||
|
||||
async def get_account_company_domain(
|
||||
session: AsyncSession,
|
||||
account_id,
|
||||
company_domain_name: str | None,
|
||||
) -> MailDomain | None:
|
||||
if not company_domain_name:
|
||||
return None
|
||||
return await session.scalar(
|
||||
select(MailDomain)
|
||||
.options(selectinload(MailDomain.dns_records))
|
||||
.where(
|
||||
MailDomain.domain_name == company_domain_name,
|
||||
MailDomain.account_id == account_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def create_smtp_credential(
|
||||
session: AsyncSession,
|
||||
domain: MailDomain,
|
||||
*,
|
||||
label: str,
|
||||
) -> tuple[SmtpCredential, str]:
|
||||
username = generate_smtp_username(domain.domain_name)
|
||||
plain_password = generate_smtp_password()
|
||||
credential = SmtpCredential(
|
||||
domain_id=domain.id,
|
||||
username=username,
|
||||
password_hash=password_hasher.hash(plain_password),
|
||||
label=label,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(credential)
|
||||
await session.flush()
|
||||
return credential, plain_password
|
||||
|
||||
|
||||
async def ensure_platform_domain(session: AsyncSession) -> MailDomain:
|
||||
settings = get_settings()
|
||||
domain = await get_domain_by_name(session, settings.platform_domain)
|
||||
if domain:
|
||||
return domain
|
||||
return await provision_mail_domain(
|
||||
session,
|
||||
domain_name=settings.platform_domain,
|
||||
domain_type="platform",
|
||||
)
|
||||
159
backend/app/services/inbound_smtp.py
Normal file
159
backend/app/services/inbound_smtp.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiosmtpd.smtp import SMTP
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import SessionFactory, mongo_db
|
||||
from app.models import Account
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def peer_is_allowed(session) -> bool:
|
||||
if session is None or not getattr(session, "peer", None):
|
||||
return True
|
||||
try:
|
||||
peer = ipaddress.ip_address(session.peer[0])
|
||||
return any(
|
||||
peer in ipaddress.ip_network(cidr)
|
||||
for cidr in get_settings().inbound_allowed_cidrs
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class InboundHandler:
|
||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||
if not peer_is_allowed(session):
|
||||
logger.warning("Rejected inbound SMTP peer %s", session.peer[0])
|
||||
return "550 5.7.1 Delivery from this host is not authorized"
|
||||
normalized = address.strip().lower()
|
||||
async with SessionFactory() as database_session:
|
||||
account = await database_session.scalar(
|
||||
select(Account).where(func.lower(Account.email) == normalized)
|
||||
)
|
||||
if account is None:
|
||||
return "550 5.1.1 Recipient address rejected: user unknown"
|
||||
envelope.rcpt_tos.append(normalized)
|
||||
envelope.rcpt_options.extend(rcpt_options)
|
||||
if not hasattr(envelope, "account_ids"):
|
||||
envelope.account_ids = {}
|
||||
envelope.account_ids[normalized] = str(account.id)
|
||||
return "250 2.1.5 Recipient OK"
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
settings = get_settings()
|
||||
raw = envelope.original_content
|
||||
if len(raw) > settings.inbound_max_bytes:
|
||||
return "552 5.3.4 Message size exceeds fixed maximum message size"
|
||||
recipients = list(dict.fromkeys(address.lower() for address in envelope.rcpt_tos))
|
||||
if not recipients:
|
||||
return "554 5.5.1 No valid recipients"
|
||||
|
||||
delivery_key = delivery_digest(raw, recipients)
|
||||
try:
|
||||
tombstones = getattr(mongo_db, "message_tombstones", None)
|
||||
if tombstones is not None and await tombstones.find_one(
|
||||
{"delivery_key": delivery_key},
|
||||
{"_id": 1},
|
||||
):
|
||||
return f"250 2.0.0 Message previously accepted as {delivery_key}"
|
||||
parsed = parse_mime(raw)
|
||||
body_text = parsed.pop("body_text")
|
||||
body_html = parsed.pop("body_html")
|
||||
stored = await get_raw_mime_storage().put(raw, delivery_key)
|
||||
now = datetime.now(timezone.utc)
|
||||
message = {
|
||||
**parsed,
|
||||
"direction": "inbound",
|
||||
"envelope_sender": envelope.mail_from.lower(),
|
||||
"envelope_recipients": recipients,
|
||||
"delivery_key": delivery_key,
|
||||
"raw_mime": {
|
||||
"bucket": stored.bucket,
|
||||
"name": stored.name,
|
||||
"generation": stored.generation,
|
||||
"size": stored.size,
|
||||
"crc32c": stored.crc32c,
|
||||
},
|
||||
"received_at": now,
|
||||
"created_at": now,
|
||||
"has_text_body": bool(body_text),
|
||||
"has_html_body": bool(body_html),
|
||||
}
|
||||
await mongo_db.messages.update_one(
|
||||
{"delivery_key": delivery_key},
|
||||
{"$setOnInsert": message},
|
||||
upsert=True,
|
||||
)
|
||||
persisted = await mongo_db.messages.find_one(
|
||||
{"delivery_key": delivery_key},
|
||||
{"_id": 1},
|
||||
)
|
||||
if persisted is None:
|
||||
raise RuntimeError("Inbound message was not persisted.")
|
||||
for recipient in recipients:
|
||||
account_id = envelope.account_ids[recipient]
|
||||
await mongo_db.mailbox_entries.update_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"message_id": persisted["_id"],
|
||||
"mailbox": "inbox",
|
||||
},
|
||||
{
|
||||
"$setOnInsert": {
|
||||
"account_id": account_id,
|
||||
"message_id": persisted["_id"],
|
||||
"mailbox": "inbox",
|
||||
"direction": "inbound",
|
||||
"sender": parsed["sender"],
|
||||
"recipients": parsed["to"],
|
||||
"subject": parsed["subject"],
|
||||
"snippet": parsed["snippet"],
|
||||
"attachments": parsed["attachments"],
|
||||
"status": "received",
|
||||
"is_read": False,
|
||||
"is_starred": False,
|
||||
"event_at": now,
|
||||
"created_at": now,
|
||||
"trashed_at": None,
|
||||
"deletion_state": None,
|
||||
}
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Temporary failure accepting inbound message")
|
||||
return "451 4.3.0 Temporary server failure; please retry"
|
||||
return f"250 2.0.0 Message accepted as {delivery_key}"
|
||||
|
||||
|
||||
class InboundSMTPServer:
|
||||
def __init__(self):
|
||||
self.server: asyncio.Server | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
settings = get_settings()
|
||||
loop = asyncio.get_running_loop()
|
||||
self.server = await loop.create_server(
|
||||
lambda: SMTP(
|
||||
InboundHandler(),
|
||||
data_size_limit=settings.inbound_max_bytes,
|
||||
enable_SMTPUTF8=True,
|
||||
decode_data=False,
|
||||
),
|
||||
host=settings.inbound_smtp_host,
|
||||
port=settings.inbound_smtp_port,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.server is not None:
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
self.server = None
|
||||
107
backend/app/services/mail_sender.py
Normal file
107
backend/app/services/mail_sender.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import email.policy
|
||||
import email.utils
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
import dkim
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import MailDomain
|
||||
|
||||
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
|
||||
|
||||
|
||||
def build_message(
|
||||
*,
|
||||
sender: str,
|
||||
recipients: list[str],
|
||||
cc: list[str],
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: str | None,
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage(policy=SMTP_POLICY)
|
||||
message["From"] = sender
|
||||
message["To"] = ", ".join(recipients)
|
||||
if cc:
|
||||
message["Cc"] = ", ".join(cc)
|
||||
message["Subject"] = subject
|
||||
message["Date"] = email.utils.formatdate(localtime=True)
|
||||
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||
message.set_content(body_text)
|
||||
if body_html:
|
||||
message.add_alternative(body_html, subtype="html")
|
||||
return message
|
||||
|
||||
|
||||
def sign_message(message: EmailMessage, domain: MailDomain) -> bytes:
|
||||
raw_message = message.as_bytes(policy=SMTP_POLICY)
|
||||
signed_headers = [
|
||||
b"from",
|
||||
b"to",
|
||||
b"subject",
|
||||
b"date",
|
||||
b"message-id",
|
||||
b"mime-version",
|
||||
b"content-type",
|
||||
]
|
||||
if message.get("Cc"):
|
||||
signed_headers.insert(3, b"cc")
|
||||
signature = dkim.sign(
|
||||
raw_message,
|
||||
domain.dkim_selector.encode(),
|
||||
domain.domain_name.encode(),
|
||||
domain.dkim_private_key.encode(),
|
||||
include_headers=signed_headers,
|
||||
canonicalize=(b"relaxed", b"relaxed"),
|
||||
)
|
||||
if not signature.endswith(b"\r\n"):
|
||||
signature += b"\r\n"
|
||||
return signature + raw_message
|
||||
|
||||
|
||||
async def send_via_smtp(
|
||||
message_bytes: bytes,
|
||||
*,
|
||||
sender: str,
|
||||
recipients: list[str],
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
await aiosmtplib.send(
|
||||
message_bytes,
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username or None,
|
||||
password=settings.smtp_password or None,
|
||||
start_tls=settings.smtp_use_tls,
|
||||
timeout=settings.smtp_timeout,
|
||||
)
|
||||
|
||||
|
||||
async def deliver_email(
|
||||
*,
|
||||
sender: str,
|
||||
recipients: list[str],
|
||||
cc: list[str],
|
||||
bcc: list[str],
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: str | None,
|
||||
domain: MailDomain,
|
||||
) -> None:
|
||||
message = build_message(
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
signed = sign_message(message, domain)
|
||||
await send_via_smtp(
|
||||
signed,
|
||||
sender=sender,
|
||||
recipients=[*recipients, *cc, *bcc],
|
||||
)
|
||||
90
backend/app/services/mime_parser.py
Normal file
90
backend/app/services/mime_parser.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import re
|
||||
from datetime import timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from email.utils import getaddresses, parsedate_to_datetime
|
||||
from html import unescape
|
||||
|
||||
|
||||
def _addresses(values: list[str]) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"name": name.strip() or address.split("@", 1)[0],
|
||||
"address": address.lower(),
|
||||
}
|
||||
for name, address in getaddresses(values)
|
||||
if address
|
||||
]
|
||||
|
||||
|
||||
def _text(part) -> str:
|
||||
try:
|
||||
value = part.get_content()
|
||||
except (LookupError, UnicodeError):
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
value = payload.decode("utf-8", errors="replace")
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _snippet(text: str, html: str | None, limit: int = 240) -> str:
|
||||
source = text
|
||||
if not source and html:
|
||||
source = re.sub(r"<[^>]+>", " ", html)
|
||||
source = unescape(source)
|
||||
return re.sub(r"\s+", " ", source).strip()[:limit]
|
||||
|
||||
|
||||
def parse_mime(raw: bytes) -> dict:
|
||||
message = BytesParser(policy=policy.default).parsebytes(raw)
|
||||
sender = _addresses(message.get_all("from", []))
|
||||
from_value = sender[0] if sender else {"name": "Unknown sender", "address": ""}
|
||||
text_parts: list[str] = []
|
||||
html_parts: list[str] = []
|
||||
attachments: list[dict] = []
|
||||
|
||||
for part in message.walk():
|
||||
if part.is_multipart():
|
||||
continue
|
||||
disposition = part.get_content_disposition()
|
||||
filename = part.get_filename()
|
||||
content_type = part.get_content_type()
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
if disposition == "attachment" or filename:
|
||||
attachments.append(
|
||||
{
|
||||
"filename": filename or "attachment",
|
||||
"content_type": content_type,
|
||||
"size": len(payload),
|
||||
"content_id": (part.get("Content-ID") or "").strip("<>") or None,
|
||||
"disposition": disposition or "attachment",
|
||||
}
|
||||
)
|
||||
elif content_type == "text/plain":
|
||||
text_parts.append(_text(part))
|
||||
elif content_type == "text/html":
|
||||
html_parts.append(_text(part))
|
||||
|
||||
date = None
|
||||
if message.get("Date"):
|
||||
try:
|
||||
date = parsedate_to_datetime(message["Date"])
|
||||
if date.tzinfo is None:
|
||||
date = date.replace(tzinfo=timezone.utc)
|
||||
date = date.astimezone(timezone.utc)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
body_text = "\n".join(text_parts).strip()
|
||||
body_html = "\n".join(html_parts).strip() or None
|
||||
return {
|
||||
"message_id": (message.get("Message-ID") or "").strip() or None,
|
||||
"sender": from_value,
|
||||
"to": _addresses(message.get_all("to", [])),
|
||||
"cc": _addresses(message.get_all("cc", [])),
|
||||
"reply_to": _addresses(message.get_all("reply-to", [])),
|
||||
"subject": str(message.get("Subject") or ""),
|
||||
"sent_at": date,
|
||||
"body_text": body_text,
|
||||
"body_html": body_html,
|
||||
"snippet": _snippet(body_text, body_html),
|
||||
"attachments": attachments,
|
||||
}
|
||||
101
backend/app/services/raw_mime_storage.py
Normal file
101
backend/app/services/raw_mime_storage.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
import google_crc32c
|
||||
from google.api_core.exceptions import NotFound, PreconditionFailed
|
||||
from google.cloud import storage
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredMime:
|
||||
bucket: str
|
||||
name: str
|
||||
generation: int
|
||||
size: int
|
||||
crc32c: str
|
||||
|
||||
|
||||
class RawMimeStorage:
|
||||
def __init__(self, bucket: str, prefix: str = "raw", project: str | None = None):
|
||||
self.bucket_name = bucket
|
||||
self.prefix = prefix.strip("/")
|
||||
self.client = storage.Client(project=project)
|
||||
|
||||
async def put(self, raw: bytes, delivery_key: str) -> StoredMime:
|
||||
return await asyncio.to_thread(self._put, raw, delivery_key)
|
||||
|
||||
def _put(self, raw: bytes, delivery_key: str) -> StoredMime:
|
||||
name = f"{self.prefix}/{delivery_key[:2]}/{delivery_key}.eml"
|
||||
blob = self.client.bucket(self.bucket_name).blob(name)
|
||||
checksum = google_crc32c.Checksum(raw).hexdigest().decode("ascii")
|
||||
try:
|
||||
blob.upload_from_string(
|
||||
raw,
|
||||
content_type="message/rfc822",
|
||||
checksum="crc32c",
|
||||
if_generation_match=0,
|
||||
)
|
||||
blob.reload()
|
||||
except PreconditionFailed:
|
||||
blob.reload()
|
||||
return StoredMime(
|
||||
bucket=self.bucket_name,
|
||||
name=name,
|
||||
generation=int(blob.generation),
|
||||
size=len(raw),
|
||||
crc32c=checksum,
|
||||
)
|
||||
|
||||
async def get(self, reference: dict) -> bytes:
|
||||
return await asyncio.to_thread(self._get, reference)
|
||||
|
||||
def _get(self, reference: dict) -> bytes:
|
||||
generation = int(reference["generation"])
|
||||
blob = self.client.bucket(reference["bucket"]).blob(
|
||||
reference["name"],
|
||||
generation=generation,
|
||||
)
|
||||
return blob.download_as_bytes(if_generation_match=generation)
|
||||
|
||||
async def delete(self, reference: dict) -> None:
|
||||
await asyncio.to_thread(self._delete, reference)
|
||||
|
||||
def _delete(self, reference: dict) -> None:
|
||||
generation = int(reference["generation"])
|
||||
blob = self.client.bucket(reference["bucket"]).blob(
|
||||
reference["name"],
|
||||
generation=generation,
|
||||
)
|
||||
try:
|
||||
blob.delete(if_generation_match=generation)
|
||||
except NotFound:
|
||||
# A prior delete may have removed GCS before Mongo acknowledged it.
|
||||
pass
|
||||
|
||||
|
||||
def delivery_digest(raw: bytes, recipients: list[str]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
digest.update(raw)
|
||||
digest.update(b"\0")
|
||||
digest.update("\0".join(sorted(set(recipients))).encode())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
_storage: RawMimeStorage | None = None
|
||||
|
||||
|
||||
def get_raw_mime_storage() -> RawMimeStorage:
|
||||
global _storage
|
||||
if _storage is None:
|
||||
settings = get_settings()
|
||||
if not settings.gcs_raw_mime_bucket:
|
||||
raise RuntimeError("GCS_RAW_MIME_BUCKET is required for inbound mail.")
|
||||
_storage = RawMimeStorage(
|
||||
settings.gcs_raw_mime_bucket,
|
||||
settings.gcs_raw_mime_prefix,
|
||||
settings.gcs_project,
|
||||
)
|
||||
return _storage
|
||||
Loading…
Add table
Add a link
Reference in a new issue