Files
zulip/manage.py
Alex Vandiver 1f0cfd4662 email-mirror: Add a standalone server that processes incoming email.
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.
2025-05-19 16:39:44 -07:00

159 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
import configparser
import os
import sys
from collections import defaultdict
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(BASE_DIR)
from scripts.lib.setup_path import setup_path
setup_path()
from django.core.management import ManagementUtility, get_commands
from django.core.management.color import color_style
from typing_extensions import override
from scripts.lib.zulip_tools import assert_not_running_as_root
def get_filtered_commands() -> dict[str, str]:
"""Because Zulip uses management commands in production, `manage.py
help` is a form of documentation for users. Here we exclude from
that documentation built-in commands that are not constructive for
end users or even Zulip developers to run.
Ideally, we'd do further customization to display management
commands with more organization in the help text, and also hide
development-focused management commands in production.
"""
all_commands = get_commands()
documented_commands = dict()
documented_apps = [
# "auth" removed because its commands are not applicable to Zulip.
# "contenttypes" removed because we don't use that subsystem, and
# even if we did.
"django.core",
"analytics",
# "otp_static" removed because it's a 2FA internals detail.
# "sessions" removed because it's just a cron job with a misleading
# name, since all it does is delete expired sessions.
# "social_django" removed for similar reasons to sessions.
# "staticfiles" removed because its commands are only usefully run when
# wrapped by Zulip tooling.
# "two_factor" removed because it's a 2FA internals detail.
"zerver",
"zilencer",
]
documented_command_subsets = {
"django.core": {
"changepassword",
"dbshell",
"makemigrations",
"migrate",
"shell",
"showmigrations",
},
}
for command, app in all_commands.items():
if app not in documented_apps:
continue
if app in documented_command_subsets and command not in documented_command_subsets[app]:
continue
documented_commands[command] = app
return documented_commands
class FilteredManagementUtility(ManagementUtility):
"""Replaces the main_help_text function of ManagementUtility with one
that calls our get_filtered_commands(), rather than the default
get_commands() function.
All other change are just code style differences to pass the Zulip linter.
"""
@override
def main_help_text(self, commands_only: bool = False) -> str:
"""Return the script's main help text, as a string."""
if commands_only:
usage = sorted(get_filtered_commands())
else:
usage = [
"",
f"Type '{self.prog_name} help <subcommand>' for help on a specific subcommand.",
"",
"Available subcommands:",
]
commands_dict = defaultdict(list)
for name, app in get_filtered_commands().items():
if app == "django.core":
app = "django"
else:
app = app.rpartition(".")[-1]
commands_dict[app].append(name)
style = color_style()
for app in sorted(commands_dict):
usage.append("")
usage.append(style.NOTICE(f"[{app}]"))
usage.extend(f" {name}" for name in sorted(commands_dict[app]))
# Output an extra note if settings are not properly configured
if self.settings_exception is not None:
usage.append(
style.NOTICE(
"Note that only Django core commands are listed "
f"as settings are not properly configured (error: {self.settings_exception})."
)
)
return "\n".join(usage)
def execute_from_command_line(argv: list[str] | None = None) -> None:
"""Run a FilteredManagementUtility."""
utility = FilteredManagementUtility(argv)
utility.execute()
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "email_server":
# This needs to be able to run as root to read certificates
# for STARTTLS, and bind to port 25.
pass
else:
assert_not_running_as_root()
config_file = configparser.RawConfigParser()
config_file.read("/etc/zulip/zulip.conf")
PRODUCTION = config_file.has_option("machine", "deploy_type")
HAS_SECRETS = os.access("/etc/zulip/zulip-secrets.conf", os.R_OK)
if PRODUCTION and not HAS_SECRETS:
# The best way to detect running manage.py as another user in
# production before importing anything that would require that
# access is to check for access to /etc/zulip/zulip.conf (in
# which case it's a production server, not a dev environment)
# and lack of access for /etc/zulip/zulip-secrets.conf (which
# should be only readable by root and zulip)
print(
"Error accessing Zulip secrets; manage.py in production must be run as the zulip user."
)
sys.exit(1)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zproject.settings")
from django.conf import settings
from django.core.management.base import CommandError
from scripts.lib.zulip_tools import log_management_command
log_management_command(sys.argv, settings.MANAGEMENT_LOG_PATH)
os.environ.setdefault("PYTHONSTARTUP", os.path.join(BASE_DIR, "scripts/lib/pythonrc.py"))
if "--no-traceback" not in sys.argv and len(sys.argv) > 1:
sys.argv.append("--traceback")
try:
execute_from_command_line(sys.argv)
except CommandError as e:
print(e, file=sys.stderr)
sys.exit(1)