Merge branch 'alias' into 'master'
[mailman.git] / src / mailman / utilities / importer.py
blobc223dd5791ebe969dc3bd9b8bb7ae58c519ac237
1 # Copyright (C) 2010-2019 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
18 """Importer routines."""
20 import os
21 import re
22 import sys
23 import logging
24 import datetime
26 from contextlib import ExitStack
27 from mailman.config import config
28 from mailman.handlers.decorate import decorate_template
29 from mailman.interfaces.action import Action, FilterAction
30 from mailman.interfaces.address import IEmailValidator
31 from mailman.interfaces.archiver import ArchivePolicy
32 from mailman.interfaces.autorespond import ResponseAction
33 from mailman.interfaces.bans import IBanManager
34 from mailman.interfaces.bounce import UnrecognizedBounceDisposition
35 from mailman.interfaces.digests import DigestFrequency
36 from mailman.interfaces.errors import MailmanError
37 from mailman.interfaces.languages import ILanguageManager
38 from mailman.interfaces.mailinglist import (
39 DMARCMitigateAction, IAcceptableAliasSet, IHeaderMatchList,
40 Personalization, ReplyToMunging, SubscriptionPolicy)
41 from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
42 from mailman.interfaces.nntp import NewsgroupModeration
43 from mailman.interfaces.template import ITemplateLoader, ITemplateManager
44 from mailman.interfaces.usermanager import IUserManager
45 from mailman.utilities.filesystem import makedirs
46 from mailman.utilities.i18n import search
47 from public import public
48 from sqlalchemy import Boolean
49 from urllib.error import URLError
50 from zope.component import getUtility
52 log = logging.getLogger('mailman.error')
55 @public
56 class Import21Error(MailmanError):
57 """An import from a Mailman 2.1 list failed."""
60 def bytes_to_str(value):
61 # Convert a string to unicode when the encoding is not declared.
62 if not isinstance(value, bytes):
63 return value
64 for encoding in ('ascii', 'utf-8'):
65 try:
66 return value.decode(encoding)
67 except UnicodeDecodeError:
68 continue
69 # We did our best, use replace.
70 return value.decode('ascii', 'replace')
73 def str_to_bytes(value):
74 if value is None or isinstance(value, bytes):
75 return value
76 return value.encode('utf-8')
79 def seconds_to_delta(value):
80 return datetime.timedelta(seconds=value)
83 def days_to_delta(value):
84 return datetime.timedelta(days=value)
87 def list_members_to_unicode(value):
88 return [bytes_to_str(item) for item in value]
91 def autoresponder_mapping(value):
92 if value:
93 return ResponseAction.respond_and_continue
94 return ResponseAction.none
97 def dmarc_action_mapping(value):
98 # Convert dmarc_moderation_action to a DMARCMitigateAction enum.
99 # 2.1 actions are 0==accept, 1==Munge From, 2==Wrap Message,
100 # 3==Reject, 4==discard
101 return {
102 0: DMARCMitigateAction.no_mitigation,
103 1: DMARCMitigateAction.munge_from,
104 2: DMARCMitigateAction.wrap_message,
105 3: DMARCMitigateAction.reject,
106 4: DMARCMitigateAction.discard,
107 }[value]
110 def filter_action_mapping(value):
111 # The filter_action enum values have changed. In Mailman 2.1 the order
112 # was 'Discard', 'Reject', 'Forward to List Owner', 'Preserve'. In MM3
113 # it's 'hold', 'reject', 'discard', 'accept', 'defer', 'forward',
114 # 'preserve'. Some of the MM3 actions don't exist in MM2.1.
115 return {
116 0: FilterAction.discard,
117 1: FilterAction.reject,
118 2: FilterAction.forward,
119 3: FilterAction.preserve,
120 }[value]
123 def member_moderation_action_mapping(value):
124 # Convert the member_moderation_action option to an Action enum.
125 # The values were: 0==Hold, 1==Reject, 2==Discard
126 return {
127 0: Action.hold,
128 1: Action.reject,
129 2: Action.discard,
130 }[value]
133 def nonmember_action_mapping(value):
134 # For default_nonmember_action, which used to be called
135 # generic_nonmember_action, the values were: 0==Accept, 1==Hold,
136 # 2==Reject, 3==Discard
137 return {
138 0: Action.accept,
139 1: Action.hold,
140 2: Action.reject,
141 3: Action.discard,
142 }[value]
145 def action_to_chain(value):
146 # Converts an action number in Mailman 2.1 to the name of the corresponding
147 # chain in 3.x. The actions 'approve', 'subscribe' and 'unsubscribe' are
148 # ignored. The defer action is converted to None, because it is not
149 # a jump to a terminal chain.
150 return {
151 0: None,
152 # 1: 'approve',
153 2: 'reject',
154 3: 'discard',
155 # 4: 'subscribe',
156 # 5: 'unsubscribe',
157 6: 'accept',
158 7: 'hold',
159 }[value]
162 def check_language_code(code):
163 if code is None:
164 return None
165 code = bytes_to_str(code)
166 if code not in getUtility(ILanguageManager):
167 msg = """Missing language: {0}
168 You must add a section describing this language to your mailman.cfg file.
169 This section should look like this:
170 [language.{0}]
171 # The English name for this language.
172 description: CHANGE ME
173 # The default character set for this language.
174 charset: utf-8
175 # Whether the language is enabled or not.
176 enabled: yes
177 """.format(code)
178 raise Import21Error(msg)
179 return code
182 # Attributes in Mailman 2 which have a different type in Mailman 3. Some
183 # types (e.g. bools) are autodetected from their SA column types.
184 TYPES = dict(
185 autorespond_owner=autoresponder_mapping,
186 autorespond_postings=autoresponder_mapping,
187 autorespond_requests=ResponseAction,
188 autoresponse_grace_period=days_to_delta,
189 bounce_info_stale_after=seconds_to_delta,
190 bounce_you_are_disabled_warnings_interval=seconds_to_delta,
191 default_nonmember_action=nonmember_action_mapping,
192 digest_volume_frequency=DigestFrequency,
193 filter_action=filter_action_mapping,
194 filter_extensions=list_members_to_unicode,
195 filter_types=list_members_to_unicode,
196 forward_unrecognized_bounces_to=UnrecognizedBounceDisposition,
197 moderator_password=str_to_bytes,
198 newsgroup_moderation=NewsgroupModeration,
199 pass_extensions=list_members_to_unicode,
200 pass_types=list_members_to_unicode,
201 personalize=Personalization,
202 preferred_language=check_language_code,
203 reply_goes_to_list=ReplyToMunging,
204 subscription_policy=SubscriptionPolicy,
208 # Attribute names in Mailman 2 which are renamed in Mailman 3.
209 NAME_MAPPINGS = dict(
210 autorespond_admin='autorespond_owner',
211 autoresponse_admin_text='autoresponse_owner_text',
212 autoresponse_graceperiod='autoresponse_grace_period',
213 bounce_processing='process_bounces',
214 bounce_unrecognized_goes_to_list_owner='forward_unrecognized_bounces_to',
215 filter_filename_extensions='filter_extensions',
216 filter_mime_types='filter_types',
217 generic_nonmember_action='default_nonmember_action',
218 include_list_post_header='allow_list_posts',
219 mod_password='moderator_password',
220 news_moderation='newsgroup_moderation',
221 news_prefix_subject_too='nntp_prefix_subject_too',
222 pass_filename_extensions='pass_extensions',
223 pass_mime_types='pass_types',
224 real_name='display_name',
225 send_goodbye_msg='send_goodbye_message',
226 send_welcome_msg='send_welcome_message',
227 subscribe_policy='subscription_policy',
230 # These DateTime fields of the mailinglist table need a type conversion to
231 # Python datetime object for SQLite databases.
232 DATETIME_COLUMNS = [
233 'created_at',
234 'digest_last_sent_at',
235 'last_post_time',
238 EXCLUDES = set((
239 'delivery_status',
240 'digest_members',
241 'members',
242 'user_options',
246 @public
247 def import_config_pck(mlist, config_dict):
248 """Apply a config.pck configuration dictionary to a mailing list.
250 :param mlist: The mailing list.
251 :type mlist: IMailingList
252 :param config_dict: The Mailman 2.1 configuration dictionary.
253 :type config_dict: dict
255 for key, value in config_dict.items():
256 # Some attributes must not be directly imported.
257 if key in EXCLUDES:
258 continue
259 # These objects need explicit type conversions.
260 if key in DATETIME_COLUMNS:
261 continue
262 # Some attributes from Mailman 2 were renamed in Mailman 3.
263 key = NAME_MAPPINGS.get(key, key)
264 # Handle the simple case where the key is an attribute of the
265 # IMailingList and the types are the same (modulo 8-bit/unicode
266 # strings).
268 # If the mailing list has a preferred language that isn't registered
269 # in the configuration file, hasattr() will swallow the KeyError this
270 # raises and return False. Treat that attribute specially.
271 if key == 'preferred_language' or hasattr(mlist, key):
272 if isinstance(value, bytes):
273 value = bytes_to_str(value)
274 # Some types require conversion.
275 converter = TYPES.get(key)
276 if converter is None:
277 column = getattr(mlist.__class__, key, None)
278 if column is not None and isinstance(column.type, Boolean):
279 converter = bool
280 try:
281 if converter is not None:
282 value = converter(value)
283 setattr(mlist, key, value)
284 except (TypeError, KeyError):
285 print('Type conversion error for key "{}": {}'.format(
286 key, value), file=sys.stderr)
287 for key in DATETIME_COLUMNS:
288 try:
289 value = datetime.datetime.utcfromtimestamp(config_dict[key])
290 except KeyError:
291 continue
292 if key == 'last_post_time':
293 setattr(mlist, 'last_post_at', value)
294 continue
295 setattr(mlist, key, value)
296 # Handle the moderation policy.
298 # The mlist.default_member_action and mlist.default_nonmember_action enum
299 # values are different in Mailman 2.1, because they have been merged into a
300 # single enum in Mailman 3.
302 # Unmoderated lists used to have default_member_moderation set to a false
303 # value; this translates to the Defer default action. Moderated lists with
304 # the default_member_moderation set to a true value used to store the
305 # action in the member_moderation_action flag, the values were: 0==Hold,
306 # 1=Reject, 2==Discard
307 if bool(config_dict.get('default_member_moderation', 0)):
308 mlist.default_member_action = member_moderation_action_mapping(
309 config_dict.get('member_moderation_action'))
310 else:
311 mlist.default_member_action = Action.defer
312 # Handle DMARC mitigations.
313 # This would be straightforward except for from_is_list. The issue
314 # is in MM 2.1 the from_is_list action applies if dmarc_moderation_action
315 # doesn't apply and they can be different.
316 # We will map as follows:
317 # from_is_list > dmarc_moderation_action
318 # dmarc_mitigate_action = from_is_list action
319 # dmarc_mitigate_unconditionally = True
320 # from_is_list <= dmarc_moderation_action
321 # dmarc_mitigate_action = dmarc_moderation_action
322 # dmarc_mitigate_unconditionally = False
323 # The text attributes are handled above.
324 if (config_dict.get('from_is_list', 0) >
325 config_dict.get('dmarc_moderation_action', 0)):
326 mlist.dmarc_mitigate_action = dmarc_action_mapping(
327 config_dict.get('from_is_list', 0))
328 mlist.dmarc_mitigate_unconditionally = True
329 else:
330 mlist.dmarc_mitigate_action = dmarc_action_mapping(
331 config_dict.get('dmarc_moderation_action', 0))
332 mlist.dmarc_mitigate_unconditionally = False
333 # Handle the archiving policy. In MM2.1 there were two boolean options
334 # but only three of the four possible states were valid. Now there's just
335 # an enum.
336 if config_dict.get('archive'):
337 # For maximum safety, if for some strange reason there's no
338 # archive_private key, treat the list as having private archives.
339 if config_dict.get('archive_private', True):
340 mlist.archive_policy = ArchivePolicy.private
341 else:
342 mlist.archive_policy = ArchivePolicy.public
343 else:
344 mlist.archive_policy = ArchivePolicy.never
345 # Handle ban list.
346 ban_manager = IBanManager(mlist)
347 for address in config_dict.get('ban_list', []):
348 ban_manager.ban(bytes_to_str(address))
349 # Handle acceptable aliases.
350 acceptable_aliases = config_dict.get('acceptable_aliases', '')
351 if isinstance(acceptable_aliases, bytes):
352 acceptable_aliases = acceptable_aliases.decode('utf-8')
353 if isinstance(acceptable_aliases, str):
354 acceptable_aliases = acceptable_aliases.splitlines()
355 alias_set = IAcceptableAliasSet(mlist)
356 for address in acceptable_aliases:
357 address = address.strip()
358 if len(address) == 0:
359 continue
360 address = bytes_to_str(address)
361 # All 2.1 acceptable aliases are regexps whether or not they start
362 # with '^' or contain '@'.
363 if not address.startswith('^'):
364 address = '^' + address
365 # This used to be in a try which would catch ValueError and add a '^',
366 # but .add() would not raise ValueError if address contained '@' and
367 # that needs the '^' too as it could be a regexp with an '@' in it.
368 alias_set.add(address)
369 # Handle header_filter_rules conversion to header_matches.
370 header_matches = IHeaderMatchList(mlist)
371 header_filter_rules = config_dict.get('header_filter_rules', [])
372 for line_patterns, action, _unused in header_filter_rules:
373 try:
374 chain = action_to_chain(action)
375 except KeyError:
376 log.warning('Unsupported header_filter_rules action: %r',
377 action)
378 continue
379 # Now split the line into a header and a pattern.
380 for line_pattern in line_patterns.splitlines():
381 if len(line_pattern.strip()) == 0:
382 continue
383 for sep in (': ', ':.*', ':.', ':'):
384 header, sep, pattern = line_pattern.partition(sep)
385 if sep:
386 # We found it.
387 break
388 else:
389 # Matches any header, which is not supported. XXX
390 log.warning('Unsupported header_filter_rules pattern: %r',
391 line_pattern)
392 continue
393 header = header.strip().lstrip('^').lower()
394 header = header.replace('\\', '')
395 if not header:
396 log.warning(
397 'Cannot parse the header in header_filter_rule: %r',
398 line_pattern)
399 continue
400 if len(pattern) == 0:
401 # The line matched only the header, therefore the header can
402 # be anything.
403 pattern = '.*'
404 try:
405 re.compile(pattern)
406 except re.error:
407 log.warning('Skipping header_filter rule because of an '
408 'invalid regular expression: %r', line_pattern)
409 continue
410 try:
411 header_matches.append(header, pattern, chain)
412 except ValueError:
413 log.warning('Skipping duplicate header_filter rule: %r',
414 line_pattern)
415 continue
416 # Handle conversion to URIs. In MM2.1, the decorations are strings
417 # containing placeholders, and there's no provision for language-specific
418 # strings. In MM3, template locations are specified by URLs with the
419 # special `mailman:` scheme indicating a file system path. What we do
420 # here is look to see if the list's decoration is different than the
421 # default, and if so, we'll write the new decoration template to a
422 # `mailman:` scheme path, then add the template to the template manager.
423 # We are intentionally omitting the 2.1 welcome_msg here because the
424 # string is actually interpolated into a larger template and there's
425 # no good way to figure where in the default template to insert it.
426 convert_to_uri = {
427 'goodbye_msg': 'list:user:notice:goodbye',
428 'msg_header': 'list:member:regular:header',
429 'msg_footer': 'list:member:regular:footer',
430 'digest_header': 'list:member:digest:header',
431 'digest_footer': 'list:member:digest:footer',
433 # The best we can do is convert only the most common ones. These are
434 # order dependent; the longer substitution with the common prefix must
435 # show up earlier.
436 convert_placeholders = [
437 ('%(real_name)s@%(host_name)s',
438 'To unsubscribe send an email to ${short_listname}-leave@${domain}'),
439 ('%(real_name)s mailing list',
440 '$display_name mailing list -- $listname'),
441 # The generic footers no longer have URLs in them.
442 ('%(web_page_url)slistinfo%(cgiext)s/%(_internal_name)s\n', ''),
444 # Collect defaults.
445 manager = getUtility(ITemplateManager)
446 defaults = {}
447 for oldvar, newvar in convert_to_uri.items():
448 default_value = getUtility(ITemplateLoader).get(newvar, mlist)
449 if not default_value:
450 continue
451 # Get the decorated default text
452 try:
453 default_text = decorate_template(mlist, default_value)
454 except (URLError, KeyError): # pragma: nocover
455 # Use case: importing the old a@ex.com into b@ex.com. We can't
456 # check if it changed from the default so don't import, we may do
457 # more harm than good and it's easy to change if needed.
458 # TESTME
459 print('Unable to convert mailing list attribute:', oldvar,
460 'with old value "{}"'.format(default_value),
461 file=sys.stderr)
462 continue
463 defaults[newvar] = default_text
464 for oldvar, newvar in convert_to_uri.items():
465 if oldvar not in config_dict:
466 continue
467 text = config_dict[oldvar]
468 if isinstance(text, bytes):
469 text = text.decode('utf-8', 'replace')
470 for oldph, newph in convert_placeholders:
471 text = text.replace(oldph, newph)
472 default_text = defaults.get(newvar, None)
473 if not text and not default_text:
474 # Both are empty, leave it.
475 continue
476 # Check if the value changed from the default
477 try:
478 expanded_text = decorate_template(mlist, text)
479 except KeyError: # pragma: nocover
480 # Use case: importing the old a@ex.com into b@ex.com
481 # We can't check if it changed from the default
482 # -> don't import, we may do more harm than good and it's easy to
483 # change if needed
484 # TESTME
485 print('Unable to convert mailing list attribute:', oldvar,
486 'with value "{}"'.format(text),
487 file=sys.stderr)
488 continue
489 if (expanded_text and default_text and
490 expanded_text.strip() == default_text.strip()):
491 # Keep the default.
492 continue
493 # Write the custom value to the right file and add it to the template
494 # manager for real.
495 base_uri = 'mailman:///$listname/$language/'
496 filename = '{}.txt'.format(newvar)
497 manager.set(newvar, mlist.list_id, base_uri + filename)
498 with ExitStack() as resources:
499 filepath = list(search(resources, filename, mlist))[0]
500 makedirs(os.path.dirname(filepath))
501 with open(filepath, 'w', encoding='utf-8') as fp:
502 fp.write(text)
503 # Import rosters.
504 regulars_set = set(config_dict.get('members', {}))
505 digesters_set = set(config_dict.get('digest_members', {}))
506 members = regulars_set.union(digesters_set)
507 # Don't send welcome messages when we import the rosters.
508 send_welcome_message = mlist.send_welcome_message
509 mlist.send_welcome_message = False
510 try:
511 import_roster(mlist, config_dict, members, MemberRole.member)
512 import_roster(mlist, config_dict, config_dict.get('owner', []),
513 MemberRole.owner)
514 import_roster(mlist, config_dict, config_dict.get('moderator', []),
515 MemberRole.moderator)
516 # Now import the '*_these_nonmembers' properties, filtering out the
517 # regexps which will remain in the property.
518 for action_name in ('accept', 'hold', 'reject', 'discard'):
519 prop_name = '{}_these_nonmembers'.format(action_name)
520 emails = [addr
521 for addr in config_dict.get(prop_name, [])
522 if not addr.startswith('^')]
523 import_roster(mlist, config_dict, emails, MemberRole.nonmember,
524 Action[action_name])
525 # Only keep the regexes in the legacy list property.
526 list_prop = getattr(mlist, prop_name)
527 for email in emails:
528 list_prop.remove(email)
529 finally:
530 mlist.send_welcome_message = send_welcome_message
533 def import_roster(mlist, config_dict, members, role, action=None):
534 """Import members lists from a config.pck configuration dictionary.
536 :param mlist: The mailing list.
537 :type mlist: IMailingList
538 :param config_dict: The Mailman 2.1 configuration dictionary.
539 :type config_dict: dict
540 :param members: The members list to import.
541 :type members: list
542 :param role: The MemberRole to import them as.
543 :type role: MemberRole enum
544 :param action: The default nonmember action.
545 :type action: Action
547 usermanager = getUtility(IUserManager)
548 validator = getUtility(IEmailValidator)
549 roster = mlist.get_roster(role)
550 for email in members:
551 # For owners and members, the emails can have a mixed case, so
552 # lowercase them all.
553 email = bytes_to_str(email).lower()
554 if roster.get_member(email) is not None:
555 print('{} is already imported with role {}'.format(email, role),
556 file=sys.stderr)
557 continue
558 address = usermanager.get_address(email)
559 user = usermanager.get_user(email)
560 if user is None:
561 user = usermanager.create_user()
562 if address is None:
563 merged_members = {}
564 merged_members.update(config_dict.get('members', {}))
565 merged_members.update(config_dict.get('digest_members', {}))
566 if merged_members.get(email, 0) != 0:
567 original_email = bytes_to_str(merged_members[email])
568 if not validator.is_valid(original_email):
569 original_email = email
570 else:
571 original_email = email
572 if not validator.is_valid(original_email):
573 # Skip this one entirely.
574 continue
575 address = usermanager.create_address(original_email)
576 address.verified_on = datetime.datetime.now()
577 user.link(address)
578 member = mlist.subscribe(address, role)
579 assert member is not None
580 prefs = config_dict.get('user_options', {}).get(email)
581 if email in config_dict.get('members', {}):
582 member.preferences.delivery_mode = DeliveryMode.regular
583 elif email in config_dict.get('digest_members', {}):
584 if prefs is not None and prefs & 8: # DisableMime
585 member.preferences.delivery_mode = \
586 DeliveryMode.plaintext_digests
587 else:
588 member.preferences.delivery_mode = DeliveryMode.mime_digests
589 else:
590 # XXX Probably not adding a member role here.
591 pass
592 if email in config_dict.get('language', {}):
593 member.preferences.preferred_language = \
594 check_language_code(config_dict['language'][email])
595 # If the user already exists, display_name and password will be
596 # overwritten.
597 if email in config_dict.get('usernames', {}):
598 address.display_name = \
599 bytes_to_str(config_dict['usernames'][email])
600 user.display_name = \
601 bytes_to_str(config_dict['usernames'][email])
602 if email in config_dict.get('passwords', {}):
603 user.password = config.password_context.encrypt(
604 config_dict['passwords'][email])
605 # delivery_status
606 oldds = config_dict.get('delivery_status', {}).get(email, (0, 0))[0]
607 if oldds == 0:
608 member.preferences.delivery_status = DeliveryStatus.enabled
609 elif oldds == 1:
610 member.preferences.delivery_status = DeliveryStatus.unknown
611 elif oldds == 2:
612 member.preferences.delivery_status = DeliveryStatus.by_user
613 elif oldds == 3:
614 member.preferences.delivery_status = DeliveryStatus.by_moderator
615 elif oldds == 4:
616 member.preferences.delivery_status = DeliveryStatus.by_bounces
617 # Moderation.
618 if prefs is not None:
619 # We're adding a member.
620 if prefs & 128:
621 # The member is moderated. Check the member_moderation_action
622 # option to know which action should be taken.
623 action = member_moderation_action_mapping(
624 config_dict.get('member_moderation_action'))
625 else:
626 # Member is not moderated: defer is the best option, as
627 # discussed on merge request 100.
628 action = Action.defer
629 if action is not None:
630 # Either this was set right above or in the function's arguments
631 # for nonmembers.
632 member.moderation_action = action
633 # Other preferences.
634 if prefs is not None:
635 # AcknowledgePosts
636 member.preferences.acknowledge_posts = bool(prefs & 4)
637 # ConcealSubscription
638 member.preferences.hide_address = bool(prefs & 16)
639 # DontReceiveOwnPosts
640 member.preferences.receive_own_postings = not bool(prefs & 2)
641 # DontReceiveDuplicates
642 member.preferences.receive_list_copy = not bool(prefs & 256)