Compare commits
14 Commits
d8284f4475
...
848c98c456
Author | SHA1 | Date | |
---|---|---|---|
848c98c456 | |||
2b4d1acf52 | |||
0ae6e0c461 | |||
91b1217633 | |||
ab0c8fb5af | |||
c65cd8ea8e | |||
686bfc7e24 | |||
df4e3a6afd | |||
812affd0ae | |||
ff944231d8 | |||
9e3a042a58 | |||
c49f3c7635 | |||
ef8a299e65 | |||
c8f3e229c8 |
211
mailman_sync.py
211
mailman_sync.py
@ -1,90 +1,149 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Update Mailman 2 lists via a json API of the form {"LIST": ["ADDRESS", ...]}
|
Update Mailman 2 lists via a json API of the form:
|
||||||
|
{
|
||||||
|
"LIST": {
|
||||||
|
"real_name": "REAL_NAME",
|
||||||
|
"moderator": ["ADDRESS", ...],
|
||||||
|
"subject_prefix": "PREFIX",
|
||||||
|
"reply_to_address": "REPLY_TO_ADDRESS",
|
||||||
|
"members": ["ADDRESS", ...]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation
|
||||||
|
PASSWORD_LEN = 18
|
||||||
|
|
||||||
def config_list(mailman_bin: Path, mailing_list: str):
|
|
||||||
config_changes = """
|
|
||||||
# TODO: set real_name, moderator, subject prefix, and reply-to address
|
|
||||||
from_is_list = 1
|
|
||||||
anonymous_list = 1
|
|
||||||
first_strip_reply_to = 1
|
|
||||||
reply_goes_to_list = 2
|
|
||||||
|
|
||||||
# quiet member management
|
@dataclass
|
||||||
send_reminders = 0
|
class ListManager:
|
||||||
send_welcome_msg = 0
|
mailman_bin: Path
|
||||||
send_goodbye_msg = 0
|
list_name: str
|
||||||
|
dry_run: bool
|
||||||
|
|
||||||
new_member_options = 272
|
def _call_script(self, script: str, args: list[str], **kwargs):
|
||||||
advertised = 0
|
output = subprocess.run(
|
||||||
private_roster = 2 # only admins can view the roster
|
[self.mailman_bin / script, *args],
|
||||||
default_member_moderation = 1
|
encoding="ascii",
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
for line in output.stdout.splitlines():
|
||||||
|
print(f"[{script} {self.list_name}] {line}")
|
||||||
|
|
||||||
generic_nonmember_action = 3 # discard non-member emails
|
def newlist(self, urlhost: str, emailhost: str, admin: str):
|
||||||
forward_auto_discards = 0 # don't notify admin about discards
|
password = "".join(secrets.choice(PASSWORD_CHARS) for i in range(PASSWORD_LEN))
|
||||||
"""
|
|
||||||
|
self._call_script(
|
||||||
|
"newlist",
|
||||||
|
[
|
||||||
|
"--quiet",
|
||||||
|
f"--urlhost={urlhost}",
|
||||||
|
f"--emailhost={emailhost}",
|
||||||
|
self.list_name,
|
||||||
|
admin,
|
||||||
|
password,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def config_list(
|
||||||
|
self,
|
||||||
|
emailhost: str,
|
||||||
|
real_name: str,
|
||||||
|
moderator: list[str],
|
||||||
|
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:
|
with tempfile.NamedTemporaryFile("w", suffix=".py") as config_file:
|
||||||
config_file.write(config_changes)
|
config_file.write(config_changes)
|
||||||
config_file.flush()
|
config_file.flush()
|
||||||
output = subprocess.run(
|
|
||||||
[
|
args = [
|
||||||
mailman_bin / "config_list",
|
|
||||||
"--inputfile",
|
"--inputfile",
|
||||||
config_file.name,
|
config_file.name,
|
||||||
mailing_list,
|
self.list_name,
|
||||||
],
|
]
|
||||||
encoding="ascii",
|
if self.dry_run:
|
||||||
capture_output=True,
|
args.append("--checkonly")
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
for line in output.stdout.splitlines():
|
|
||||||
print(f"[Configuring {mailing_list}] {line}")
|
|
||||||
|
|
||||||
|
self._call_script("config_list", args)
|
||||||
|
|
||||||
def sync_members(
|
def sync_members(self, members: list[str]):
|
||||||
mailman_bin: Path, mailing_list: str, members: list[str], dry_run: bool
|
args = [
|
||||||
):
|
|
||||||
command = [
|
|
||||||
mailman_bin / "sync_members",
|
|
||||||
"--welcome-msg=no",
|
"--welcome-msg=no",
|
||||||
"--goodbye-msg=no",
|
"--goodbye-msg=no",
|
||||||
"--notifyadmin=no",
|
"--notifyadmin=no",
|
||||||
"--file",
|
"--file",
|
||||||
"-",
|
"-",
|
||||||
mailing_list,
|
self.list_name,
|
||||||
]
|
]
|
||||||
if dry_run:
|
if self.dry_run:
|
||||||
command.append("--no-change")
|
args.append("--no-change")
|
||||||
|
|
||||||
members_data = "\n".join(members)
|
members_data = "\n".join(members)
|
||||||
output = subprocess.run(
|
self._call_script("sync_members", args, input=members_data)
|
||||||
command,
|
|
||||||
input=members_data,
|
|
||||||
encoding="ascii",
|
|
||||||
capture_output=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
for line in output.stdout.splitlines():
|
|
||||||
print(f"[Syncing {mailing_list}] {line}")
|
|
||||||
|
|
||||||
|
|
||||||
def main(mailman_bin: Path, api: str, api_auth: str, list_suffix: str, dry_run: bool):
|
def main(
|
||||||
|
mailman_bin: Path,
|
||||||
|
api: str,
|
||||||
|
api_auth: str,
|
||||||
|
list_suffix: str,
|
||||||
|
dry_run: bool,
|
||||||
|
urlhost: 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:
|
||||||
print(f"Failed to get mailing list data from api: {r.status_code} {r.text}")
|
print(f"Failed to get mailing list data from api: {r.status_code} {r.text}")
|
||||||
return
|
return
|
||||||
|
expected_lists = r.json()
|
||||||
|
|
||||||
existing_lists = subprocess.run(
|
existing_lists = subprocess.run(
|
||||||
[mailman_bin / "list_lists", "-b"],
|
[mailman_bin / "list_lists", "-b"],
|
||||||
@ -92,18 +151,28 @@ def main(mailman_bin: Path, api: str, api_auth: str, list_suffix: str, dry_run:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=True,
|
check=True,
|
||||||
).stdout.split("\n")
|
).stdout.split("\n")
|
||||||
certification_lists = r.json()
|
for name, props in expected_lists.items():
|
||||||
for name, members in certification_lists.items():
|
|
||||||
list_name = name + list_suffix
|
list_name = name + list_suffix
|
||||||
if list_name in existing_lists:
|
list_manager = ListManager(mailman_bin, list_name, dry_run)
|
||||||
print(f"Configuring/syncing {list_name}...")
|
if list_name.lower() not in existing_lists:
|
||||||
config_list(mailman_bin, list_name)
|
if dry_run:
|
||||||
sync_members(mailman_bin, list_name, members, dry_run)
|
print(f"Skipping non-existing list {list_name} in dry run mode")
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
print(f"Skipping {list_name}, as it does not exist in Mailman")
|
list_manager.newlist(urlhost, emailhost, admin)
|
||||||
|
|
||||||
|
print(f"Configuring/syncing {list_name}...")
|
||||||
|
list_manager.config_list(
|
||||||
|
emailhost,
|
||||||
|
props["real_name"],
|
||||||
|
props["moderator"],
|
||||||
|
props["subject_prefix"],
|
||||||
|
props["reply_to_address"],
|
||||||
|
)
|
||||||
|
list_manager.sync_members(props["members"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def parse_arguments():
|
||||||
argp = argparse.ArgumentParser(description=__doc__)
|
argp = argparse.ArgumentParser(description=__doc__)
|
||||||
argp.add_argument(
|
argp.add_argument(
|
||||||
"--bin",
|
"--bin",
|
||||||
@ -112,14 +181,29 @@ if __name__ == "__main__":
|
|||||||
help="Path to Mailman site admin scripts (default %(default)s)",
|
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")
|
argp.add_argument("--list-suffix", help="Suffix for mailing lists", default="")
|
||||||
|
argp.add_argument(
|
||||||
|
"--urlhost", help="Urlhost to use when creating new lists", required=True
|
||||||
|
)
|
||||||
|
argp.add_argument(
|
||||||
|
"--emailhost", help="Emailhost to use when creating new lists", required=True
|
||||||
|
)
|
||||||
|
argp.add_argument(
|
||||||
|
"--admin",
|
||||||
|
help="Admin email address to use when creating new lists",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
argp.add_argument(
|
argp.add_argument(
|
||||||
"-n",
|
"-n",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Don't make changes, just print what would happen",
|
help="Don't make changes, just print what would happen",
|
||||||
)
|
)
|
||||||
args = argp.parse_args()
|
return argp.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_arguments()
|
||||||
|
|
||||||
if "API_AUTH" in os.environ:
|
if "API_AUTH" in os.environ:
|
||||||
api_auth = os.environ.get("API_AUTH")
|
api_auth = os.environ.get("API_AUTH")
|
||||||
@ -128,7 +212,16 @@ if __name__ == "__main__":
|
|||||||
exit(-1)
|
exit(-1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
main(args.bin, args.api, api_auth, args.list_suffix, args.dry_run)
|
main(
|
||||||
|
args.bin,
|
||||||
|
args.api,
|
||||||
|
api_auth,
|
||||||
|
args.list_suffix,
|
||||||
|
args.dry_run,
|
||||||
|
args.urlhost,
|
||||||
|
args.emailhost,
|
||||||
|
args.admin,
|
||||||
|
)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(e.stderr)
|
print(e.stderr)
|
||||||
raise
|
raise
|
||||||
|
1
pyproject.toml
Normal file
1
pyproject.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
[tool.black]
|
Loading…
Reference in New Issue
Block a user