Don't use `flake8: noqa`.
[mailman.git] / src / mailman / app / moderator.py
blob2f8c19cfc19e1b9bd7b61dc2b8b6a9ebcecbfefe
1 # Copyright (C) 2007-2016 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)
8 # any later version.
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
13 # more details.
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 """Application support for moderators."""
20 import time
21 import logging
23 from email.utils import formatdate, getaddresses, make_msgid
24 from mailman import public
25 from mailman.app.membership import delete_member
26 from mailman.config import config
27 from mailman.core.i18n import _
28 from mailman.email.message import UserNotification
29 from mailman.interfaces.action import Action
30 from mailman.interfaces.listmanager import ListDeletingEvent
31 from mailman.interfaces.member import NotAMemberError
32 from mailman.interfaces.messages import IMessageStore
33 from mailman.interfaces.requests import IListRequests, RequestType
34 from mailman.utilities.datetime import now
35 from mailman.utilities.i18n import make
36 from zope.component import getUtility
39 NL = '\n'
41 vlog = logging.getLogger('mailman.vette')
42 slog = logging.getLogger('mailman.subscribe')
45 @public
46 def hold_message(mlist, msg, msgdata=None, reason=None):
47 """Hold a message for moderator approval.
49 The message is added to the mailing list's request database.
51 :param mlist: The mailing list to hold the message on.
52 :param msg: The message to hold.
53 :param msgdata: Optional message metadata to hold. If not given, a new
54 metadata dictionary is created and held with the message.
55 :param reason: Optional string reason why the message is being held. If
56 not given, the empty string is used.
57 :return: An id used to handle the held message later.
58 """
59 if msgdata is None:
60 msgdata = {}
61 else:
62 # Make a copy of msgdata so that subsequent changes won't corrupt the
63 # request database. TBD: remove the `filebase' key since this will
64 # not be relevant when the message is resurrected.
65 msgdata = msgdata.copy()
66 if reason is None:
67 reason = ''
68 # Add the message to the message store. It is required to have a
69 # Message-ID header.
70 message_id = msg.get('message-id')
71 if message_id is None:
72 msg['Message-ID'] = message_id = make_msgid()
73 elif isinstance(message_id, bytes):
74 message_id = message_id.decode('ascii')
75 getUtility(IMessageStore).add(msg)
76 # Prepare the message metadata with some extra information needed only by
77 # the moderation interface.
78 msgdata['_mod_message_id'] = message_id
79 msgdata['_mod_listid'] = mlist.list_id
80 msgdata['_mod_sender'] = msg.sender
81 msgdata['_mod_subject'] = msg.get('subject', _('(no subject)'))
82 msgdata['_mod_reason'] = reason
83 msgdata['_mod_hold_date'] = now().isoformat()
84 # Now hold this request. We'll use the message_id as the key.
85 requestsdb = IListRequests(mlist)
86 request_id = requestsdb.hold_request(
87 RequestType.held_message, message_id, msgdata)
88 return request_id
91 @public
92 def handle_message(mlist, id, action, comment=None, forward=None):
93 message_store = getUtility(IMessageStore)
94 requestdb = IListRequests(mlist)
95 key, msgdata = requestdb.get_request(id)
96 # Handle the action.
97 rejection = None
98 message_id = msgdata['_mod_message_id']
99 sender = msgdata['_mod_sender']
100 subject = msgdata['_mod_subject']
101 keep = False
102 if action in (Action.defer, Action.hold):
103 # Nothing to do, but preserve the message for later.
104 keep = True
105 elif action is Action.discard:
106 rejection = 'Discarded'
107 elif action is Action.reject:
108 rejection = 'Refused'
109 member = mlist.members.get_member(sender)
110 if member:
111 language = member.preferred_language
112 else:
113 language = None
114 send_rejection(
115 mlist, _('Posting of your message titled "$subject"'),
116 sender, comment or _('[No reason given]'), language)
117 elif action is Action.accept:
118 # Start by getting the message from the message store.
119 msg = message_store.get_message_by_id(message_id)
120 # Delete moderation-specific entries from the message metadata.
121 for key in list(msgdata):
122 if key.startswith('_mod_'):
123 del msgdata[key]
124 # Add some metadata to indicate this message has now been approved.
125 msgdata['approved'] = True
126 msgdata['moderator_approved'] = True
127 # Calculate a new filebase for the approved message, otherwise
128 # delivery errors will cause duplicates.
129 if 'filebase' in msgdata:
130 del msgdata['filebase']
131 # Queue the file for delivery. Trying to deliver the message directly
132 # here can lead to a huge delay in web turnaround. Log the moderation
133 # and add a header.
134 msg['X-Mailman-Approved-At'] = formatdate(
135 time.mktime(now().timetuple()), localtime=True)
136 vlog.info('held message approved, message-id: %s',
137 msg.get('message-id', 'n/a'))
138 # Stick the message back in the incoming queue for further
139 # processing.
140 config.switchboards['pipeline'].enqueue(msg, _metadata=msgdata)
141 else:
142 raise AssertionError('Unexpected action: {0}'.format(action))
143 # Forward the message.
144 if forward:
145 # Get a copy of the original message from the message store.
146 msg = message_store.get_message_by_id(message_id)
147 # It's possible the forwarding address list is a comma separated list
148 # of display_name/address pairs.
149 addresses = [addr[1] for addr in getaddresses(forward)]
150 language = mlist.preferred_language
151 if len(addresses) == 1:
152 # If the address getting the forwarded message is a member of
153 # the list, we want the headers of the outer message to be
154 # encoded in their language. Otherwise it'll be the preferred
155 # language of the mailing list. This is better than sending a
156 # separate message per recipient.
157 member = mlist.members.get_member(addresses[0])
158 if member:
159 language = member.preferred_language
160 with _.using(language.code):
161 fmsg = UserNotification(
162 addresses, mlist.bounces_address,
163 _('Forward of moderated message'),
164 lang=language)
165 fmsg.set_type('message/rfc822')
166 fmsg.attach(msg)
167 fmsg.send(mlist)
168 # Delete the request if it's not being kept.
169 if not keep:
170 requestdb.delete_request(id)
171 # Log the rejection
172 if rejection:
173 note = """%s: %s posting:
174 \tFrom: %s
175 \tSubject: %s"""
176 if comment:
177 note += '\n\tReason: ' + comment
178 vlog.info(note, mlist.fqdn_listname, rejection, sender, subject)
181 @public
182 def hold_unsubscription(mlist, email):
183 data = dict(email=email)
184 requestsdb = IListRequests(mlist)
185 request_id = requestsdb.hold_request(
186 RequestType.unsubscription, email, data)
187 vlog.info('%s: held unsubscription request from %s',
188 mlist.fqdn_listname, email)
189 # Possibly notify the administrator of the hold
190 if mlist.admin_immed_notify:
191 subject = _(
192 'New unsubscription request from $mlist.display_name by $email')
193 text = make('unsubauth.txt',
194 mailing_list=mlist,
195 email=email,
196 listname=mlist.fqdn_listname,
197 admindb_url=mlist.script_url('admindb'),
199 # This message should appear to come from the <list>-owner so as
200 # to avoid any useless bounce processing.
201 msg = UserNotification(
202 mlist.owner_address, mlist.owner_address,
203 subject, text, mlist.preferred_language)
204 msg.send(mlist, tomoderators=True)
205 return request_id
208 @public
209 def handle_unsubscription(mlist, id, action, comment=None):
210 requestdb = IListRequests(mlist)
211 key, data = requestdb.get_request(id)
212 email = data['email']
213 if action is Action.defer:
214 # Nothing to do.
215 return
216 elif action is Action.discard:
217 # Nothing to do except delete the request from the database.
218 pass
219 elif action is Action.reject:
220 key, data = requestdb.get_request(id)
221 send_rejection(
222 mlist, _('Unsubscription request'), email,
223 comment or _('[No reason given]'))
224 elif action is Action.accept:
225 key, data = requestdb.get_request(id)
226 try:
227 delete_member(mlist, email)
228 except NotAMemberError:
229 # User has already been unsubscribed.
230 pass
231 slog.info('%s: deleted %s', mlist.fqdn_listname, email)
232 else:
233 raise AssertionError('Unexpected action: {0}'.format(action))
234 # Delete the request from the database.
235 requestdb.delete_request(id)
238 @public
239 def send_rejection(mlist, request, recip, comment, origmsg=None, lang=None):
240 # As this message is going to the requester, try to set the language to
241 # his/her language choice, if they are a member. Otherwise use the list's
242 # preferred language.
243 display_name = mlist.display_name # noqa
244 if lang is None:
245 member = mlist.members.get_member(recip)
246 lang = (mlist.preferred_language
247 if member is None
248 else member.preferred_language)
249 text = make('refuse.txt',
250 mailing_list=mlist,
251 language=lang.code,
252 listname=mlist.fqdn_listname,
253 request=request,
254 reason=comment,
255 adminaddr=mlist.owner_address,
257 with _.using(lang.code):
258 # add in original message, but not wrap/filled
259 if origmsg:
260 text = NL.join(
261 [text,
262 '---------- ' + _('Original Message') + ' ----------',
263 str(origmsg)
265 subject = _('Request to mailing list "$display_name" rejected')
266 msg = UserNotification(recip, mlist.bounces_address, subject, text, lang)
267 msg.send(mlist)
270 @public
271 def handle_ListDeletingEvent(event):
272 if not isinstance(event, ListDeletingEvent):
273 return
274 # Get the held requests database for the mailing list. Since the mailing
275 # list is about to get deleted, we can delete all associated requests.
276 requestsdb = IListRequests(event.mailing_list)
277 for request in requestsdb.held_requests:
278 requestsdb.delete_request(request.id)