Files
upstream-zulip/zerver/lib/email_mirror_server.py
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

276 lines
10 KiB
Python

import asyncio
import base64
import email
import grp
import logging
import os
import pwd
import signal
import smtplib
import socket
from collections.abc import Awaitable
from contextlib import suppress
from ssl import SSLContext, SSLError
from typing import Any
from urllib.parse import SplitResult
from aiosmtpd.controller import UnthreadedController
from aiosmtpd.handlers import Message as MessageHandler
from aiosmtpd.smtp import SMTP, Envelope, Session, TLSSetupException
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.mail import EmailMultiAlternatives as DjangoEmailMultiAlternatives
from typing_extensions import override
from version import ZULIP_VERSION
from zerver.lib.email_mirror import decode_stream_email_address, validate_to_address
from zerver.lib.email_mirror_helpers import (
ZulipEmailForwardError,
get_email_gateway_message_string_from_address,
)
from zerver.lib.exceptions import JsonableError, RateLimitedError
from zerver.lib.logging_util import log_to_file
from zerver.lib.queue import queue_json_publish_rollback_unsafe
# We add a file handler to this later, once we've dropped privileges
logger = logging.getLogger("zerver.lib.email_mirror")
def send_to_postmaster(msg: email.message.Message) -> None:
# RFC5321 says:
# Any system that includes an SMTP server supporting mail relaying or
# delivery MUST support the reserved mailbox "postmaster" as a case-
# insensitive local name. This postmaster address is not strictly
# necessary if the server always returns 554 on connection opening (as
# described in Section 3.1). The requirement to accept mail for
# postmaster implies that RCPT commands that specify a mailbox for
# postmaster at any of the domains for which the SMTP server provides
# mail service, as well as the special case of "RCPT TO:<Postmaster>"
# (with no domain specification), MUST be supported.
#
# We forward such mail to the ZULIP_ADMINISTRATOR.
mail = DjangoEmailMultiAlternatives(
subject=f"Mail to postmaster: {msg['Subject']}",
from_email=settings.NOREPLY_EMAIL_ADDRESS,
to=[settings.ZULIP_ADMINISTRATOR],
)
mail.attach(None, msg, "message/rfc822")
try:
mail.send()
except smtplib.SMTPResponseException as e:
logger.exception(
"Error sending bounce email to %s with error code %s: %s",
mail.to,
e.smtp_code,
e.smtp_error,
stack_info=True,
)
except smtplib.SMTPException:
logger.exception("Error sending bounce email to %s", mail.to, stack_info=True)
class ZulipMessageHandler(MessageHandler):
def __init__(self) -> None:
super().__init__(email.message.Message)
async def handle_RCPT(
self,
server: SMTP,
session: Session,
envelope: Envelope,
address: str,
rcpt_options: list[str],
) -> str:
# Rewrite all postmaster email addresses to just "postmaster"
if address.lower() == "postmaster":
envelope.rcpt_tos.append("postmaster")
return "250 Continue"
with suppress(ZulipEmailForwardError):
if get_email_gateway_message_string_from_address(address).lower() == "postmaster":
envelope.rcpt_tos.append("postmaster")
return "250 Continue"
try:
# Attempt to do the ACL-check now, just to reject early.
# The authoritative check is done in the worker, but we
# wish to reject now if we can do so simply.
await sync_to_async(validate_to_address)(address)
except RateLimitedError:
recipient_realm = await sync_to_async(
lambda a: decode_stream_email_address(a)[0].realm
)(address)
logger.info(
"Rejecting a MAIL FROM: %s to realm: %s via %s - rate limited.",
envelope.mail_from,
recipient_realm.string_id,
str(session.peer),
)
return "550 4.7.0 Rate-limited due to too many emails on this realm."
except ZulipEmailForwardError as e:
return f"550 5.1.1 Bad destination mailbox address: {e}"
except JsonableError as e:
return f"550 5.7.1 Permission denied: {e}"
envelope.rcpt_tos.append(address)
return "250 Continue"
@override
def handle_message(self, message: email.message.Message) -> None:
msg_base64 = base64.b64encode(bytes(message))
logger.info(
"Received email to %s, from %s via %s",
message["X-RcptTo"],
message["X-MailFrom"],
message["X-Peer"],
)
for address in message["X-RcptTo"].split(", "):
if address == "postmaster":
send_to_postmaster(message)
else:
queue_json_publish_rollback_unsafe(
"email_mirror",
{
"rcpt_to": address,
"msg_base64": msg_base64.decode(),
},
)
async def handle_exception(self, error: Exception) -> str:
if isinstance(error, TLSSetupException): # nocoverage
if isinstance(error.__cause__, SSLError):
reason = error.__cause__.reason
else:
reason = str(error.__cause__)
logger.debug("Dropping invalid TLS connection: %s", reason)
return f"421 4.7.6 TLS error: {reason}"
else:
logger.error("SMTP session exception", exc_info=error)
return "500 Server error"
def parse_listen_address(listen: str) -> tuple[str, int]:
host: str | None
port: int | None
if listen.isdigit():
host, port = None, int(listen)
else:
r = SplitResult("", listen, "", "", "")
try:
port = r.port
except ValueError as e:
# An unbracketed IPv6 address is ambiguous with
# address:port, and parses as a host and a non-numeric
# port.
raise RuntimeError(
f"{listen!r} is not a valid port, or address:port; IPv6 addresses "
"must be enclosed in square brackets."
) from e
if port is None:
raise RuntimeError(f"{listen!r} does not have a valid port number.")
host = r.hostname
if host is None:
# With no address specified, bind the IPv6 wildcard address if
# IPv6 is available, since (with IPV6_V6ONLY disabled, as
# _create_server does) that accepts IPv4 connections as well.
host = "::" if socket.has_dualstack_ipv6() else "0.0.0.0" # noqa: S104
return host, port
class PermissionDroppingUnthreadedController(UnthreadedController): # nocoverage
@override
def __init__(
self,
user: str | None = None,
group: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
# These may remain None in development, when elevated
# privileges are not needed because a non-low port is chosen.
self.user_id: int | None = None
self.group_id: int | None = None
if user is not None:
self.user_id = pwd.getpwnam(user).pw_uid
if group is not None:
self.group_id = grp.getgrnam(group).gr_gid
@override
def _create_server(self) -> Awaitable[asyncio.AbstractServer]:
# Make the listen socket, then drop privileges before starting
# the server
family, type_, proto, _, sockaddr = socket.getaddrinfo(
self.hostname,
self.port,
type=socket.SOCK_STREAM,
flags=socket.AI_PASSIVE,
)[0]
server_socket = socket.socket(family, type_, proto)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if family == socket.AF_INET6:
# Also accept IPv4 connections when binding the IPv6
# wildcard address; this has no effect when binding a
# specific address.
server_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
server_socket.bind(sockaddr)
# Log the address which was actually bound; if the requested
# address was a hostname with more than one address, only the
# first one that the resolver returned is used.
bound_host, bound_port = server_socket.getsockname()[:2]
if family == socket.AF_INET6:
bound_host = f"[{bound_host}]"
logger.info("Listening on %s:%d", bound_host, bound_port)
if os.geteuid() == 0:
assert self.user_id is not None
assert self.group_id is not None
# We may have a logfile owned by root, from before we
# fixed it to be owned by zulip; chown it if it exists, so
# we don't fail below.
if os.path.exists(settings.EMAIL_MIRROR_LOG_PATH):
os.chown(settings.EMAIL_MIRROR_LOG_PATH, self.user_id, self.group_id)
logger.info("Dropping privileges to uid %d / gid %d", self.user_id, self.group_id)
os.setgid(self.group_id)
os.setuid(self.user_id)
log_to_file(logger, settings.EMAIL_MIRROR_LOG_PATH)
server = self.loop.create_server(
self._factory_invoker,
sock=server_socket,
ssl=self.ssl_context,
)
return server
def run_smtp_server(
user: str | None, group: str | None, host: str, port: int, tls_context: SSLContext | None
) -> None: # nocoverage
logger.info("Starting SMTP server on %s:%d", f"[{host}]" if ":" in host else host, port)
server = PermissionDroppingUnthreadedController(
user=user,
group=group,
hostname=host,
port=port,
handler=ZulipMessageHandler(),
tls_context=tls_context,
ident=f"Zulip Server {ZULIP_VERSION}",
)
server.loop.add_signal_handler(signal.SIGINT, server.loop.stop)
server.begin()
with suppress(KeyboardInterrupt):
# The KeyboardInterrupt will exit the loop, but there's no
# reason to throw a stacktrace rather than conduct an ordered
# exit.
server.loop.run_forever()
server.end()