Files
redmine/reset_helpdesk_mail_settings.py
T
2026-05-04 09:49:47 -04:00

288 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Reset RedmineUP Helpdesk mail settings on the LAN test Redmine instance.
This is intended to be run after importing a production database into the test
instance. It rewrites every active project's incoming/outgoing Helpdesk mail
settings so test mail flows through Mailpit and imported real credentials cannot
be used accidentally.
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
DEFAULT_SSH_KEY = Path("/home/iadnah/reddev")
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
DEFAULT_MAILPIT_HOST = "192.168.1.105"
MAIL_SETTING_NAMES = [
"helpdesk_protocol",
"helpdesk_host",
"helpdesk_port",
"helpdesk_username",
"helpdesk_password",
"helpdesk_use_ssl",
"helpdesk_imap_folder",
"helpdesk_move_on_success",
"helpdesk_move_on_failure",
"helpdesk_apop",
"helpdesk_delete_unprocessed",
"helpdesk_smtp_use_default_settings",
"helpdesk_smtp_server",
"helpdesk_smtp_port",
"helpdesk_smtp_domain",
"helpdesk_smtp_authentication",
"helpdesk_smtp_username",
"helpdesk_smtp_password",
"helpdesk_smtp_ssl",
"helpdesk_smtp_tls",
"helpdesk_answer_from",
]
SECRET_NAMES = {"helpdesk_password", "helpdesk_smtp_password"}
class ResetError(RuntimeError):
pass
class RemoteRedmine:
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
self.ssh_host = ssh_host
self.ssh_key = ssh_key
self.remote_redmine = remote_redmine
self.local = local
def mysql_json_lines(self, sql):
stdout = self.mysql(sql)
rows = []
for line in stdout.splitlines():
if not line.strip():
continue
try:
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
except (ValueError, json.JSONDecodeError) as exc:
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
return rows
def mysql(self, sql):
command = self._mysql_runner_command()
shell = True
if not self.local:
command = [
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_command(),
]
shell = False
try:
result = subprocess.run(
command,
input=sql,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
shell=shell,
)
except OSError as exc:
raise ResetError(f"Could not run ssh: {exc}") from exc
if result.returncode != 0:
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
return result.stdout
def _mysql_runner_command(self):
ruby = (
"require 'yaml'; "
"c = YAML.load_file('config/database.yml')['production']; "
"ENV['MYSQL_PWD'] = c['password'].to_s; "
"args = ['--batch', '--raw', '--quick', '--skip-column-names', "
"'--default-character-set=utf8', '-h', c['host'].to_s, "
"'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; "
"exec('mysql', *args)"
)
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
def main():
parser = argparse.ArgumentParser(
description="Reset Helpdesk mail settings for all active projects."
)
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.")
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
parser.add_argument("--pop3-port", type=int, default=1110)
parser.add_argument("--smtp-port", type=int, default=1025)
parser.add_argument("--username", default="test")
parser.add_argument("--password", default="testpass")
parser.add_argument("--smtp-domain", default="example.test")
parser.add_argument(
"--from-pattern",
default="helpdesk-{identifier}@example.test",
help="Pattern for helpdesk_answer_from. Available fields: {id}, {identifier}, {name}.",
)
parser.add_argument(
"--project",
action="append",
default=[],
help="Optional project id or identifier to limit changes. Can be passed more than once.",
)
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
args = parser.parse_args()
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
try:
projects = find_active_projects(remote, args.project)
if not projects:
print("No active projects matched the requested filters.")
return 0
print(f"Matched {len(projects)} active project(s):")
for project in projects:
print(f" - #{project['id']} {project['identifier']} ({project['name']})")
values = build_values(args, projects)
if args.dry_run:
print("\nDry run. Planned settings:")
print_plan(values)
return 0
apply_values(remote, values)
print(f"\nUpdated {len(values)} setting row(s) across {len(projects)} project(s).")
print("Password values were written but not displayed.")
return 0
except ResetError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def find_active_projects(remote, filters):
where = ["p.status = 1"]
if filters:
clauses = []
for value in filters:
if str(value).isdigit():
clauses.append(f"p.id = {sql_int(value)}")
clauses.append(f"p.identifier = {sql_string(value)}")
where.append("(" + " OR ".join(clauses) + ")")
return remote.mysql_json_lines(
f"""
SELECT HEX(CAST(JSON_OBJECT(
'id', p.id,
'identifier', p.identifier,
'name', p.name
) AS CHAR)) AS document
FROM projects p
WHERE {' AND '.join(where)}
ORDER BY p.identifier;
"""
)
def build_values(args, projects):
rows = []
for project in projects:
project_id = int(project["id"])
answer_from = args.from_pattern.format(
id=project_id,
identifier=project["identifier"],
name=project["name"],
)
settings = {
"helpdesk_protocol": "pop3",
"helpdesk_host": args.mailpit_host,
"helpdesk_port": str(args.pop3_port),
"helpdesk_username": args.username,
"helpdesk_password": args.password,
"helpdesk_use_ssl": "0",
"helpdesk_imap_folder": "",
"helpdesk_move_on_success": "",
"helpdesk_move_on_failure": "",
"helpdesk_apop": "0",
"helpdesk_delete_unprocessed": "0",
# RedmineUP's UI label is confusing: 1 means use the custom SMTP block.
"helpdesk_smtp_use_default_settings": "1",
"helpdesk_smtp_server": args.mailpit_host,
"helpdesk_smtp_port": str(args.smtp_port),
"helpdesk_smtp_domain": args.smtp_domain,
"helpdesk_smtp_authentication": "",
"helpdesk_smtp_username": "",
"helpdesk_smtp_password": "",
"helpdesk_smtp_ssl": "0",
"helpdesk_smtp_tls": "0",
"helpdesk_answer_from": answer_from,
}
rows.extend((project_id, name, settings[name]) for name in MAIL_SETTING_NAMES)
return rows
def apply_values(remote, rows):
statements = ["START TRANSACTION;"]
for project_id, name, value in rows:
project_id_sql = sql_int(project_id)
name_sql = sql_string(name)
value_sql = sql_string(value)
statements.append(
f"""
UPDATE contacts_settings
SET value = {value_sql}, updated_on = UTC_TIMESTAMP()
WHERE project_id = {project_id_sql}
AND name = {name_sql};
INSERT INTO contacts_settings (project_id, name, value, updated_on)
SELECT {project_id_sql}, {name_sql}, {value_sql}, UTC_TIMESTAMP()
WHERE NOT EXISTS (
SELECT 1
FROM contacts_settings
WHERE project_id = {project_id_sql}
AND name = {name_sql}
);
"""
)
statements.append("COMMIT;")
remote.mysql("\n".join(statements))
def print_plan(rows):
current_project_id = None
for project_id, name, value in rows:
if project_id != current_project_id:
current_project_id = project_id
print(f"\nProject #{project_id}")
display_value = "<redacted>" if name in SECRET_NAMES else value
print(f" {name} = {display_value}")
def sql_int(value):
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def sql_string(value):
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
def shell_quote(value):
return "'" + value.replace("'", "'\"'\"'") + "'"
if __name__ == "__main__":
raise SystemExit(main())