~futatuki/mailman/2.1-forbid-subscription

« back to all changes in this revision

Viewing changes to Mailman/ListAdmin.py

  • Committer:
  • Date: 2003-01-02 05:25:50 UTC
  • Revision ID: vcs-imports@canonical.com-20030102052550-qqbl1i96tzg3bach
This commit was manufactured by cvs2svn to create branch
'Release_2_1-maint'.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc.
 
2
#
 
3
# This program is free software; you can redistribute it and/or
 
4
# modify it under the terms of the GNU General Public License
 
5
# as published by the Free Software Foundation; either version 2
 
6
# of the License, or (at your option) any later version.
 
7
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software 
 
15
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
 
16
 
 
17
"""Mixin class for MailList which handles administrative requests.
 
18
 
 
19
Two types of admin requests are currently supported: adding members to a
 
20
closed or semi-closed list, and moderated posts.
 
21
 
 
22
Pending subscriptions which are requiring a user's confirmation are handled
 
23
elsewhere.
 
24
"""
 
25
 
 
26
import os
 
27
import time
 
28
import marshal
 
29
import errno
 
30
import cPickle
 
31
from cStringIO import StringIO
 
32
 
 
33
import email
 
34
from email.MIMEMessage import MIMEMessage
 
35
from email.Generator import Generator
 
36
from email.Utils import getaddresses
 
37
 
 
38
from Mailman import mm_cfg
 
39
from Mailman import Utils
 
40
from Mailman import Message
 
41
from Mailman import Errors
 
42
from Mailman.UserDesc import UserDesc
 
43
from Mailman.Queue.sbcache import get_switchboard
 
44
from Mailman.Logging.Syslog import syslog
 
45
from Mailman import i18n
 
46
 
 
47
_ = i18n._
 
48
 
 
49
# Request types requiring admin approval
 
50
IGN = 0
 
51
HELDMSG = 1
 
52
SUBSCRIPTION = 2
 
53
UNSUBSCRIPTION = 3
 
54
 
 
55
# Return status from __handlepost()
 
56
DEFER = 0
 
57
REMOVE = 1
 
58
LOST = 2
 
59
 
 
60
DASH = '-'
 
61
NL = '\n'
 
62
 
 
63
 
 
64
 
 
65
class ListAdmin:
 
66
    def InitVars(self):
 
67
        # non-configurable data
 
68
        self.next_request_id = 1
 
69
 
 
70
    def InitTempVars(self):
 
71
        self.__db = None
 
72
 
 
73
    def __filename(self):
 
74
        return os.path.join(self.fullpath(), 'request.db')
 
75
 
 
76
    def __opendb(self):
 
77
        filename = self.__filename()
 
78
        if self.__db is None:
 
79
            assert self.Locked()
 
80
            try:
 
81
                fp = open(filename)
 
82
                self.__db = marshal.load(fp)
 
83
                fp.close()
 
84
            except IOError, e:
 
85
                if e.errno <> errno.ENOENT: raise
 
86
                self.__db = {}
 
87
            except EOFError, e:
 
88
                # The unmarshalling failed, which means the file is corrupt.
 
89
                # Sigh. Start over.
 
90
                syslog('error',
 
91
                       'request.db file corrupt for list %s, blowing it away.',
 
92
                       self.internal_name())
 
93
                self.__db = {}
 
94
            # Migrate pre-2.1a3 held subscription records to include the
 
95
            # fullname data field.
 
96
            type, version = self.__db.get('version', (IGN, None))
 
97
            if version is None:
 
98
                # No previous revisiont number, must be upgrading to 2.1a3 or
 
99
                # beyond from some unknown earlier version.
 
100
                for id, (type, data) in self.__db.items():
 
101
                    if id == IGN:
 
102
                        pass
 
103
                    elif id == HELDMSG and len(data) == 5:
 
104
                        # tack on a msgdata dictionary
 
105
                        self.__db[id] = data + ({},)
 
106
                    elif id == SUBSCRIPTION and len(data) == 5:
 
107
                        # a fullname field was added
 
108
                        stime, addr, password, digest, lang = data
 
