Compare commits

...

23 Commits

Author SHA1 Message Date
738d7c4dfc Add create_members and create_bans 2024-03-05 13:58:24 -08:00
3wc
0c1f25cc11 Final(?) attr skips 2024-02-29 17:00:12 -03:00
3wc
9a93c3e70f More attribute tidying 2024-02-29 16:56:45 -03:00
3wc
d548ae824e Map some boooools 2024-02-29 16:53:27 -03:00
3wc
4d9f6f7228 Type mappings 2024-02-29 16:45:45 -03:00
339dea2581 Coerce (some) bools to strings 2024-02-29 11:45:36 -08:00
54c20a8c65 Add total counts to the end of import script 2024-02-28 08:43:50 -08:00
4d4c7bc00d Individually patch each parameter rather than all at once, and indicate which ones succeed and which ones dont 2024-02-28 08:20:46 -08:00
3wc
e3867f1656 Map roster visibility 2024-02-28 13:20:04 -03:00
3wc
dd2a266a22 More settings exclusions 2024-02-27 17:06:22 -03:00
3wc
cc0f767a0b More settings exclusions 2024-02-27 16:56:47 -03:00
3wc
2f541d797e Ignore more settings 2024-02-27 16:55:50 -03:00
3wc
1a0cdbe2ed Actually exclude attributes 2024-02-27 16:51:10 -03:00
3wc
f3b9d15107 And more.. 2024-02-27 16:45:19 -03:00
e91122f5ed Add owner and moderator stubs 2024-02-27 11:43:44 -08:00
3wc
e99a496659 Ignore more digest stuff 2024-02-27 16:38:53 -03:00
3wc
7765bfc640 And ignore some digest settings 2024-02-27 16:37:38 -03:00
3wc
1ef25c2b45 More conversion tweaks 2024-02-27 16:36:42 -03:00
82bbf22e64 more verbose failure 2024-02-27 11:32:41 -08:00
3wc
cb2e7c7fd5 Pull in more conversion stuff from upstream 2024-02-27 16:32:13 -03:00
3wc
6ffc739b15 Moderation policy, DMARC, archive 2024-02-27 16:08:12 -03:00
d58953d10d Update to handle multiple files at a time, and also coerce integers into integers 2024-02-15 15:41:16 -08:00
3wc
61a87f190c Tweak README 2024-02-15 20:10:56 -03:00
3 changed files with 369 additions and 46 deletions

View File

@ -1,5 +1,7 @@
# adversarial-mailman-2to3
> be you
> migrating mailman2 to mailman3
> shared hosting doesn't give you access to mailman2 config
migrating mailman2 to mailman3?
shared hosting doesn't give you access to mailman2 config?
enter adversarial-mailman-2to3!

View File

