Commit 766c2fef authored by Mark Sapiro's avatar Mark Sapiro

Merge branch 'fix_686' into 'master'

Implement separate mailman subcommands for add, delete and sync members.

Closes #686

See merge request !658
parents d5139fad 9c47d8df
Pipeline #155810524 passed with stage
in 13 minutes and 6 seconds
# Copyright (C) 2009-2020 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
"""The 'addmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, DeliveryStatus,
MembershipIsBannedError)
from mailman.interfaces.subscriptions import ISubscriptionManager
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def get_addr(display_name, email, user_manager):
"""Return an existing address record if available, otherwise make one."""
addr = user_manager.get_address(email)
if addr is not None:
# We have an address with this email. Return that.
return addr
# Unknown email. Create an address for this.
# XXX Should we be making a user instead?
return user_manager.create_address(email, display_name)
@transactional
def add_members(mlist, in_fp, delivery, invite, welcome_msg):
"""Add members to a mailing list."""
user_manager = getUtility(IUserManager)
registrar = ISubscriptionManager(mlist)
email_validator = getUtility(IEmailValidator)
for line in in_fp:
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line.strip()) == 0:
continue
# Parse the line and ensure that the values are unicodes.
display_name, email = parseaddr(line)
# parseaddr can return invalid emails. E.g. parseaddr('[email protected]')
# returns ('', '[email protected]') in python 3.6.7 and 3.7.1 so check validity.
if not email_validator.is_valid(email):
line = line.strip()
print(_('Cannot parse as valid email address (skipping): $line'),
file=sys.stderr)
continue
subscriber = get_addr(display_name, email, user_manager)
# For error messages.
email = formataddr((display_name, email))
delivery_status = DeliveryStatus.enabled
if delivery is None or delivery == 'regular' or delivery == 'disabled':
delivery_mode = DeliveryMode.regular
if delivery == 'disabled':
delivery_status = DeliveryStatus.by_moderator
elif delivery == 'mime':
delivery_mode = DeliveryMode.mime_digests
elif delivery == 'plain':
delivery_mode = DeliveryMode.plaintext_digests
elif delivery == 'summary':
delivery_mode = DeliveryMode.summary_digests
try:
member = registrar.register(
subscriber,
pre_verified=True,
pre_approved=True,
pre_confirmed=(not invite),
send_welcome_message=welcome_msg)[2]
member.preferences.delivery_status = delivery_status
member.preferences.delivery_mode = delivery_mode
except AlreadySubscribedError:
# It's okay if the address is already subscribed, just print a
# warning and continue.
print(_('Already subscribed (skipping): $email'), file=sys.stderr)
except MembershipIsBannedError:
print(_('Membership is banned (skipping): $email'),
file=sys.stderr)
@click.command(
cls=I18nCommand,
help=_("""\
Add all member addresses in FILENAME with delivery mode as specified
with -d/--delivery. FILENAME can be '-' to indicate standard input.
Blank lines and lines that start with a '#' are ignored.
"""))
@click.option(
'--delivery', '-d',
type=click.Choice(('regular', 'mime', 'plain', 'summary', 'disabled')),
help=_("""\
Set the added members delivery mode to 'regular', 'mime', 'plain',
'summary' or 'disabled'. I.e., one of regular, three modes of digest
or no delivery. If not given, the default is regular."""))
@click.option(
'--invite', '-i',
is_flag=True, default=False,
help=_("""\
Send the added members a confirmation request rather than immediately
adding them."""))
@click.option(
'--welcome-msg/--no-welcome-msg', '-w/-W', 'welcome_msg', default=None,
help=_("""\
Override the list's setting for send_welcome_message."""))
@click.argument('in_fp', metavar='FILENAME', type=click.File(encoding='utf-8'))
@click.argument('listspec')
@click.pass_context
def addmembers(ctx, in_fp, delivery, invite, welcome_msg, listspec):
"""Add members to a mailing list."""
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: $listspec'))
add_members(mlist, in_fp, delivery, invite, welcome_msg)
@public
@implementer(ICLISubCommand)
class AddMembers:
name = 'addmembers'
command = addmembers
# Copyright (C) 2009-2020 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
"""The 'delmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.app.membership import delete_member
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import NotAMemberError
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
@transactional
def delete_members(mlists, memb_list, goodbye_msg, admin_notify):
"""Delete one or more members from one or more mailing lists."""
for mlist in mlists:
for display_name, email in memb_list:
try:
delete_member(mlist, email, admin_notif=admin_notify,
userack=goodbye_msg)
except NotAMemberError:
email = formataddr((display_name, email))
if len(mlists) == 1:
print(_('Member not subscribed (skipping): $email'),
file=sys.stderr)
@click.command(
cls=I18nCommand,
help=_("""\
Delete members from a mailing list."""))
@click.option(
'--list', '-l', '_list', metavar='LISTSPEC',
help=_("""\
The list to operate on. Required unless --fromall is specified.
"""))
@click.option(
'--file', '-f', 'in_fp', metavar='FILENAME',
type=click.File(encoding='utf-8'),
help=_("""\
Delete list members whose addresses are in FILENAME in addition to those
specified with -m/--member if any. FILENAME can be '-' to indicate
standard input. Blank lines and lines that start with a '#' are ignored.
"""))
@click.option(
'--member', '-m', metavar='ADDRESS', multiple=True,
help=_("""\
Delete the list member whose address is ADDRESS in addition to those
specified with -f/--file if any. This option may be repeated for
multiple addresses.
"""))
@click.option(
'--all', '-a', '_all',
is_flag=True, default=False,
help=_("""\
Delete all the members of the list. If specified, none of -f/--file,
-m/--member or --fromall may be specified.
"""))
@click.option(
'--fromall',
is_flag=True, default=False,
help=_("""\
Delete the member(s) specified by -m/--member and/or -f/--file from all
lists in the installation. This may not be specified together with
-a/--all or -l/--list.
"""))
@click.option(
'--goodbye-msg/--no-goodbye-msg', '-g/-G', 'goodbye_msg', default=None,
help=_("""\
Override the list's setting for send_goodbye_message to
deleted members."""))
@click.option(
'--admin-notify/--no-admin-notify', '-n/-N', 'admin_notify', default=None,
help=_("""\
Override the list's setting for admin_notify_mchanges."""))
@click.pass_context
def delmembers(ctx, _list, in_fp, member, _all, fromall, goodbye_msg,
admin_notify):
"""Delete members from mailing lists."""
if fromall:
if _list is not None or _all:
ctx.fail('--fromall may not be specified with -l/--list, '
'or -a/--all')
elif _all:
if in_fp is not None or len(member) != 0:
ctx.fail('-a/--all must not be specified with '
'-f/--file or -m/--member.')
if _list is None and not fromall:
ctx.fail('Without --fromall, -l/--list is required.')
if not _all and in_fp is None and len(member) == 0:
ctx.fail('At least one of -a/--all, -f/--file or -m/--member '
'is required.')
list_manager = getUtility(IListManager)
if fromall:
mlists = list_manager.mailing_lists
else:
mlist = list_manager.get(_list)
if mlist is None:
ctx.fail(_('No such list: $_list'))
mlists = [mlist]
if _all:
memb_list = [(address.display_name, address.email) for address in
mlist.members.addresses]
else:
memb_list = []
memb_list.extend([parseaddr(x) for x in member])
if in_fp:
for line in in_fp:
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line.strip()) == 0:
continue
memb_list.append(parseaddr(line))
delete_members(mlists, memb_list, goodbye_msg, admin_notify)
@public
@implementer(ICLISubCommand)
class DelMembers:
name = 'delmembers'
command = delmembers
# Copyright (C) 2009-2020 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
"""The 'syncmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.app.membership import delete_member
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
DeliveryMode, DeliveryStatus, MembershipIsBannedError)
from mailman.interfaces.subscriptions import ISubscriptionManager
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def get_addr(display_name, email):
"""Return an existing address record if available, otherwise make one."""
global user_manager
addr = user_manager.get_address(email)
if addr is not None:
# We have an address with this email. Return that.
return addr
# Unknown email. Create an address for this.
# XXX Should we be making a user instead?`
return user_manager.create_address(email, display_name)
@transactional
def add_members(mlist, member, delivery, welcome_msg):
"""Add members to a mailing list."""
global registrar
display_name, email = parseaddr(member)
subscriber = get_addr(display_name, email)
# For error messages.
email = formataddr((display_name, email))
delivery_status = DeliveryStatus.enabled
if delivery is None or delivery == 'regular' or delivery == 'disabled':
delivery_mode = DeliveryMode.regular
if delivery == 'disabled':
delivery_status = DeliveryStatus.by_moderator
elif delivery == 'mime':
delivery_mode = DeliveryMode.mime_digests
elif delivery == 'plain':
delivery_mode = DeliveryMode.plaintext_digests
elif delivery == 'summary':
delivery_mode = DeliveryMode.summary_digests
try:
member = registrar.register(
subscriber,
pre_verified=True,
pre_approved=True,
pre_confirmed=True,
send_welcome_message=welcome_msg)[2]
member.preferences.delivery_status = delivery_status
member.preferences.delivery_mode = delivery_mode
except MembershipIsBannedError:
print(_('Membership is banned (skipping): $email'),
file=sys.stderr)
@transactional
def sync_members(mlist, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change):
"""Add and delete mailing list members to match an input file."""
global email_validator
subscribers = mlist.members
addresses = list(subscribers.addresses)
# Variable that shows if something was done to the original mailing list
ml_changed = False
# A list (set) of the members currently subscribed.
members_of_list = set([address.email.lower()
for address in addresses])
# A list (set) of all valid email addresses in a file.
file_emails = set()
# A list (dict) of (display name + address) for a members address.
formatted_addresses = {}
for line in in_fp:
# Don't include newlines or whitespaces at the start or end
line = line.strip()
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line) == 0:
continue
# Parse the line to a tuple.
parsed_addr = parseaddr(line)
# parseaddr can return invalid emails. E.g. parseaddr('[email protected]')
# returns ('', '[email protected]') in python 3.6.7 and 3.7.1 so check validity.
if not email_validator.is_valid(parsed_addr[1]):
print(_('Cannot parse as valid email address (skipping): $line'),
file=sys.stderr)
continue
new_display_name, new_email = parsed_addr
# Address to lowercase
lc_email = new_email.lower()
# Format output with display name if available
formatted_addr = formataddr((new_display_name, new_email))
# Add the 'outputable' version to a dict
formatted_addresses[lc_email] = formatted_addr
file_emails.add(lc_email)
addresses_to_add = file_emails - members_of_list
addresses_to_delete = members_of_list - file_emails
for email in sorted(addresses_to_add):
# Add to mailing list if not dryrun.
print(_("[ADD] %s") % formatted_addresses[email])
if not no_change:
add_members(mlist, formatted_addresses[email], delivery,
welcome_msg)
# Indicate that we done something to the mailing list.
ml_changed = True
continue
for email in sorted(addresses_to_delete):
# Delete from mailing list if not dryrun.
member = str(subscribers.get_member(email).address)
print(_("[DEL] %s") % member)
if not no_change:
delete_member(mlist, email, admin_notif=admin_notify,
userack=goodbye_msg)
# Indicate that we done something to the mailing list.
ml_changed = True
continue
# We did nothing to the mailing list -> We had nothing to do.
if not ml_changed:
print(_("Nothing to do"))
@click.command(
cls=I18nCommand,
help=_("""\
Add and delete members as necessary to syncronize a list's membership
with an input file. FILENAME is the file containing the new membership,
one member per line. Blank lines and lines that start with a '#' are
ignored. Addresses in FILENAME which are not current list members
will be added to the list with delivery mode as specified with
-d/--delivery. List members whose addresses are not in FILENAME will
be removed from the list. FILENAME can be '-' to indicate standard input.
"""))
@click.option(
'--delivery', '-d',
type=click.Choice(('regular', 'mime', 'plain', 'summary', 'disabled')),
help=_("""\
Set the added members delivery mode to 'regular', 'mime', 'plain',
'summary' or 'disabled'. I.e., one of regular, three modes of digest
or no delivery. If not given, the default is regular."""))
@click.option(
'--welcome-msg/--no-welcome-msg', '-w/-W', 'welcome_msg', default=None,
help=_("""\
Override the list's setting for send_welcome_message to added members."""))
@click.option(
'--goodbye-msg/--no-goodbye-msg', '-g/-G', 'goodbye_msg', default=None,
help=_("""\
Override the list's setting for send_goodbye_message to
deleted members."""))
@click.option(
'--admin-notify/--no-admin-notify', '-a/-A', 'admin_notify', default=None,
help=_("""\
Override the list's setting for admin_notify_mchanges."""))
@click.option(
'--no-change', '-n', 'no_change',
is_flag=True, default=False,
help=_("""\
Don't actually make the changes. Instead, print out what would be
done to the list."""))
@click.argument('in_fp', metavar='FILENAME', type=click.File(encoding='utf-8'))
@click.argument('listspec')
@click.pass_context
def syncmembers(ctx, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change, listspec):
"""Add and delete mailing list members to match an input file."""
global email_validator, registrar, user_manager
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: $listspec'))
email_validator = getUtility(IEmailValidator)
registrar = ISubscriptionManager(mlist)
user_manager = getUtility(IUserManager)
sync_members(mlist, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change)
@public
@implementer(ICLISubCommand)
class SyncMembers:
name = 'syncmembers'
command = syncmembers
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -24,6 +24,12 @@ Bugs
* The ``dmarc`` rule no longer misses if DNS returns a name containing upper
case. (Closes #726)
Command line
------------
* New ``addmembers``, ``delmembers`` and ``syncmembers`` ``mailman``
subcommands have been added. These provide more options and controls than
the corresponding ``mailman members`` modes which are now deprecated.
(Closes #686)
REST
----
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment