first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue