mailman-sync/mailman_sync.py

201 lines
6.0 KiB
Python
Raw Normal View History

2022-12-24 14:04:29 -05:00
#!/usr/bin/env python3
"""
2023-09-01 23:33:08 -04:00
Update Mailman 3 lists via a json API of the form:
{
LIST: {
"config"?: {
"real_name": REAL_NAME,
"subject_prefix": PREFIX,
"reply_to_address": REPLY_TO_ADDRESS,
}
2023-09-01 23:33:08 -04:00
"moderators"?: [ADDRESS, ...],
"members": [ADDRESS, ...]
},
...
}
2022-12-24 14:04:29 -05:00
"""
import argparse
import os
2023-09-01 23:33:08 -04:00
import email.utils
2022-12-24 14:04:29 -05:00
2023-09-01 23:33:08 -04:00
import mailmanclient
2022-12-24 14:04:29 -05:00
import requests
2023-09-01 23:33:08 -04:00
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()
# TODO: this will never update someone's display name
def diff_roster(
expected_members: list[str], existing_members: list[mailmanclient.Member]
) -> tuple[dict[str, str], list[mailmanclient.Member]]:
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 sync_members(
list: mailmanclient.MailingList, dry_run: bool, expected_members: list[str]
):
members_to_add, members_to_remove = diff_roster(expected_members, list.members)
for address, display_name in members_to_add.items():
print(f"Adding '{display_name} <{address}>' to list {list}")
if not dry_run:
list.subscribe(
address,
display_name,
pre_verified=True,
pre_confirmed=True,
pre_approved=True,
send_welcome_message=False,
)
# TODO: could use `.mass_unsubscribe()` instead
for member in members_to_remove:
print(f"Removing {member} from list {list}")
if not dry_run:
list.unsubscribe(member.address, pre_approved=True, pre_confirmed=True)
def sync_moderators(
list: mailmanclient.MailingList, dry_run: bool, expected_members: list[str]
):
members_to_add, members_to_remove = diff_roster(expected_members, list.moderators)
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)
for member in members_to_remove:
print(f"Removing {member} as moderator from list {list}")
if not dry_run:
list.remove_moderator(member.address)
2022-12-24 14:04:29 -05:00
def main(
api: str,
api_auth: str,
2023-09-01 23:33:08 -04:00
mailman_client: mailmanclient.Client,
dry_run: bool,
2023-09-01 23:33:08 -04:00
mail_host: str,
):
r = requests.get(api, headers={"Authorization": api_auth})
2022-12-24 14:04:29 -05:00
if not r.ok:
print(f"Failed to get mailing list data from api: {r.status_code} {r.text}")
return
expected_lists = r.json()
2022-12-24 14:04:29 -05:00
2023-09-01 23:33:08 -04:00
domain = mailman_client.get_domain(mail_host)
2023-09-02 00:01:04 -04:00
existing_lists = {list.list_name.lower(): list for list in domain.get_lists()}
2023-09-01 23:33:08 -04:00
for name, props in expected_lists.items():
2023-09-02 00:01:04 -04:00
if name.lower() in existing_lists:
list = existing_lists[name.lower()]
2023-09-01 23:33:08 -04:00
elif dry_run:
print(f"Skipping non-existing list {name} in dry run mode")
continue
else:
list = domain.create_list(name, props.get("style"))
if "config" not in props:
print(f"Not configuring {name}, as it has no config section")
else:
2023-09-01 23:33:08 -04:00
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"])
2022-12-24 14:04:29 -05:00
2023-01-09 21:26:28 -05:00
def parse_arguments():
2022-12-24 14:04:29 -05:00
argp = argparse.ArgumentParser(description=__doc__)
argp.add_argument("--api", required=True, help="API endpoint to retrieve JSON from")
2023-09-01 23:33:08 -04:00
argp.add_argument("--mail-host", help="Base domain for all lists", required=True)
argp.add_argument(
2023-09-01 23:33:08 -04:00
"--mailman-url",
help="base URL for for Mailman3 REST API",
default="http://localhost:9001",
2023-01-19 18:05:29 -05:00
)
argp.add_argument(
2023-09-01 23:33:08 -04:00
"--mailman-user", help="Username for Mailman3 REST API", default="restadmin"
)
2022-12-25 02:25:24 -05:00
argp.add_argument(
"-n",
"--dry-run",
action="store_true",
help="Don't make changes, just print what would happen",
)
2023-01-09 21:26:28 -05:00
return argp.parse_args()
if __name__ == "__main__":
args = parse_arguments()
2022-12-24 14:04:29 -05:00
2023-09-01 23:33:08 -04:00
for env_var in ("API_AUTH", "MAILMAN_PASS"):
if env_var not in os.environ:
print(f"Missing {env_var} environment variable")
exit(-1)
mailman_client = mailmanclient.Client(
args.mailman_url + "/3.1",
args.mailman_user,
os.environ["MAILMAN_PASS"],
)
main(
args.api,
os.environ["API_AUTH"],
mailman_client,
args.dry_run,
args.mail_host,
)