109
                        self.__db[id] = stime, addr, '', password, digest, lang
 
110
                        
 
111
 
 
112
    def __closedb(self):
 
113
        if self.__db is not None:
 
114
            assert self.Locked()
 
115
            # Save the version number
 
116
            self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION
 
117
            # Now save a temp file and do the tmpfile->real file dance.  BAW:
 
118
            # should we be as paranoid as for the config.pck file?  Should we
 
119
            # use pickle?
 
120
            tmpfile = self.__filename() + '.tmp'
 
121
            omask = os.umask(002)
 
122
            try:
 
123
                fp = open(tmpfile, 'w')
 
124
                marshal.dump(self.__db, fp)
 
125
                fp.close()
 
126
                self.__db = None
 
127
            finally:
 
128
                os.umask(omask)
 
129
            # Do the dance
 
130
            os.rename(tmpfile, self.__filename())
 
131
 
 
132
    def __request_id(self):
 
133
        id = self.next_request_id
 
134
        self.next_request_id += 1
 
135
        return id
 
136
 
 
137
    def SaveRequestsDb(self):
 
138
        self.__closedb()
 
139
 
 
140
    def NumRequestsPending(self):
 
141
        self.__opendb()
 
142
        # Subtrace one for the version pseudo-entry
 
143
        if self.__db.has_key('version'):
 
144
            return len(self.__db) - 1
 
145
        return len(self.__db)
 
146
 
 
147
    def __getmsgids(self, rtype):
 
148
        self.__opendb()
 
149
        ids = [k for k, (type, data) in self.__db.items() if type == rtype]
 
150
        ids.sort()
 
151
        return ids
 
152
 
 
153
    def GetHeldMessageIds(self):
 
154
        return self.__getmsgids(HELDMSG)
 
155
 
 
156
    def GetSubscriptionIds(self):
 
157
        return self.__getmsgids(SUBSCRIPTION)
 
158
 
 
159
    def GetUnsubscriptionIds(self):
 
160
        return self.__getmsgids(UNSUBSCRIPTION)
 
161
 
 
162
    def GetRecord(self, id):
 
163
        self.__opendb()
 
164
        type, data = self.__db[id]
 
165
        return data
 
166
 
 
167
    def GetRecordType(self, id):
 
168
        self.__opendb()
 
169
        type, data = self.__db[id]
 
170
        return type
 
171
 
 
172
    def HandleRequest(self, id, value, comment=None, preserve=None,
 
173
                      forward=None, addr=None):
 
174
        self.__opendb()
 
175
        rtype, data = self.__db[id]
 
176
        if rtype == HELDMSG:
 
177
            status = self.__handlepost(data, value, comment, preserve,
 
178
                                       forward, addr)
 
179
        elif rtype == UNSUBSCRIPTION:
 
180
            status = self.__handleunsubscription(data, value, comment)
 
181
        else:
 
182
            assert rtype == SUBSCRIPTION
 
183
            status = self.__handlesubscription(data, value, comment)
 
184
        if status <> DEFER:
 
185
            # BAW: Held message ids are linked to Pending cookies, allowing
 
186
            # the user to cancel their post before the moderator has approved
 
187
            # it.  We should probably remove the cookie associated with this
 
188
            # id, but we have no way currently of correlating them. :(
 
189
            del self.__db[id]
 
190
 
 
191
    def HoldMessage(self, msg, reason, msgdata={}):
 
192
        # Make a copy of msgdata so that subsequent changes won't corrupt the
 
193
        # request database.  TBD: remove the `filebase' key since this will
 
194
        # not be relevant when the message is resurrected.
 
195
        newmsgdata = {}
 
196
        newmsgdata.update(msgdata)
 
197
        msgdata = newmsgdata
 
198
        # assure that the database is open for writing
 
199
        self.__opendb()
 
200
        # get the next unique id
 
201
        id = self.__request_id()
 
202
        assert not self.__db.has_key(id)
 
203
        # get the message sender
 
204
        sender = msg.get_sender()
 
205
        # calculate the file name for the message text and write it to disk
 
206
        if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
 