@ -1,47 +1,285 @@
import argparse
import json
import sys
from functools import partial
from copy import deepcopy
from math import floor
from bs4 import BeautifulSoup
from mailman.utilities.importer import NAME_MAPPINGS
parser = argparse.ArgumentParser(
prog=sys.argv[0], description="Munge Mailman config data"
from mailman.interfaces.action import Action
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.digests import DigestFrequency
from mailman.utilities.importer import (
NAME_MAPPINGS,
EXCLUDES as EXCLUDES_ORIGINAL,
TYPES as TYPES_ORIGINAL,
member_moderation_action_mapping,
dmarc_action_mapping,
member_roster_visibility_mapping
)
from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.mailinglist import (
DMARCMitigateAction,
Personalization,
ReplyToMunging,
SubscriptionPolicy,
)
parser.add_argument("file", type=argparse.FileType('r', encoding='utf-8'))
KEYFILTER = ('submit')
args = parser.parse_args()
EXCLUDES = set(
tuple(EXCLUDES_ORIGINAL) + tuple({
# from `convert_to_uri` in mailman's `importer.py`
'goodbye_msg': 'list:user:notice:goodbye',
'msg_header': 'list:member:regular:header',
'msg_footer': 'list:member:regular:footer',
'digest_header': 'list:member:digest:header',
'digest_footer': 'list:member:digest:footer',
}.keys()) + (
# some settings to manually skip, which don't seem to be handled by the
# conversion script
'archive_volume_frequency',
'autoresponse_postings_text_upload',
'autoresponse_admin_text_upload',
'autoresponse_request_text_upload',
'bounce_notify_owner_on_bounce_increment',
'nondigestable',
'digestable',
'digest_is_default',
'mime_is_default_digest',
'digest_size_threshhold',
'_new_volume',
'_send_digest_now',
'umbrella_list',
'umbrella_member_suffix',
'send_reminders',
'admin_member_chunksize',
'host_name',
'new_member_options',
'include_sender_header',
'drop_cc',
'available_languages',
'encode_ascii_prefixes',
'regular_exclude_lists',
'regular_include_lists',
'scrub_nondigest',
'regular_exclude_ignore',
'subscribe_auto_approval',
'unsubscribe_policy',
'obscure_addresses',
'member_moderation_notice',
'equivalent_domains',
'nonmember_rejection_notice',
'default_member_moderation',
'member_verbosity_threshold',
'member_verbosity_interval',
'member_moderation_action',
# TODO investigate
'dmarc_quarantine_moderation_action',
'dmarc_none_moderation_action',
'forward_auto_discards',
'hdrfilter_rebox_01',
'hdrfilter_action_01',
'bounce_matching_headers',
'topic_rebox_01',
'topic_desc_01',
'topic_box_01',
# None of the lists has topic filtering enabled
'topics_enabled',
'topics_bodylines_limit',
# ... or DMARC addresses
'dmarc_moderation_addresses',
))
data_clean = {}
TYPES = deepcopy(TYPES_ORIGINAL)
soup = BeautifulSoup(args.file.read(), 'html.parser')
TYPES['process_bounces'] = lambda x: bool(x)
TYPES['bounce_notify_owner_on_disable'] = lambda x: bool(x)
TYPES['bounce_notify_owner_on_removal'] = lambda x: bool(x)
TYPES['filter_content'] = lambda x: bool(x)
TYPES['collapse_alternatives'] = lambda x: bool(x)
TYPES['convert_html_to_plaintext'] = lambda x: bool(x)
TYPES['digest_send_periodic'] = lambda x: bool(x)
TYPES['anonymous_list'] = lambda x: bool(x)
TYPES['first_strip_reply_to'] = lambda x: bool(x)
TYPES['send_welcome_message'] = lambda x: bool(x)
TYPES['send_goodbye_message'] = lambda x: bool(x)
TYPES['admin_immed_notify'] = lambda x: bool(x)
TYPES['admin_notify_mchanges'] = lambda x: bool(x)
TYPES['respond_to_post_requests'] = lambda x: bool(x)
TYPES['emergency'] = lambda x: bool(x)
TYPES['administrivia'] = lambda x: bool(x)
TYPES['include_rfc2369_headers'] = lambda x: bool(x)
TYPES['allow_list_posts'] = lambda x: bool(x)
TYPES['advertised'] = lambda x: bool(x)
TYPES['require_explicit_destination'] = lambda x: bool(x)
TYPES['bounce_score_threshold'] = lambda x: floor(float(x))
for field in soup.find_all('textarea'):
name = NAME_MAPPINGS.get(field['name'], field['name'])
TYPES_EXTRA = {
'autorespond_requests': lambda x, y: x(y).name,
'autorespond_owner': lambda x, y: x(y).name,
'autorespond_postings': lambda x, y: x(y).name,
'autoresponse_grace_period': lambda x, y: '{}d'.format(x(y).days),
'bounce_info_stale_after': lambda x, y: '{}s'.format(x(y).seconds),
'bounce_you_are_disabled_warnings_interval': lambda x, y: '{}s'.format(x(y).seconds),
'forward_unrecognized_bounces_to': lambda x, y: x(y).name,
'filter_action': lambda x, y: x(y).name,
'digest_volume_frequency': lambda x, y: x(y).name,
'reply_goes_to_list': lambda x, y: x(y).name,
'newsgroup_moderation': lambda x, y: x(y).name,
'subscription_policy': lambda x, y: x(y).name,
'default_nonmember_action': lambda x, y: x(y).name,
}
if 'msg' in name:
continue
data_clean[name] = [l for l in field.get_text().split('\n') if l != ""]
for key, func in TYPES_EXTRA.items():
TYPES[key] = partial(func, TYPES_ORIGINAL[key])
for field in soup.find_all('input'):
if field['type'] == 'hidden':
continue
def msg(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
if field['type'] == 'RADIO':
if 'checked' not in field.attrs:
def get_form_data(htmlfile):
data_clean = {}
soup = BeautifulSoup(htmlfile.read(), 'html.parser')
for field in soup.find_all('textarea'):
if field['name'] in EXCLUDES:
continue
name = NAME_MAPPINGS.get(field['name'], field['name'])
name = NAME_MAPPINGS.get(field['name'], field['name'])
if 'msg' in name:
continue
if 'msg' in name:
continue
try:
data_clean[name] = field['value']
except KeyError:
data_clean[name] = ""
data_clean[name] = [line for line in field.get_text().split('\n') if
line != ""]
print(json.dumps(data_clean))
for field in soup.find_all('input'):
if field['name'] in EXCLUDES:
continue
if field['type'].lower() in ('hidden', 'submit'):
continue
if field['type'].lower() == 'radio':
if 'checked' not in field.attrs:
continue
name = NAME_MAPPINGS.get(field['name'], field['name'])
if 'msg' in name:
continue
try:
value = field['value']
try:
value = int(value)
except ValueError:
...
except KeyError:
value = ''
converter = TYPES.get(name)
try:
if converter is not None:
value = converter(value)
except (TypeError, KeyError, ValueError):
from pdb import set_trace; set_trace()
print('Type conversion error for key "{}": {}'.format(
name, value), file=sys.stderr)
data_clean[name] = value
return data_clean
if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog=sys.argv[0], description="Munge Mailman config data"
)
parser.add_argument("config", help="A list of files (named specifically) to import as configurations",
nargs='+',
action="append")
args = parser.parse_args()
globalconfig = {}
for page in args.config[0]:
msg(page)
with open(page) as inf:
result = get_form_data(inf)
for key, value in result.items():
if key in globalconfig:
msg(f"warning - duplicate key {key}")
else:
globalconfig[key] = value
# Handle the moderation policy.
#
# The mlist.default_member_action and mlist.default_nonmember_action enum
# values are different in Mailman 2.1, because they have been merged into a
# single enum in Mailman 3.
#
# Unmoderated lists used to have default_member_moderation set to a false
# value; this translates to the Defer default action. Moderated lists with
# the default_member_moderation set to a true value used to store the
# action in the member_moderation_action flag, the values were: 0==Hold,
# 1=Reject, 2==Discard
if bool(globalconfig.get('default_member_moderation', 0)):
globalconfig['default_member_action'] = member_moderation_action_mapping(
globalconfig.get('member_moderation_action')).name
del globalconfig['default_member_moderation']
else:
globalconfig['default_member_action'] = Action.defer.name
# Handle DMARC mitigations.
# This would be straightforward except for from_is_list. The issue
# is in MM 2.1 the from_is_list action applies if dmarc_moderation_action
# doesn't apply and they can be different.
# We will map as follows:
# from_is_list > dmarc_moderation_action
# dmarc_mitigate_action = from_is_list action
# dmarc_mitigate_unconditionally = True
# from_is_list <= dmarc_moderation_action
# dmarc_mitigate_action = dmarc_moderation_action
# dmarc_mitigate_unconditionally = False
# The text attributes are handled above.
if (globalconfig.get('from_is_list', 0) >
globalconfig.get('dmarc_moderation_action', 0)):
globalconfig['dmarc_mitigate_action'] = dmarc_action_mapping(
globalconfig.get('from_is_list', 0)).name
globalconfig['dmarc_mitigate_unconditionally'] = True
else:
globalconfig['dmarc_mitigate_action'] = dmarc_action_mapping(
globalconfig.get('dmarc_moderation_action', 0)).name
globalconfig['dmarc_mitigate_unconditionally'] = False
if 'from_is_list' in globalconfig.keys():
del globalconfig['from_is_list']
if 'dmarc_moderation_action' in globalconfig.keys():
del globalconfig['dmarc_moderation_action']
# Handle the archiving policy. In MM2.1 there were two boolean options
# but only three of the four possible states were valid. Now there's just
# an enum.
if globalconfig.get('archive'):
# For maximum safety, if for some strange reason there's no
# archive_private key, treat the list as having private archives.
if globalconfig.get('archive_private', True):
globalconfig['archive_policy'] = ArchivePolicy.private.name
else:
globalconfig['archive_policy'] = ArchivePolicy.public.name
del globalconfig['archive']
else:
globalconfig['archive_policy'] = ArchivePolicy.never.name
if 'archive_private' in globalconfig.keys():
del globalconfig['archive_private']
# Handle roster visibility.
mapping = member_roster_visibility_mapping(
globalconfig.get('private_roster', None))
if mapping is not None:
globalconfig['member_roster_visibility'] = mapping.name
del globalconfig['private_roster']
print(json.dumps(globalconfig))

115
import_mailman3_list_config.py Normal file → Executable file
View File

@ -9,7 +9,9 @@ from pathlib import Path
from requests.auth import HTTPBasicAuth
REST_USER="restadmin"
REST_PATH="http://127.0.0.1:8001/3.0/lists"
REST_PATH="http://127.0.0.1:8001/3.0"
REST_PASS = Path("/run/secrets/mailman_rest_password").read_text().strip()
REST_AUTH = HTTPBasicAuth(REST_USER, REST_PASS)
BANNER="""
_) | __ / _|_)
@ -19,6 +21,55 @@ _|_|_|\__,_|_|_|_|_|_|\__,_|_| _|___/\__|\___/_| _|_| _|\__, |
"""
# def create_user(email):
# result = requests.get(f"{REST_PATH}/users/{email}")
# print(result)
# print(result.text)
# if not result.ok:
# print(f"user {email} not found, creating")
# result = requests.post(f"{REST_PATH}/users", auth=REST_AUTH, json={"email": email})
# if (result.ok):
# print("success")
# print(result)
# print(result.text)
# else:
# print("failed")
# print(result)
# print(result.reason)
# return False
# return True
def create_member(email, mlist, role):
result = requests.post(f"{REST_PATH}/members", auth=REST_AUTH, json={"list_id": mlist.replace('@', '.'), "subscriber": email, "role": role})
if (result.ok):
print(f"{email} <- {role} success")
print(result)
print(result.text)
return True
else:
print(f"{email} <- {role} failed")
print(result)
print(result.reason)
print(result.text)
return False
def create_ban(email, mlist):
result = requests.post(f"{REST_PATH}/lists/{mlist}/bans", auth=REST_AUTH, json={"email": email})
if (result.ok):
print(f"{email} <- BAN success")
print(result)
print(result.text)
return True
else:
print(f"{email} <- BAN failed")
print(result)
print(result.reason)
print(result.text)
return False
def parse_args(args):
parser = argparse.ArgumentParser("load a json config into local mailman configuration")
@ -37,16 +88,13 @@ def main(args):
with pargs.patch.open() as inf:
p = json.load(inf)
rest_pass = Path("/run/secrets/mailman_rest_password").read_text().strip()
rest_auth = HTTPBasicAuth(REST_USER, rest_pass)
# determine if list exists, if not create with defaults
result = requests.get(REST_PATH, auth=rest_auth)
result = requests.get(REST_PATH + '/lists', auth=REST_AUTH)
if result.ok:
if (not (pargs.list in [x['fqdn_listname'] for x in result.json()['entries']])):
# create list
print("list not found: creating")
result = requests.post(f"{REST_PATH}", auth=rest_auth, json={"fqdn_listname": pargs.list})
result = requests.post(f"{REST_PATH}/lists", auth=REST_AUTH, json={"fqdn_listname": pargs.list})
if (result.ok):
print("success")
print(result)
@ -55,6 +103,7 @@ def main(args):
print("failed")
print(result)
print(result.reason)
print(result.text)
return 1
# list exists
else:
@ -64,17 +113,51 @@ def main(args):
return 1
# patch config
result = requests.patch(f"{REST_PATH}/{pargs.list}/config", auth=rest_auth, json=p)
if result.ok:
print("success")
print(result)
print(result.text)
else:
print("failed")
print(result)
print(result.reason)
# fixme pull out owner, moderator keys, we'll poke them in separately
owners = []
moderators = []
ban_list = []
if "owner" in p:
owners = p["owner"]
del p["owner"]
if "moderator" in p:
moderators = p["moderator"]
del p["moderator"]
if "ban_list" in p:
ban_list = p["ban_list"]
del p["ban_list"]
# patch config
cnt = 0
for k, v in p.items():
if (type(v) == bool):
v = str(v)
result = requests.patch(f"{REST_PATH}/lists/{pargs.list}/config", auth=REST_AUTH, json={k: v})
if result.ok:
cnt += 1
print(f"*** [OK] {k} success")
print(result)
print(result.text)
print("")
else:
print(f"*** [!!] {k} failed")
print(result)
print(result.reason)
print(result.text)
print("")
print(f"- {cnt} / {len(p) - 1} succeeded.")
print("assigning roles")
for user in owners:
create_member(user, pargs.list, "owner")
for user in moderators:
create_member(user, pargs.list, "moderator")
print("banning")
for user in ban_list:
create_ban(user, pargs.list)
if __name__ == "__main__":
sys.exit(main(sys.argv))