mirror of
https://github.com/zulip/zulip.git
synced 2025-10-25 17:14:02 +00:00
Using postfix to handle the incoming email gateway complicates things a great deal: - It cannot verify that incoming email addresses exist in Zulip before accepting them; it thus accepts mail at the `RCPT TO` stage which it cannot handle, and thus must reject after the `DATA`. - It is built to handle both incoming and outgoing email, which results in subtle errors (1c17583ad5,79931051bd,a53092687e, #18600). - Rate-limiting happens much too late to avoid denial of service (#12501). - Mis-configurations of the HTTP endpoint can break incoming mail (#18105). Provide a replacement SMTP server which accepts incoming email on port 25, verifies that Zulip can accept the address, and that no rate-limits are being broken, and then adds it directly to the relevant queue. Removes an incorrect comment which implied that missed-message addresses were only usable once. We leave rate-limiting to only channel email addresses, since missed-message addresses are unlikely to be placed into automated systems, as channel email addresses are. Also simplifies #7814 somewhat.
217 lines
7.7 KiB
Python
217 lines
7.7 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 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,
|
|
is_missed_message_address,
|
|
rate_limit_mirror_by_realm,
|
|
validate_to_address,
|
|
)
|
|
from zerver.lib.email_mirror_helpers import (
|
|
ZulipEmailForwardError,
|
|
get_email_gateway_message_string_from_address,
|
|
)
|
|
from zerver.lib.exceptions import RateLimitedError
|
|
from zerver.lib.logging_util import log_to_file
|
|
from zerver.lib.queue import queue_json_publish_rollback_unsafe
|
|
|
|
logger = logging.getLogger("zerver.lib.email_mirror")
|
|
log_to_file(logger, settings.EMAIL_MIRROR_LOG_PATH)
|
|
|
|
|
|
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 as e:
|
|
logger.exception("Error sending bounce email to %s: %s", mail.to, str(e), 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:
|
|
await sync_to_async(validate_to_address)(address)
|
|
if not is_missed_message_address(address):
|
|
# Only channel email addresses are rate-limited, since
|
|
# they are likely to be used as the destination for
|
|
# mails from automated systems.
|
|
recipient_realm = await sync_to_async(
|
|
lambda a: decode_stream_email_address(a)[0].realm
|
|
)(address)
|
|
try:
|
|
rate_limit_mirror_by_realm(recipient_realm)
|
|
except RateLimitedError:
|
|
logger.warning(
|
|
"Rejecting a MAIL FROM: %s to realm: %s - rate limited.",
|
|
envelope.mail_from,
|
|
recipient_realm.name,
|
|
)
|
|
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}"
|
|
|
|
envelope.rcpt_tos.append(address)
|
|
return "250 Continue"
|
|
|
|
@override
|
|
def handle_message(self, message: email.message.Message) -> None:
|
|
msg_base64 = base64.b64encode(bytes(message))
|
|
|
|
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) and isinstance(
|
|
error.__cause__, SSLError
|
|
): # nocoverage
|
|
logger.info("Dropping invalid TLS connection: %s", error.__cause__.reason)
|
|
# The client probably never sees this error code, but for completeness:
|
|
return f"421 4.7.6 TLS error: {error.__cause__.reason}"
|
|
else:
|
|
logger.exception("SMTP session exception")
|
|
return "500 Server error"
|
|
|
|
|
|
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
|
|
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server_socket.bind((self.hostname, self.port))
|
|
if os.geteuid() == 0:
|
|
assert self.user_id is not None
|
|
assert self.group_id is not None
|
|
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)
|
|
|
|
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("Listening on %s:%d", 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()
|