#! /usr/local/bin/python2.6
#
# Copyright (C) 1998-2003 by the Free Software Foundation, Inc.
#
# This program 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 2
# of the License, or (at your option) any later version.
#
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

"""Synchronize a mailing list's membership with a flat file.

This script is useful if you have a Mailman mailing list and a sendmail
:include: style list of addresses (also as is used in Majordomo).  For every
address in the file that does not appear in the mailing list, the address is
added.  For every address in the mailing list that does not appear in the
file, the address is removed.  Other options control what happens when an
address is added or removed.

Usage: %(PROGRAM)s [options] -f file listname

Where `options' are:

    --no-change
    -n
        Don't actually make the changes.  Instead, print out what would be
        done to the list.

    --welcome-msg[=<yes|no>]
    -w[=<yes|no>]
        Sets whether or not to send the newly added members a welcome
        message, overriding whatever the list's `send_welcome_msg' setting
        is.  With -w=yes or -w, the welcome message is sent.  With -w=no, no
        message is sent.

    --goodbye-msg[=<yes|no>]
    -g[=<yes|no>]
        Sets whether or not to send the goodbye message to removed members,
        overriding whatever the list's `send_goodbye_msg' setting is.  With
        -g=yes or -g, the goodbye message is sent.  With -g=no, no message is
        sent.

    --digest[=<yes|no>]
    -d[=<yes|no>]
        Selects whether to make newly added members receive messages in
        digests.  With -d=yes or -d, they become digest members.  With -d=no
        (or if no -d option given) they are added as regular members.

    --notifyadmin[=<yes|no>]
    -a[=<yes|no>]
        Specifies whether the admin should be notified for each subscription
        or unsubscription.  If you're adding a lot of addresses, you
        definitely want to turn this off!  With -a=yes or -a, the admin is
        notified.  With -a=no, the admin is not notified.  With no -a option,
        the default for the list is used.

    --file <filename | ->
    -f <filename | ->
        This option is required.  It specifies the flat file to synchronize
        against.  Email addresses must appear one per line.  If filename is
        `-' then stdin is used.

    --help
    -h
        Print this message.

    listname
        Required.  This specifies the list to synchronize.
"""

import sys

import paths
# Import this /after/ paths so that the sys.path is properly hacked
import email.Utils

from Mailman import MailList
from Mailman import Errors
from Mailman import Utils
from Mailman.UserDesc import UserDesc
from Mailman.i18n import _



PROGRAM = sys.argv[0]

def usage(code, msg=''):
    if code:
        fd = sys.stderr
    else:
        fd = sys.stdout
    print >> fd, _(__doc__)
    if msg:
        print >> fd, msg
    sys.exit(code)



def yesno(opt):
    i = opt.find('=')
    yesno = opt[i+1:].lower()
    if yesno in ('y', 'yes'):
        return 1
    elif yesno in ('n', 'no'):
        return 0
    else:
        usage(1, _('Bad choice: %(yesno)s'))
        # no return