207
            ext = 'pck'
 
208
        else:
 
209
            ext = 'txt'
 
210
        filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext)
 
211
        omask = os.umask(002)
 
212
        fp = None
 
213
        try:
 
214
            fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w')
 
215
            if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
 
216
                cPickle.dump(msg, fp, 1)
 
217
            else:
 
218
                g = Generator(fp)
 
219
                g(msg, 1)
 
220
        finally:
 
221
            if fp:
 
222
                fp.close()
 
223
            os.umask(omask)
 
224
        # save the information to the request database.  for held message
 
225
        # entries, each record in the database will be of the following
 
226
        # format:
 
227
        #
 
228
        # the time the message was received
 
229
        # the sender of the message
 
230
        # the message's subject
 
231
        # a string description of the problem
 
232
        # name of the file in $PREFIX/data containing the msg text
 
233
        # an additional dictionary of message metadata
 
234
        #
 
235
        msgsubject = msg.get('subject', _('(no subject)'))
 
236
        data = time.time(), sender, msgsubject, reason, filename, msgdata
 
237
        self.__db[id] = (HELDMSG, data)
 
238
        return id
 
239
 
 
240
    def __handlepost(self, record, value, comment, preserve, forward, addr):
 
241
        # For backwards compatibility with pre 2.0beta3
 
242
        ptime, sender, subject, reason, filename, msgdata = record
 
243
        path = os.path.join(mm_cfg.DATA_DIR, filename)
 
244
        # Handle message preservation
 
245
        if preserve:
 
246
            parts = os.path.split(path)[1].split(DASH)
 
247
            parts[0] = 'spam'
 
248
            spamfile = DASH.join(parts)
 
249
            # Preserve the message as plain text, not as a pickle
 
250
            try:
 
251
                fp = open(path)
 
252
            except IOError, e:
 
253
                if e.errno <> errno.ENOENT: raise
 
254
                return LOST
 
255
            try:
 
256
                msg = cPickle.load(fp)
 
257
            finally:
 
258
                fp.close()
 
259
            # Save the plain text to a .msg file, not a .pck file
 
260
            outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile)
 
261
            head, ext = os.path.splitext(outpath)
 
262
            outpath = head + '.msg'
 
263
            outfp = open(outpath, 'w')
 
264
            try:
 
265
                g = Generator(outfp)
 
266
                g(msg, 1)
 
267
            finally:
 
268
                outfp.close()
 
269
        # Now handle updates to the database
 
270
        rejection = None
 
271
        fp = None
 
272
        msg = None
 
273
        status = REMOVE
 
274
        if value == mm_cfg.DEFER:
 
275
            # Defer
 
276
            status = DEFER
 
277
        elif value == mm_cfg.APPROVE:
 
278
            # Approved.
 
279
            try:
 
280
                msg = readMessage(path)
 
281
            except IOError, e:
 
282
                if e.errno <> errno.ENOENT: raise
 
283
                return LOST
 
284
            msg = readMessage(path)
 
285
            msgdata['approved'] = 1
 
286
            # adminapproved is used by the Emergency handler
 
287
            msgdata['adminapproved'] = 1
 
288
            # Calculate a new filebase for the approved message, otherwise
 
289
            # delivery errors will cause duplicates.
 
290
            try:
 
291
                del msgdata['filebase']
 
292
            except KeyError:
 
293
                pass
 
294
            # Queue the file for delivery by qrunner.  Trying to deliver the
 
295
            # message directly here can lead to a huge delay in web
 
296
            # turnaround.  Log the moderation and add a header.
 
297
            msg['X-Mailman-Approved-At'] = email.Utils.formatdate(localtime=1)
 
298
            syslog('vette', 'held message approved, message-id: %s',
 
299
                   msg.get('message-id', 'n/a'))
 
300
            # Stick the message back in the incoming queue for further
 
301
            # processing.
 
302
            inq = get_switchboard(mm_cfg.INQUEUE_DIR)
 
303
            inq.enqueue(msg, _metadata=msgdata)
 
304
        elif value == mm_cfg.REJECT:
 
