aboutsummaryrefslogtreecommitdiffstats
path: root/bin/sync_members
blob: 9357db585e3514611c080d2c58fa12ec696f126a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#! @PYTHON@
#
# Copyright (C) 1998-2016 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 C_



PROGRAM = sys.argv[0]

def usage(code, msg=''):
    if code:
        fd = sys.stderr
    else:
        fd = sys.stdout
    print >> fd, C_(__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, C_('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 C_('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, C_('Only one -f switch allowed'))
            try:
                filename = sys.argv[i+1]
            except IndexError:
                usage(1, C_('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, C_('Illegal option: %(opt)s'))
        else:
            try:
                listname = sys.argv[i].lower()
                i += 1
            except IndexError:
                usage(1, C_('No listname given'))
            break

    if listname is None or filename is None:
        usage(1, C_('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, C_('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 C_('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 C_('Invalid :  %(addr)30s')
            invalid = 1
    if invalid:
        print C_('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 C_('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 C_('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)
                # Avoid UnicodeError if name can't be decoded
                if isinstance(name, str):
                    name = unicode(name, errors='replace')
                name = name.encode(enc, 'replace')
                s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
                print C_('Added  : %(s)s')
            except Errors.MMAlreadyAMember:
                pass
            except Errors.MembershipIsBanned, pattern:
                print ('%s:' % addr), C_(
                                      '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)
            # Avoid UnicodeError if name can't be decoded
            if isinstance(name, str):
                name = unicode(name, errors='replace')
            name = name.encode(enc, 'replace')
            s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
            print C_('Removed: %(s)s')

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


if __name__ == '__main__':
    main()