Merge branch 'typo-documentaion-documentation' into 'master'
[mailman.git] / src / mailman / rest / lists.py
blob85a2c1a0e83e37756d1efc380e9f72ff7b482142
1 # Copyright (C) 2010-2023 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 <https://www.gnu.org/licenses/>.
18 """REST for mailing lists."""
20 from lazr.config import as_boolean
21 from mailman.app.digests import (
22 bump_digest_number_and_volume,
23 maybe_send_digest_now,
25 from mailman.app.lifecycle import (
26 create_list,
27 InvalidListNameError,
28 remove_list,
30 from mailman.config import config
31 from mailman.interfaces.address import InvalidEmailAddressError
32 from mailman.interfaces.domain import BadDomainSpecificationError
33 from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError
34 from mailman.interfaces.mailinglist import IListArchiverSet
35 from mailman.interfaces.member import MemberRole
36 from mailman.interfaces.styles import IStyleManager
37 from mailman.interfaces.subscriptions import ISubscriptionService
38 from mailman.rest.bans import BannedEmails
39 from mailman.rest.header_matches import HeaderMatches
40 from mailman.rest.helpers import (
41 accepted,
42 bad_request,
43 BadRequest,
44 child,
45 CollectionMixin,
46 created,
47 etag,
48 GetterSetter,
49 no_content,
50 not_found,
51 NotFound,
52 okay,
54 from mailman.rest.listconf import ListConfiguration
55 from mailman.rest.members import AMember, MemberCollection
56 from mailman.rest.post_moderation import HeldMessages
57 from mailman.rest.sub_moderation import SubscriptionRequests
58 from mailman.rest.uris import AListURI, AllListURIs
59 from mailman.rest.validator import (
60 enum_validator,
61 list_of_strings_validator,
62 subscriber_validator,
63 Validator,
65 from public import public
66 from zope.component import getUtility
69 def member_matcher(segments):
70 """A matcher of member URLs inside mailing lists.
72 e.g. /<role>/aperson@example.org
73 """
74 if len(segments) != 2:
75 return None
76 try:
77 role = MemberRole[segments[0]]
78 except KeyError:
79 # Not a valid role.
80 return None
81 return (), dict(role=role, email=segments[1]), ()
84 def roster_matcher(segments):
85 """A matcher of all members URLs inside mailing lists.
87 e.g. /roster/<role>
88 """
89 if len(segments) != 2 or segments[0] != 'roster':
90 return None
91 try:
92 return (), dict(role=MemberRole[segments[1]]), ()
93 except KeyError:
94 # Not a valid role.
95 return None
98 def config_matcher(segments):
99 """A matcher for a mailing list's configuration resource.
101 e.g. /config
102 e.g. /config/description
104 if len(segments) < 1 or segments[0] != 'config':
105 return None
106 if len(segments) == 1:
107 return (), {}, ()
108 if len(segments) == 2:
109 return (), dict(attribute=segments[1]), ()
110 # More segments are not allowed.
111 return None
114 class _ListBase(CollectionMixin):
115 """Shared base class for mailing list representations."""
117 def _resource_as_dict(self, mlist):
118 """See `CollectionMixin`."""
119 return dict(
120 advertised=mlist.advertised,
121 display_name=mlist.display_name,
122 fqdn_listname=mlist.fqdn_listname,
123 list_id=mlist.list_id,
124 list_name=mlist.list_name,
125 mail_host=mlist.mail_host,
126 member_count=mlist.members.member_count,
127 volume=mlist.volume,
128 description=mlist.description,
129 self_link=self.api.path_to('lists/{}'.format(mlist.list_id)),
132 def _get_collection(self, request):
133 """See `CollectionMixin`."""
134 return self._filter_lists(request)
136 def _filter_lists(self, request, **kw):
137 """Filter a collection using query parameters."""
138 advertised = request.get_param_as_bool('advertised')
139 if advertised:
140 kw['advertised'] = True
141 return getUtility(IListManager).find(**kw)
144 class _ListOfLists(_ListBase):
145 """An abstract class to return a sub-set of Lists.
147 This is used for filtering Lists based on some parameters.
149 def __init__(self, lists, api):
150 super().__init__()
151 self._lists = lists
152 self.api = api
154 def _get_collection(self, request):
155 return self._lists
158 @public
159 class FindLists(_ListBase):
160 """The mailing lists that a user is a member of."""
162 def on_get(self, request, response):
163 return self._find(request, response)
165 def on_post(self, request, response):
166 return self._find(request, response)
168 def _find(self, request, response):
169 validator = Validator(
170 subscriber=subscriber_validator(self.api),
171 role=enum_validator(MemberRole),
172 # Allow pagination.
173 page=int,
174 count=int,
175 _optional=('role', 'page', 'count'))
176 try:
177 data = validator(request)
178 except ValueError as error:
179 bad_request(response, str(error))
180 return
181 else:
182 # Remove any optional pagination query elements.
183 data.pop('page', None)
184 data.pop('count', None)
185 service = getUtility(ISubscriptionService)
186 # Get all membership records for given subscriber.
187 memberships = service.find_members(**data)
188 # Get all the lists from from the membership records.
189 lists = [getUtility(IListManager).get_by_list_id(member.list_id)
190 for member in memberships
191 if 'role' in data or member.role != MemberRole.nonmember]
192 # If there are no matching lists, return a 404.
193 if not len(lists):
194 return not_found(response)
195 resource = _ListOfLists(lists, self.api)
196 okay(response, etag(resource._make_collection(request)))
199 @public
200 class AList(_ListBase):
201 """A mailing list."""
203 def __init__(self, list_identifier):
204 # list-id is preferred, but for backward compatibility, fqdn_listname
205 # is also accepted. If the string contains '@', treat it as the
206 # latter.
207 manager = getUtility(IListManager)
208 if '@' in list_identifier:
209 self._mlist = manager.get(list_identifier)
210 else:
211 self._mlist = manager.get_by_list_id(list_identifier)
213 def on_get(self, request, response):
214 """Return a single mailing list end-point."""
215 if self._mlist is None:
216 not_found(response)
217 else:
218 okay(response, self._resource_as_json(self._mlist))
220 def on_delete(self, request, response):
221 """Delete the named mailing list."""
222 if self._mlist is None:
223 not_found(response)
224 else:
225 remove_list(self._mlist)
226 no_content(response)
228 @child(member_matcher)
229 def member(self, context, segments, role, email):
230 """Return a single member representation."""
231 if self._mlist is None:
232 return NotFound(), []
233 member = getUtility(ISubscriptionService).find_member(
234 email, self._mlist.list_id, role)
235 if member is None:
236 return NotFound(), []
237 return AMember(member.member_id)
239 @child(roster_matcher)
240 def roster(self, context, segments, role):
241 """Return the collection of all a mailing list's members."""
242 if self._mlist is None:
243 return NotFound(), []
244 return MembersOfList(self._mlist, role)
246 @child(config_matcher)
247 def config(self, context, segments, attribute=None):
248 """Return a mailing list configuration object."""
249 if self._mlist is None:
250 return NotFound(), []
251 return ListConfiguration(self._mlist, attribute)
253 @child()
254 def held(self, context, segments):
255 """Return a list of held messages for the mailing list."""
256 if self._mlist is None:
257 return NotFound(), []
258 return HeldMessages(self._mlist)
260 @child()
261 def requests(self, context, segments):
262 """Return a list of subscription/unsubscription requests."""
263 if self._mlist is None:
264 return NotFound(), []
265 return SubscriptionRequests(self._mlist)
267 @child()
268 def archivers(self, context, segments):
269 """Return a representation of mailing list archivers."""
270 if self._mlist is None:
271 return NotFound(), []
272 return ListArchivers(self._mlist)
274 @child()
275 def digest(self, context, segments):
276 if self._mlist is None:
277 return NotFound(), []
278 return ListDigest(self._mlist)
280 @child()
281 def bans(self, context, segments):
282 """Return a collection of mailing list's banned addresses."""
283 if self._mlist is None:
284 return NotFound(), []
285 return BannedEmails(self._mlist)
287 @child(r'^header-matches')
288 def header_matches(self, context, segments):
289 """Return a collection of mailing list's header matches."""
290 if self._mlist is None:
291 return NotFound(), []
292 return HeaderMatches(self._mlist)
294 @child()
295 def uris(self, context, segments):
296 """Return the template URIs of the mailing list.
298 These are only available after API 3.0.
300 if self._mlist is None or self.api.version_info < (3, 1):
301 return NotFound(), []
302 if len(segments) == 0:
303 return AllListURIs(self._mlist)
304 if len(segments) > 1:
305 return BadRequest(), []
306 template = segments[0]
307 if template not in AllListURIs.URIs:
308 return NotFound(), []
309 return AListURI(self._mlist, template), []
312 @public
313 class AllLists(_ListBase):
314 """The mailing lists."""
316 def on_post(self, request, response):
317 """Create a new mailing list."""
318 try:
319 validator = Validator(fqdn_listname=str,
320 style_name=str,
321 _optional=('style_name',))
322 mlist = create_list(**validator(request))
323 except ValueError as error:
324 bad_request(response, str(error))
325 except ListAlreadyExistsError:
326 bad_request(response, b'Mailing list exists')
327 except BadDomainSpecificationError as error:
328 reason = 'Domain does not exist: {}'.format(error.domain)
329 bad_request(response, reason.encode('utf-8'))
330 except InvalidListNameError as error:
331 reason = 'Invalid list name: {}'.format(error.listname)
332 bad_request(response, reason.encode('utf-8'))
333 except InvalidEmailAddressError as error:
334 reason = 'Invalid list posting address: {}'.format(error.email)
335 bad_request(response, reason.encode('utf-8'))
336 else:
337 location = self.api.path_to('lists/{0}'.format(mlist.list_id))
338 created(response, location)
340 def on_get(self, request, response):
341 """/lists"""
342 resource = self._make_collection(request)
343 okay(response, etag(resource))
346 @public
347 class MembersOfList(MemberCollection):
348 """The members of a mailing list."""
350 def __init__(self, mailing_list, role):
351 super().__init__()
352 self._mlist = mailing_list
353 self._role = role
355 def _get_collection(self, request):
356 """See `CollectionMixin`."""
357 # Overrides _MemberBase._get_collection() because we only want to
358 # return the members from the contexted roster.
359 return getUtility(ISubscriptionService).find_members(
360 list_id=self._mlist.list_id,
361 role=self._role)
363 def on_delete(self, request, response):
364 """Delete the members of the named mailing list."""
365 status = {}
366 try:
367 validator = Validator(emails=list_of_strings_validator)
368 arguments = validator(request)
369 except ValueError as error:
370 bad_request(response, str(error))
371 return
372 emails = arguments.pop('emails')
373 success, fail = getUtility(ISubscriptionService).unsubscribe_members(
374 self._mlist.list_id, emails)
375 # There should be no email in both sets.
376 assert success.isdisjoint(fail), (success, fail)
377 status.update({email: True for email in success})
378 status.update({email: False for email in fail})
379 okay(response, etag(status))
382 @public
383 class ListsForDomain(_ListBase):
384 """The mailing lists for a particular domain."""
386 def __init__(self, domain):
387 self._domain = domain
389 def on_get(self, request, response):
390 """/domains/<domain>/lists"""
391 resource = self._make_collection(request)
392 okay(response, etag(resource))
394 def _get_collection(self, request):
395 """See `CollectionMixin`."""
396 return self._filter_lists(request, mail_host=self._domain.mail_host)
399 @public
400 class ArchiverGetterSetter(GetterSetter):
401 """Resource for updating archiver statuses."""
403 def __init__(self, mlist):
404 super().__init__()
405 self._archiver_set = IListArchiverSet(mlist)
407 def put(self, mlist, attribute, value):
408 # attribute will contain the (bytes) name of the archiver that is
409 # getting a new status. value will be the representation of the new
410 # boolean status.
411 archiver = self._archiver_set.get(attribute)
412 assert archiver is not None, attribute
413 archiver.is_enabled = as_boolean(value)
416 @public
417 class ListArchivers:
418 """The archivers for a list, with their enabled flags."""
420 def __init__(self, mlist):
421 self._mlist = mlist
423 def on_get(self, request, response):
424 """Get all the archiver statuses."""
425 archiver_set = IListArchiverSet(self._mlist)
426 resource = {archiver.name: archiver.is_enabled
427 for archiver in archiver_set.archivers
428 if archiver.system_archiver.is_enabled}
429 okay(response, etag(resource))
431 def patch_put(self, request, response, is_optional):
432 archiver_set = IListArchiverSet(self._mlist)
433 kws = {archiver.name: ArchiverGetterSetter(self._mlist)
434 for archiver in archiver_set.archivers
435 if archiver.system_archiver.is_enabled}
436 if is_optional:
437 # For a PATCH, all attributes are optional.
438 kws['_optional'] = kws.keys()
439 try:
440 Validator(**kws).update(self._mlist, request)
441 except ValueError as error:
442 bad_request(response, str(error))
443 else:
444 no_content(response)
446 def on_put(self, request, response):
447 """Update all the archiver statuses."""
448 self.patch_put(request, response, is_optional=False)
450 def on_patch(self, request, response):
451 """Patch some archiver statueses."""
452 self.patch_put(request, response, is_optional=True)
455 @public
456 class ListDigest:
457 """Simple resource representing actions on a list's digest."""
459 def __init__(self, mlist):
460 self._mlist = mlist
462 def on_get(self, request, response):
463 resource = dict(
464 next_digest_number=self._mlist.next_digest_number,
465 volume=self._mlist.volume,
467 okay(response, etag(resource))
469 def on_post(self, request, response):
470 try:
471 validator = Validator(
472 send=as_boolean,
473 bump=as_boolean,
474 periodic=as_boolean,
475 _optional=('send', 'bump', 'periodic'))
476 values = validator(request)
477 except ValueError as error:
478 bad_request(response, str(error))
479 return
480 if values.get('send', False) and values.get('periodic', False):
481 # Send and periodic and mutually exclusive options.
482 bad_request(
483 response, 'send and periodic options are mutually exclusive')
484 return
485 if len(values) == 0:
486 # There's nothing to do, but that's okay.
487 okay(response)
488 return
489 if values.get('bump', False):
490 bump_digest_number_and_volume(self._mlist)
491 if values.get('send', False):
492 maybe_send_digest_now(self._mlist, force=True)
493 if values.get('periodic', False) and self._mlist.digest_send_periodic:
494 maybe_send_digest_now(self._mlist, force=True)
495 accepted(response)
498 @public
499 class Styles:
500 """Simple resource representing all list styles."""
502 def __init__(self):
503 manager = getUtility(IStyleManager)
504 styles = [dict(name=style.name, description=style.description)
505 for style in manager.styles]
506 style_names = sorted(style.name for style in manager.styles)
507 self._resource = dict(
508 # TODO (maxking): style_name is meant for backwards compatibility
509 # and should be removed in 3.3 release.
510 style_names=style_names,
511 styles=styles,
512 default=config.styles.default)
514 def on_get(self, request, response):
515 okay(response, etag(self._resource))