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)
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
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."""
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')
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
):
64 for encoding
in ('ascii', 'utf-8'):
66 return value
.decode(encoding
)
67 except UnicodeDecodeError:
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
):
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
):
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
102 0: DMARCMitigateAction
.no_mitigation
,
103 1: DMARCMitigateAction
.munge_from
,
104 2: DMARCMitigateAction
.wrap_message
,
105 3: DMARCMitigateAction
.reject
,
106 4: DMARCMitigateAction
.discard
,
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.
116 0: FilterAction
.discard
,
117 1: FilterAction
.reject
,
118 2: FilterAction
.forward
,
119 3: FilterAction
.preserve
,
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
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
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.
162 def check_language_code(code
):
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:
171 # The English name for this language.
172 description: CHANGE ME
173 # The default character set for this language.
175 # Whether the language is enabled or not.
178 raise Import21Error(msg
)
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.
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.
234 'digest_last_sent_at',
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.
259 # These objects need explicit type conversions.
260 if key
in DATETIME_COLUMNS
:
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
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
):
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
:
289 value
= datetime
.datetime
.utcfromtimestamp(config_dict
[key
])
292 if key
== 'last_post_time':
293 setattr(mlist
, 'last_post_at', value
)
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'))
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
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
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
342 mlist
.archive_policy
= ArchivePolicy
.public
344 mlist
.archive_policy
= ArchivePolicy
.never
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:
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
:
374 chain
= action_to_chain(action
)
376 log
.warning('Unsupported header_filter_rules action: %r',
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:
383 for sep
in (': ', ':.*', ':.', ':'):
384 header
, sep
, pattern
= line_pattern
.partition(sep
)
389 # Matches any header, which is not supported. XXX
390 log
.warning('Unsupported header_filter_rules pattern: %r',
393 header
= header
.strip().lstrip('^').lower()
394 header
= header
.replace('\\', '')
397 'Cannot parse the header in header_filter_rule: %r',
400 if len(pattern
) == 0:
401 # The line matched only the header, therefore the header can
407 log
.warning('Skipping header_filter rule because of an '
408 'invalid regular expression: %r', line_pattern
)
411 header_matches
.append(header
, pattern
, chain
)
413 log
.warning('Skipping duplicate header_filter rule: %r',
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.
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
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', ''),
445 manager
= getUtility(ITemplateManager
)
447 for oldvar
, newvar
in convert_to_uri
.items():
448 default_value
= getUtility(ITemplateLoader
).get(newvar
, mlist
)
449 if not default_value
:
451 # Get the decorated default text
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.
459 print('Unable to convert mailing list attribute:', oldvar
,
460 'with old value "{}"'.format(default_value
),
463 defaults
[newvar
] = default_text
464 for oldvar
, newvar
in convert_to_uri
.items():
465 if oldvar
not in config_dict
:
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.
476 # Check if the value changed from the default
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
485 print('Unable to convert mailing list attribute:', oldvar
,
486 'with value "{}"'.format(text
),
489 if (expanded_text
and default_text
and
490 expanded_text
.strip() == default_text
.strip()):
493 # Write the custom value to the right file and add it to the template
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
:
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
511 import_roster(mlist
, config_dict
, members
, MemberRole
.member
)
512 import_roster(mlist
, config_dict
, config_dict
.get('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
)
521 for addr
in config_dict
.get(prop_name
, [])
522 if not addr
.startswith('^')]
523 import_roster(mlist
, config_dict
, emails
, MemberRole
.nonmember
,
525 # Only keep the regexes in the legacy list property.
526 list_prop
= getattr(mlist
, prop_name
)
528 list_prop
.remove(email
)
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.
542 :param role: The MemberRole to import them as.
543 :type role: MemberRole enum
544 :param action: The default nonmember 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
),
558 address
= usermanager
.get_address(email
)
559 user
= usermanager
.get_user(email
)
561 user
= usermanager
.create_user()
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
571 original_email
= email
572 if not validator
.is_valid(original_email
):
573 # Skip this one entirely.
575 address
= usermanager
.create_address(original_email
)
576 address
.verified_on
= datetime
.datetime
.now()
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
588 member
.preferences
.delivery_mode
= DeliveryMode
.mime_digests
590 # XXX Probably not adding a member role here.
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
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
])
606 oldds
= config_dict
.get('delivery_status', {}).get(email
, (0, 0))[0]
608 member
.preferences
.delivery_status
= DeliveryStatus
.enabled
610 member
.preferences
.delivery_status
= DeliveryStatus
.unknown
612 member
.preferences
.delivery_status
= DeliveryStatus
.by_user
614 member
.preferences
.delivery_status
= DeliveryStatus
.by_moderator
616 member
.preferences
.delivery_status
= DeliveryStatus
.by_bounces
618 if prefs
is not None:
619 # We're adding a member.
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'))
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
632 member
.moderation_action
= action
634 if prefs
is not None:
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)