305
            # Rejected
 
306
            rejection = 'Refused'
 
307
            self.__refuse(_('Posting of your message titled "%(subject)s"'),
 
308
                          sender, comment or _('[No reason given]'),
 
309
                          lang=self.getMemberLanguage(sender))
 
310
        else:
 
311
            assert value == mm_cfg.DISCARD
 
312
            # Discarded
 
313
            rejection = 'Discarded'
 
314
        # Forward the message
 
315
        if forward and addr:
 
316
            # If we've approved the message, we need to be sure to craft a
 
317
            # completely unique second message for the forwarding operation,
 
318
            # since we don't want to share any state or information with the
 
319
            # normal delivery.
 
320
            try:
 
321
                copy = readMessage(path)
 
322
            except IOError, e:
 
323
                if e.errno <> errno.ENOENT: raise
 
324
                raise Errors.LostHeldMessage(path)
 
325
            # It's possible the addr is a comma separated list of addresses.
 
326
            addrs = getaddresses([addr])
 
327
            if len(addrs) == 1:
 
328
                realname, addr = addrs[0]
 
329
                # If the address getting the forwarded message is a member of
 
330
                # the list, we want the headers of the outer message to be
 
331
                # encoded in their language.  Otherwise it'll be the preferred
 
332
                # language of the mailing list.
 
333
                lang = self.getMemberLanguage(addr)
 
334
            else:
 
335
                # Throw away the realnames
 
336
                addr = [a for realname, a in addrs]
 
337
                # Which member language do we attempt to use?  We could use
 
338
                # the first match or the first address, but in the face of
 
339
                # ambiguity, let's just use the list's preferred language
 
340
                lang = self.preferred_language
 
341
            otrans = i18n.get_translation()
 
342
            i18n.set_language(lang)
 
343
            try:
 
344
                fmsg = Message.UserNotification(
 
345
                    addr, self.GetBouncesEmail(),
 
346
                    _('Forward of moderated message'),
 
347
                    lang=lang)
 
348
            finally:
 
349
                i18n.set_translation(otrans)
 
350
            fmsg.set_type('message/rfc822')
 
351
            fmsg.attach(copy)
 
352
            fmsg.send(self)
 
353
        # Log the rejection
 
354
        if rejection:
 
355
            note = '''%(listname)s: %(rejection)s posting:
 
356
\tFrom: %(sender)s
 
357
\tSubject: %(subject)s''' % {
 
358
                'listname' : self.internal_name(),
 
359
                'rejection': rejection,
 
360
                'sender'   : sender.replace('%', '%%'),
 
361
                'subject'  : subject.replace('%', '%%'),
 
362
                }
 
363
            if comment:
 
364
                note += '\n\tReason: ' + comment.replace('%', '%%')
 
365
            syslog('vette', note)
 
366
        # Always unlink the file containing the message text.  It's not
 
367
        # necessary anymore, regardless of the disposition of the message.
 
368
        if status <> DEFER:
 
369
            try:
 
370
                os.unlink(path)
 
371
            except OSError, e:
 
372
                if e.errno <> errno.ENOENT: raise
 
373
                # We lost the message text file.  Clean up our housekeeping
 
374
                # and inform of this status.
 
375
                return LOST
 
376
        return status
 
377
            
 
378
    def HoldSubscription(self, addr, fullname, password, digest, lang):
 
379
        # Assure that the database is open for writing
 
380
        self.__opendb()
 
381
        # Get the next unique id
 
382
        id = self.__request_id()
 
383
        assert not self.__db.has_key(id)
 
384
        #
 
385
        # Save the information to the request database. for held subscription
 
386
        # entries, each record in the database will be one of the following
 
387
        # format:
 
388
        #
 
389
        # the time the subscription request was received
 
390
        # the subscriber's address
 
391
        # the subscriber's selected password (TBD: is this safe???)
 
392
        # the digest flag
 
393
        # the user's preferred language
 
394
        #
 
395
        data = time.time(), addr, fullname, password, digest, lang
 
396
        self.__db[id] = (SUBSCRIPTION, data)
 
397
        #
 
