Update for Mailman 3

This commit is contained in:
Adam Goldsmith 2023-09-01 23:33:08 -04:00
parent 2e612837c7
commit 73cee9be6e
2 changed files with 137 additions and 162 deletions

View File

@ -1,15 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Update Mailman 2 lists via a json API of the form: Update Mailman 3 lists via a json API of the form:
{ {
LIST: { LIST: {
"config"?: { "config"?: {
"real_name": REAL_NAME, "real_name": REAL_NAME,
"moderator": [ADDRESS, ...],
"subject_prefix": PREFIX, "subject_prefix": PREFIX,
"reply_to_address": REPLY_TO_ADDRESS, "reply_to_address": REPLY_TO_ADDRESS,
} }
"moderators"?: [ADDRESS, ...],
"members": [ADDRESS, ...] "members": [ADDRESS, ...]
}, },
... ...
@ -17,129 +17,115 @@ Update Mailman 2 lists via a json API of the form:
""" """
import argparse import argparse
from dataclasses import dataclass
import os import os
from pathlib import Path import email.utils
import secrets
import string
import subprocess
import tempfile
import mailmanclient
import requests import requests
PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation
PASSWORD_LEN = 18 def config_list(
list: mailmanclient.MailingList,
dry_run: bool,
real_name: str,
subject_prefix: str,
reply_to_address: str,
):
# TODO: some of this is announcement list specific, some not
config_changes = {
"display_name": real_name,
"subject_prefix": subject_prefix,
"reply_to_address": reply_to_address,
"reply_goes_to_list": "explicit_header_only",
"first_strip_reply_to": True,
# quiet member management
"send_welcome_message": False,
"send_goodbye_message": False,
"allow_list_posts": False,
"anonymous_list": True,
"archive_policy": "private",
"member_roster_visibility": "moderators",
"subscribe_policy": "confirm_then_moderate",
"unsubscribe_policy": "confirm",
"default_member_action": "hold",
"default_nonmember_action": "discard",
"advertised": False,
}
for setting, value in config_changes.items():
old_value = list.settings.get(setting)
if old_value != value:
print(f"Changed {setting}: {old_value} -> {value}")
if not dry_run:
list.settings.update(config_changes)
list.settings.save()
@dataclass # TODO: this will never update someone's display name
class ListManager: def diff_roster(
mailman_bin: Path expected_members: list[str], existing_members: list[mailmanclient.Member]
list_name: str ) -> tuple[dict[str, str], list[mailmanclient.Member]]:
dry_run: bool expected_members_dict = dict(
list(reversed(email.utils.parseaddr(member))) for member in expected_members
)
existing_members_dict = {member.address: member for member in existing_members}
members_to_add = {
k: expected_members_dict[k]
for k in set(expected_members_dict) - set(existing_members_dict)
}
members_to_remove = [
existing_members_dict[k]
for k in set(existing_members_dict) - set(expected_members_dict)
]
return members_to_add, members_to_remove
def _call_script(self, script: str, args: list[str], **kwargs):
output = subprocess.run(
[self.mailman_bin / script, *args],
encoding="ascii",
capture_output=True,
check=True,
**kwargs,
)
for line in output.stdout.splitlines():
print(f"[{script} {self.list_name}] {line}")
def newlist(self, urlhost: str, emailhost: str, admin: str): def sync_members(
password = "".join(secrets.choice(PASSWORD_CHARS) for i in range(PASSWORD_LEN)) list: mailmanclient.MailingList, dry_run: bool, expected_members: list[str]
):
members_to_add, members_to_remove = diff_roster(expected_members, list.members)
self._call_script( for address, display_name in members_to_add.items():
"newlist", print(f"Adding '{display_name} <{address}>' to list {list}")
[ if not dry_run:
"--quiet", list.subscribe(
f"--urlhost={urlhost}", address,
f"--emailhost={emailhost}", display_name,
self.list_name, pre_verified=True,
admin, pre_confirmed=True,
password, pre_approved=True,
], send_welcome_message=False,
) )
def config_list( # TODO: could use `.mass_unsubscribe()` instead
self, for member in members_to_remove:
emailhost: str, print(f"Removing {member} from list {list}")
real_name: str, if not dry_run:
moderator: list[str], list.unsubscribe(member.address, pre_approved=True, pre_confirmed=True)
subject_prefix: str,
reply_to_address: str,
):
config_changes = "\n".join(
[
# have to modify mlist directly to allow removing suffix
"mlist.real_name = " + repr(real_name),
"host_name = " + repr(emailhost),
"moderator = " + repr(moderator),
"subject_prefix = " + repr(subject_prefix),
"from_is_list = 1",
"anonymous_list = 1",
"first_strip_reply_to = 1",
"reply_to_address = " + repr(reply_to_address),
# Use explicit address for Reply-To
"reply_goes_to_list = 2",
# quiet member management
"send_reminders = 0",
"send_welcome_msg = 0",
"send_goodbye_msg = 0",
# ConcealSubscription | DontReceiveDuplicates
"new_member_options = 272",
"advertised = 0",
"subscribe_policy = 3", # require approval to join
"unsubscribe_policy = 1", # require approval to unsubscribe
"private_roster = 2", # only admins can view the roster
"default_member_moderation = 1",
"generic_nonmember_action = 3", # discard non-member emails
"forward_auto_discards = 0", # don't notify admin about discards
"archive_private = 1",
]
)
with tempfile.NamedTemporaryFile("w", suffix=".py") as config_file:
config_file.write(config_changes)
config_file.flush()
args = [ def sync_moderators(
"--inputfile", list: mailmanclient.MailingList, dry_run: bool, expected_members: list[str]
config_file.name, ):
self.list_name, members_to_add, members_to_remove = diff_roster(expected_members, list.moderators)
]
if self.dry_run:
args.append("--checkonly")
self._call_script("config_list", args) for address, display_name in members_to_add.items():
print(f"Adding '{display_name} <{address}>' as moderator to list {list}")
if not dry_run:
list.subscribe(address, display_name)
def sync_members(self, members: list[str]): for member in members_to_remove:
args = [ print(f"Removing {member} as moderator from list {list}")
"--welcome-msg=no", if not dry_run:
"--goodbye-msg=no", list.remove_moderator(member.address)
"--notifyadmin=no",
"--file",
"-",
self.list_name,
]
if self.dry_run:
args.append("--no-change")
members_data = "\n".join(members)
self._call_script("sync_members", args, input=members_data)
def main( def main(
mailman_bin: Path,
api: str, api: str,
api_auth: str, api_auth: str,
list_suffix: str, mailman_client: mailmanclient.Client,
dry_run: bool, dry_run: bool,
urlhost: str, mail_host: str,
emailhost: str,
admin: str,
): ):
r = requests.get(api, headers={"Authorization": api_auth}) r = requests.get(api, headers={"Authorization": api_auth})
if not r.ok: if not r.ok:
@ -147,50 +133,40 @@ def main(
return return
expected_lists = r.json() expected_lists = r.json()
existing_lists = subprocess.run( domain = mailman_client.get_domain(mail_host)
[mailman_bin / "list_lists", "-b"], existing_lists = {list.list_name: list for list in domain.get_lists()}
encoding="ascii",
capture_output=True,
check=True,
).stdout.split("\n")
for name, props in expected_lists.items():
list_name = name + list_suffix
list_manager = ListManager(mailman_bin, list_name, dry_run)
if list_name.lower() not in existing_lists:
if dry_run:
print(f"Skipping non-existing list {list_name} in dry run mode")
continue
else:
list_manager.newlist(urlhost, emailhost, admin)
if "config" in props: for name, props in expected_lists.items():
print(f"Configuring/syncing {list_name}...") if name in existing_lists:
list_manager.config_list(emailhost, **props["config"]) list = existing_lists[name]
elif dry_run:
print(f"Skipping non-existing list {name} in dry run mode")
continue
else: else:
print("Not configuring {list_name}, as it has no config section") list = domain.create_list(name, props.get("style"))
list_manager.sync_members(props["members"])
if "config" not in props:
print(f"Not configuring {name}, as it has no config section")
else:
print(f"Configuring/syncing {name}...")
config_list(list, dry_run, **props["config"])
if "moderators" in props:
sync_moderators(list, dry_run, props["moderators"])
sync_members(list, dry_run, props["members"])
def parse_arguments(): def parse_arguments():
argp = argparse.ArgumentParser(description=__doc__) argp = argparse.ArgumentParser(description=__doc__)
argp.add_argument(
"--bin",
default="/usr/local/mailman",
type=Path,
help="Path to Mailman site admin scripts (default %(default)s)",
)
argp.add_argument("--api", required=True, help="API endpoint to retrieve JSON from") argp.add_argument("--api", required=True, help="API endpoint to retrieve JSON from")
argp.add_argument("--list-suffix", help="Suffix for mailing lists", default="") argp.add_argument("--mail-host", help="Base domain for all lists", required=True)
argp.add_argument( argp.add_argument(
"--urlhost", help="Urlhost to use when creating new lists", required=True "--mailman-url",
help="base URL for for Mailman3 REST API",
default="http://localhost:9001",
) )
argp.add_argument( argp.add_argument(
"--emailhost", help="Emailhost to use when creating new lists", required=True "--mailman-user", help="Username for Mailman3 REST API", default="restadmin"
)
argp.add_argument(
"--admin",
help="Admin email address to use when creating new lists",
required=True,
) )
argp.add_argument( argp.add_argument(
"-n", "-n",
@ -204,23 +180,21 @@ def parse_arguments():
if __name__ == "__main__": if __name__ == "__main__":
args = parse_arguments() args = parse_arguments()
if "API_AUTH" in os.environ: for env_var in ("API_AUTH", "MAILMAN_PASS"):
api_auth = os.environ.get("API_AUTH") if env_var not in os.environ:
else: print(f"Missing {env_var} environment variable")
print("Missing API_AUTH environment variable") exit(-1)
exit(-1)
try: mailman_client = mailmanclient.Client(
main( args.mailman_url + "/3.1",
args.bin, os.environ["MAILMAN_PASS"],
args.api, args.mailman_password,
api_auth, )
args.list_suffix,
args.dry_run, main(
args.urlhost, args.api,
args.emailhost, os.environ["API_AUTH"],
args.admin, mailman_client,
) args.dry_run,
except subprocess.CalledProcessError as e: args.mail_host,
print(e.stderr) )
raise

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Synchronize Mailman lists to CMSManage API Description=Synchronize Mailman lists with CMSManage API
[Service] [Service]
User=mailman User=mailman
@ -7,7 +7,8 @@ Group=mailman
Type=oneshot Type=oneshot
TimeoutStartSec=600 TimeoutStartSec=600
EnvironmentFile=/opt/mailman-sync/env EnvironmentFile=/opt/mailman-sync/env
ExecStart=/usr/bin/python3.9 /opt/mailman-sync/mailman_sync.py \ ExecStart=/usr/bin/python3 /opt/mailman-sync/mailman_sync.py \
--bin /usr/local/cpanel/3rdparty/mailman/bin/ \
--api https://paperwork.claremontmakerspace.org/api/v1/paperwork/certification_definition/mailing_lists.json \ --api https://paperwork.claremontmakerspace.org/api/v1/paperwork/certification_definition/mailing_lists.json \
--list-suffix _claremontmakerspace.org --mail-host claremontmakerspace.org \
--mailman-url http://localhost:8001 \
--mailman-user restadmin