def main():
    dryrun = 0
    digest = 0
    welcome = None
    goodbye = None
    filename = None
    listname = None
    notifyadmin = None

    # TBD: can't use getopt with this command line syntax, which is broken and
    # should be changed to be getopt compatible.
    i = 1
    while i < len(sys.argv):
        opt = sys.argv[i]
        if opt in ('-h', '--help'):
            usage(0)
        elif opt in ('-n', '--no-change'):
            dryrun = 1
            i += 1
            print _('Dry run mode')
        elif opt in ('-d', '--digest'):
            digest = 1
            i += 1
        elif opt.startswith('-d=') or opt.startswith('--digest='):
            digest = yesno(opt)
            i += 1
        elif opt in ('-w', '--welcome-msg'):
            welcome = 1
            i += 1
        elif opt.startswith('-w=') or opt.startswith('--welcome-msg='):
            welcome = yesno(opt)
            i += 1
        elif opt in ('-g', '--goodbye-msg'):
            goodbye = 1
            i += 1
        elif opt.startswith('-g=') or opt.startswith('--goodbye-msg='):
            goodbye = yesno(opt)
            i += 1
        elif opt in ('-f', '--file'):
            if filename is not None:
                usage(1, _('Only one -f switch allowed'))
            try:
                filename = sys.argv[i+1]
            except IndexError:
                usage(1, _('No argument to -f given'))
            i += 2
        elif opt in ('-a', '--notifyadmin'):
            notifyadmin = 1
            i += 1
        elif opt.startswith('-a=') or opt.startswith('--notifyadmin='):
            notifyadmin = yesno(opt)
            i += 1
        elif opt[0] == '-':
            usage(1, _('Illegal option: %(opt)s'))
        else:
            try:
                listname = sys.argv[i].lower()
                i += 1
            except IndexError:
                usage(1, _('No listname given'))
            break

    if listname is None or filename is None:
        usage(1, _('Must have a listname and a filename'))

    # read the list of addresses to sync to from the file
    if filename == '-':
        filemembers = sys.stdin.readlines()
    else:
        try:
            fp = open(filename)
        except IOError, (code, msg):
            usage(1, _('Cannot read address file: %(filename)s: %(msg)s'))
        try:
            filemembers = fp.readlines()
        finally:
            fp.close()

    # strip out lines we don't care about, they are comments (# in first
    # non-whitespace) or are blank
    for i in range(len(filemembers)-1, -1, -1):
        addr = filemembers[i].strip()
        if addr == '' or addr[:1] == '#':
            del filemembers[i]
            print _('Ignore  :  %(addr)30s')

    # first filter out any invalid addresses
    filemembers = email.Utils.getaddresses(filemembers)
    invalid = 0
    for name, addr in filemembers:
        try:
            Utils.ValidateEmail(addr)
        except Errors.EmailAddressError:
            print _('Invalid :  %(addr)30s')
            invalid = 1
    if invalid:
        print _('You must fix the preceding invalid addresses first.')
        sys.exit(1)

    # get the locked list object
    try:
        mlist = MailList.MailList(listname)
    except Errors.MMListError, e:
        print _('No such list: %(listname)s')
        sys.exit(1)

    try:
        # Get the list of addresses currently subscribed
        addrs = {}
        needsadding = {}
        matches = {}
        for addr in mlist.getMemberCPAddresses(mlist.getMembers()):
            addrs[addr.lower()] = addr

        for name, addr in filemembers:
            # Any address found in the file that is also in the list can be
            # ignored.  If not found in the list, it must be added later.
            laddr = addr.lower()
            if addrs.has_key(laddr):
                del addrs[laddr]
                matches[laddr] = 1
            elif not matches.has_key(laddr):
                needsadding[laddr] = (name, addr)

        if not needsadding and not addrs:
            print _('Nothing to do.')
            sys.exit(0)

        enc = sys.getdefaultencoding()
        # addrs contains now all the addresses that need removing
        for laddr, (name, addr) in needsadding.items():
            pw = Utils.MakeRandomPassword()
            # should not already be subscribed, otherwise our test above is
            # broken.  Bogosity is if the address is listed in the file more
            # than once.  Second and subsequent ones trigger an
            # MMAlreadyAMember error.  Just catch it and go on.
            userdesc = UserDesc(addr, name, pw, digest)
            try:
                if not dryrun:
                    mlist.ApprovedAddMember(userdesc, welcome, notifyadmin)
                s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
                print _('Added  : %(s)s')
            except Errors.MMAlreadyAMember:
                pass
            except Errors.MembershipIsBanned, pattern:
                print ('%s:' % addr), _('Banned address (matched %(pattern)s)')

        for laddr, addr in addrs.items():
            # Should be a member, otherwise our test above is broken
            name = mlist.getMemberName(laddr) or ''
            if not dryrun:
                try:
                    mlist.ApprovedDeleteMember(addr, admin_notif=notifyadmin,
                                               userack=goodbye)
                except Errors.NotAMemberError:
                    # This can happen if the address is illegal (i.e. can't be
                    # parsed by email.Utils.parseaddr()) but for legacy
                    # reasons is in the database.  Use a lower level remove to
                    # get rid of this member's entry
                    mlist.removeMember(addr)
            s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
            print _('Removed: %(s)s')

        mlist.Save()
    finally:
        mlist.Unlock()


if __name__ == '__main__':
    main()