398
        # TBD: this really shouldn't go here but I'm not sure where else is
 
399
        # appropriate.
 
400
        syslog('vette', '%s: held subscription request from %s',
 
401
               self.internal_name(), addr)
 
402
        # Possibly notify the administrator in default list language
 
403
        if self.admin_immed_notify:
 
404
            realname = self.real_name
 
405
            subject = _(
 
406
                'New subscription request to list %(realname)s from %(addr)s')
 
407
            text = Utils.maketext(
 
408
                'subauth.txt',
 
409
                {'username'   : addr,
 
410
                 'listname'   : self.internal_name(),
 
411
                 'hostname'   : self.host_name,
 
412
                 'admindb_url': self.GetScriptURL('admindb', absolute=1),
 
413
                 }, mlist=self)
 
414
            # This message should appear to come from the <list>-owner so as
 
415
            # to avoid any useless bounce processing.
 
416
            owneraddr = self.GetOwnerEmail()
 
417
            msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
 
418
                                           self.preferred_language)
 
419
            msg.send(self, **{'tomoderators': 1})
 
420
 
 
421
    def __handlesubscription(self, record, value, comment):
 
422
        stime, addr, fullname, password, digest, lang = record
 
423
        if value == mm_cfg.DEFER:
 
424
            return DEFER
 
425
        elif value == mm_cfg.DISCARD:
 
426
            pass
 
427
        elif value == mm_cfg.REJECT:
 
428
            self.__refuse(_('Subscription request'), addr,
 
429
                          comment or _('[No reason given]'),
 
430
                          lang=lang)
 
431
        else:
 
432
            # subscribe
 
433
            assert value == mm_cfg.SUBSCRIBE
 
434
            try:
 
435
                userdesc = UserDesc(addr, fullname, password, digest, lang)
 
436
                self.ApprovedAddMember(userdesc)
 
437
            except Errors.MMAlreadyAMember:
 
438
                # User has already been subscribed, after sending the request
 
439
                pass
 
440
            # TBD: disgusting hack: ApprovedAddMember() can end up closing
 
441
            # the request database.
 
442
            self.__opendb()
 
443
        return REMOVE
 
444
 
 
445
    def HoldUnsubscription(self, addr):
 
446
        # Assure the database is open for writing
 
447
        self.__opendb()
 
448
        # Get the next unique id
 
449
        id = self.__request_id()
 
450
        assert not self.__db.has_key(id)
 
451
        # All we need to do is save the unsubscribing address
 
452
        self.__db[id] = (UNSUBSCRIPTION, addr)
 
453
        syslog('vette', '%s: held unsubscription request from %s',
 
454
               self.internal_name(), addr)
 
455
        # Possibly notify the administrator of the hold
 
456
        if self.admin_immed_notify:
 
457
            realname = self.real_name
 
458
            subject = _(
 
459
                'New unsubscription request from %(realname)s by %(addr)s')
 
460
            text = Utils.maketext(
 
461
                'unsubauth.txt',
 
462
                {'username'   : addr,
 
463
                 'listname'   : self.internal_name(),
 
464
                 'hostname'   : self.host_name,
 
465
                 'admindb_url': self.GetScriptURL('admindb', absolute=1),
 
466
                 }, mlist=self)
 
467
            # This message should appear to come from the <list>-owner so as
 
468
            # to avoid any useless bounce processing.
 
469
            owneraddr = self.GetOwnerEmail()
 
470
            msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
 
471
                                           self.preferred_language)
 
472
            msg.send(self, **{'tomoderators': 1})
 
473
 
 
474
    def __handleunsubscription(self, record, value, comment):
 
475
        addr = record
 
476
        if value == mm_cfg.DEFER:
 
477
            return DEFER
 
478
        elif value == mm_cfg.DISCARD:
 
479
            pass
 
480
        elif value == mm_cfg.REJECT:
 
481
            self.__refuse(_('Unsubscription request'), addr, comment)
 
482
        else:
 
483
            assert value == mm_cfg.UNSUBSCRIBE
 
484
            try:
 
485
                self.ApprovedDeleteMember(addr)
 
486
            except Errors.NotAMemberError:
 
487
                # User has already been unsubscribed
 
488
                pass
 
489
        return REMOVE
 
490
 
 
491
    def __refuse(self, request, recip, comment, origmsg=None, lang=None):
 
492
        # As this message is going to the requestor, try to set the language
 
493
        # to his/her language choice, if they are a member.  Otherwise use the
 
494
        # list's preferred language.
 
495
        realname = self.real_name
 
496
        if lang is None:
 
497
            lang = self.getMemberLanguage(recip)
 
498
        text = Utils.maketext(
 
499
            'refuse.txt',
 
500
            {'listname' : realname,
 
501
             'request'  : request,
 
502
             'reason'   : comment,
 
503
             'adminaddr': self.GetOwnerEmail(),
 
504
            }, lang=lang, mlist=self)
 
505
        otrans = i18n.get_translation()
 
506
        i18n.set_language(lang)
 
507
        try:
 
508
            # add in original message, but not wrap/filled
 
509
            if origmsg:
 
510
                text = NL.join(
 
511
                    [text,
 
512
                     '---------- ' + _('Original Message') + ' ----------',
 
513
                     str(origmsg)
 
514
                     ])
 
515
            subject = _('Request to mailing list %(realname)s rejected')
 
516
        finally:
 
517
            i18n.set_translation(otrans)
 
518
        msg = Message.UserNotification(recip, self.GetBouncesEmail(),
 
519
                                       subject, text, lang)
 
520
        msg.send(self)
 
521
 
 
522
    def _UpdateRecords(self):
 
523
        # Subscription records have changed since MM2.0.x.  In that family,
 
524
        # the records were of length 4, containing the request time, the
 
525
        # address, the password, and the digest flag.  In MM2.1a2, they grew
 
526
        # an additional language parameter at the end.  In MM2.1a4, they grew
 
527
        # a fullname slot after the address.  This semi-public method is used
 
528
        # by the update script to coerce all subscription records to the
 
529
        # latest MM2.1 format.
 
530
        #
 
531
        # Held message records have historically either 5 or 6 items too.
 
532
        # These always include the requests time, the sender, subject, default
 
533
        # rejection reason, and message text.  When of length 6, it also
 
534
        # includes the message metadata dictionary on the end of the tuple.
 
535
        self.__opendb()
 
536
        for id, (type, info) in self.__db.items():
 
537
            if type == SUBSCRIPTION:
 
538
                if len(info) == 4:
 
539
                    # pre-2.1a2 compatibility
 
540
                    when, addr, passwd, digest = info
 
541
                    fullname = ''
 
542
                    lang = self.preferred_language
 
543
                elif len(info) == 5:
 
544
                    # pre-2.1a4 compatibility
 
545
                    when, addr, passwd, digest, lang = info
 
546
                    fullname = ''
 
547
                else:
 
548
                    assert len(info) == 6, 'Unknown subscription record layout'
 
549
                    continue
 
550
                # Here's the new layout
 
551
                self.__db[id] = when, addr, fullname, passwd, digest, lang
 
552
            elif type == HELDMSG:
 
553
                if len(info) == 5:
 
554
                    when, sender, subject, reason, text = info
 
555
                    msgdata = {}
 
556
                else:
 
557
                    assert len(info) == 6, 'Unknown held msg record layout'
 
558
                    continue
 
559
                # Here's the new layout
 
560
                self.__db[id] = when, sender, subject, reason, text, msgdata
 
561
        # All done
 
562
        self.__closedb()
 
563
 
 
564
 
 
565
 
 
566
def readMessage(path):
 
567
    # For backwards compatibility, we must be able to read either a flat text
 
568
    # file or a pickle.
 
569
    ext = os.path.splitext(path)[1]
 
570
    fp = open(path)
 
571
    try:
 
572
        if ext == '.txt':
 
573
            msg = email.message_from_file(fp, Message.Message)
 
574
        else:
 
575
            assert ext == '.pck'
 
576
            msg = cPickle.load(fp)
 
577
    finally:
 
578
        fp.close()
 
579
    return msg