1 # Copyright (C) 2010-2019 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)
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
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 """REST for mailing lists."""
20 from lazr
.config
import as_boolean
21 from mailman
.app
.digests
import (
22 bump_digest_number_and_volume
, maybe_send_digest_now
)
23 from mailman
.app
.lifecycle
import (
24 InvalidListNameError
, create_list
, remove_list
)
25 from mailman
.config
import config
26 from mailman
.interfaces
.address
import InvalidEmailAddressError
27 from mailman
.interfaces
.domain
import BadDomainSpecificationError
28 from mailman
.interfaces
.listmanager
import (
29 IListManager
, ListAlreadyExistsError
)
30 from mailman
.interfaces
.mailinglist
import IListArchiverSet
31 from mailman
.interfaces
.member
import MemberRole
32 from mailman
.interfaces
.styles
import IStyleManager
33 from mailman
.interfaces
.subscriptions
import ISubscriptionService
34 from mailman
.rest
.bans
import BannedEmails
35 from mailman
.rest
.header_matches
import HeaderMatches
36 from mailman
.rest
.helpers
import (
37 BadRequest
, CollectionMixin
, GetterSetter
, NotFound
, accepted
,
38 bad_request
, child
, created
, etag
, no_content
, not_found
, okay
)
39 from mailman
.rest
.listconf
import ListConfiguration
40 from mailman
.rest
.members
import AMember
, MemberCollection
41 from mailman
.rest
.post_moderation
import HeldMessages
42 from mailman
.rest
.sub_moderation
import SubscriptionRequests
43 from mailman
.rest
.uris
import AListURI
, AllListURIs
44 from mailman
.rest
.validator
import (
45 Validator
, enum_validator
, list_of_strings_validator
, subscriber_validator
)
46 from public
import public
47 from zope
.component
import getUtility
50 def member_matcher(segments
):
51 """A matcher of member URLs inside mailing lists.
53 e.g. /<role>/aperson@example.org
55 if len(segments
) != 2:
58 role
= MemberRole
[segments
[0]]
62 return (), dict(role
=role
, email
=segments
[1]), ()
65 def roster_matcher(segments
):
66 """A matcher of all members URLs inside mailing lists.
70 if len(segments
) != 2 or segments
[0] != 'roster':
73 return (), dict(role
=MemberRole
[segments
[1]]), ()
79 def config_matcher(segments
):
80 """A matcher for a mailing list's configuration resource.
83 e.g. /config/description
85 if len(segments
) < 1 or segments
[0] != 'config':
87 if len(segments
) == 1:
89 if len(segments
) == 2:
90 return (), dict(attribute
=segments
[1]), ()
91 # More segments are not allowed.
95 class _ListBase(CollectionMixin
):
96 """Shared base class for mailing list representations."""
98 def _resource_as_dict(self
, mlist
):
99 """See `CollectionMixin`."""
101 display_name
=mlist
.display_name
,
102 fqdn_listname
=mlist
.fqdn_listname
,
103 list_id
=mlist
.list_id
,
104 list_name
=mlist
.list_name
,
105 mail_host
=mlist
.mail_host
,
106 member_count
=mlist
.members
.member_count
,
108 description
=mlist
.description
,
109 self_link
=self
.api
.path_to('lists/{}'.format(mlist
.list_id
)),
112 def _get_collection(self
, request
):
113 """See `CollectionMixin`."""
114 return self
._filter
_lists
(request
)
116 def _filter_lists(self
, request
, **kw
):
117 """Filter a collection using query parameters."""
118 advertised
= request
.get_param_as_bool('advertised')
120 kw
['advertised'] = True
121 return getUtility(IListManager
).find(**kw
)
124 class _ListOfLists(_ListBase
):
125 """An abstract class to return a sub-set of Lists.
127 This is used for filtering Lists based on some parameters.
129 def __init__(self
, lists
, api
):
134 def _get_collection(self
, request
):
139 class FindLists(_ListBase
):
140 """The mailing lists that a user is a member of."""
142 def on_get(self
, request
, response
):
143 return self
._find
(request
, response
)
145 def on_post(self
, request
, response
):
146 return self
._find
(request
, response
)
148 def _find(self
, request
, response
):
149 validator
= Validator(
150 subscriber
=subscriber_validator(self
.api
),
151 role
=enum_validator(MemberRole
),
155 _optional
=('role', 'page', 'count'))
157 data
= validator(request
)
158 except ValueError as error
:
159 bad_request(response
, str(error
))
162 # Remove any optional pagination query elements.
163 data
.pop('page', None)
164 data
.pop('count', None)
165 service
= getUtility(ISubscriptionService
)
166 # Get all membership records for given subscriber.
167 memberships
= service
.find_members(**data
)
168 # Get all the lists from from the membership records.
169 lists
= [getUtility(IListManager
).get_by_list_id(member
.list_id
)
170 for member
in memberships
]
171 # If there are no matching lists, return a 404.
173 return not_found(response
)
174 resource
= _ListOfLists(lists
, self
.api
)
175 okay(response
, etag(resource
._make
_collection
(request
)))
179 class AList(_ListBase
):
180 """A mailing list."""
182 def __init__(self
, list_identifier
):
183 # list-id is preferred, but for backward compatibility, fqdn_listname
184 # is also accepted. If the string contains '@', treat it as the
186 manager
= getUtility(IListManager
)
187 if '@' in list_identifier
:
188 self
._mlist
= manager
.get(list_identifier
)
190 self
._mlist
= manager
.get_by_list_id(list_identifier
)
192 def on_get(self
, request
, response
):
193 """Return a single mailing list end-point."""
194 if self
._mlist
is None:
197 okay(response
, self
._resource
_as
_json
(self
._mlist
))
199 def on_delete(self
, request
, response
):
200 """Delete the named mailing list."""
201 if self
._mlist
is None:
204 remove_list(self
._mlist
)
207 @child(member_matcher
)
208 def member(self
, context
, segments
, role
, email
):
209 """Return a single member representation."""
210 if self
._mlist
is None:
211 return NotFound(), []
212 member
= getUtility(ISubscriptionService
).find_member(
213 email
, self
._mlist
.list_id
, role
)
215 return NotFound(), []
216 return AMember(member
.member_id
)
218 @child(roster_matcher
)
219 def roster(self
, context
, segments
, role
):
220 """Return the collection of all a mailing list's members."""
221 if self
._mlist
is None:
222 return NotFound(), []
223 return MembersOfList(self
._mlist
, role
)
225 @child(config_matcher
)
226 def config(self
, context
, segments
, attribute
=None):
227 """Return a mailing list configuration object."""
228 if self
._mlist
is None:
229 return NotFound(), []
230 return ListConfiguration(self
._mlist
, attribute
)
233 def held(self
, context
, segments
):
234 """Return a list of held messages for the mailing list."""
235 if self
._mlist
is None:
236 return NotFound(), []
237 return HeldMessages(self
._mlist
)
240 def requests(self
, context
, segments
):
241 """Return a list of subscription/unsubscription requests."""
242 if self
._mlist
is None:
243 return NotFound(), []
244 return SubscriptionRequests(self
._mlist
)
247 def archivers(self
, context
, segments
):
248 """Return a representation of mailing list archivers."""
249 if self
._mlist
is None:
250 return NotFound(), []
251 return ListArchivers(self
._mlist
)
254 def digest(self
, context
, segments
):
255 if self
._mlist
is None:
256 return NotFound(), []
257 return ListDigest(self
._mlist
)
260 def bans(self
, context
, segments
):
261 """Return a collection of mailing list's banned addresses."""
262 if self
._mlist
is None:
263 return NotFound(), []
264 return BannedEmails(self
._mlist
)
266 @child(r
'^header-matches')
267 def header_matches(self
, context
, segments
):
268 """Return a collection of mailing list's header matches."""
269 if self
._mlist
is None:
270 return NotFound(), []
271 return HeaderMatches(self
._mlist
)
274 def uris(self
, context
, segments
):
275 """Return the template URIs of the mailing list.
277 These are only available after API 3.0.
279 if self
._mlist
is None or self
.api
.version_info
< (3, 1):
280 return NotFound(), []
281 if len(segments
) == 0:
282 return AllListURIs(self
._mlist
)
283 if len(segments
) > 1:
284 return BadRequest(), []
285 template
= segments
[0]
286 if template
not in AllListURIs
.URIs
:
287 return NotFound(), []
288 return AListURI(self
._mlist
, template
), []
292 class AllLists(_ListBase
):
293 """The mailing lists."""
295 def on_post(self
, request
, response
):
296 """Create a new mailing list."""
298 validator
= Validator(fqdn_listname
=str,
300 _optional
=('style_name',))
301 mlist
= create_list(**validator(request
))
302 except ListAlreadyExistsError
:
303 bad_request(response
, b
'Mailing list exists')
304 except BadDomainSpecificationError
as error
:
305 reason
= 'Domain does not exist: {}'.format(error
.domain
)
306 bad_request(response
, reason
.encode('utf-8'))
307 except InvalidListNameError
as error
:
308 reason
= 'Invalid list name: {}'.format(error
.listname
)
309 bad_request(response
, reason
.encode('utf-8'))
310 except InvalidEmailAddressError
as error
:
311 reason
= 'Invalid list posting address: {}'.format(error
.email
)
312 bad_request(response
, reason
.encode('utf-8'))
314 location
= self
.api
.path_to('lists/{0}'.format(mlist
.list_id
))
315 created(response
, location
)
317 def on_get(self
, request
, response
):
319 resource
= self
._make
_collection
(request
)
320 okay(response
, etag(resource
))
324 class MembersOfList(MemberCollection
):
325 """The members of a mailing list."""
327 def __init__(self
, mailing_list
, role
):
329 self
._mlist
= mailing_list
332 def _get_collection(self
, request
):
333 """See `CollectionMixin`."""
334 # Overrides _MemberBase._get_collection() because we only want to
335 # return the members from the contexted roster.
336 return getUtility(ISubscriptionService
).find_members(
337 list_id
=self
._mlist
.list_id
,
340 def on_delete(self
, request
, response
):
341 """Delete the members of the named mailing list."""
344 validator
= Validator(emails
=list_of_strings_validator
)
345 arguments
= validator(request
)
346 except ValueError as error
:
347 bad_request(response
, str(error
))
349 emails
= arguments
.pop('emails')
350 success
, fail
= getUtility(ISubscriptionService
).unsubscribe_members(
351 self
._mlist
.list_id
, emails
)
352 # There should be no email in both sets.
353 assert success
.isdisjoint(fail
), (success
, fail
)
354 status
.update({email
: True for email
in success
})
355 status
.update({email
: False for email
in fail
})
356 okay(response
, etag(status
))
360 class ListsForDomain(_ListBase
):
361 """The mailing lists for a particular domain."""
363 def __init__(self
, domain
):
364 self
._domain
= domain
366 def on_get(self
, request
, response
):
367 """/domains/<domain>/lists"""
368 resource
= self
._make
_collection
(request
)
369 okay(response
, etag(resource
))
371 def _get_collection(self
, request
):
372 """See `CollectionMixin`."""
373 return self
._filter
_lists
(request
, mail_host
=self
._domain
.mail_host
)
377 class ArchiverGetterSetter(GetterSetter
):
378 """Resource for updating archiver statuses."""
380 def __init__(self
, mlist
):
382 self
._archiver
_set
= IListArchiverSet(mlist
)
384 def put(self
, mlist
, attribute
, value
):
385 # attribute will contain the (bytes) name of the archiver that is
386 # getting a new status. value will be the representation of the new
388 archiver
= self
._archiver
_set
.get(attribute
)
389 assert archiver
is not None, attribute
390 archiver
.is_enabled
= as_boolean(value
)
395 """The archivers for a list, with their enabled flags."""
397 def __init__(self
, mlist
):
400 def on_get(self
, request
, response
):
401 """Get all the archiver statuses."""
402 archiver_set
= IListArchiverSet(self
._mlist
)
403 resource
= {archiver
.name
: archiver
.is_enabled
404 for archiver
in archiver_set
.archivers
405 if archiver
.system_archiver
.is_enabled
}
406 okay(response
, etag(resource
))
408 def patch_put(self
, request
, response
, is_optional
):
409 archiver_set
= IListArchiverSet(self
._mlist
)
410 kws
= {archiver
.name
: ArchiverGetterSetter(self
._mlist
)
411 for archiver
in archiver_set
.archivers
412 if archiver
.system_archiver
.is_enabled
}
414 # For a PATCH, all attributes are optional.
415 kws
['_optional'] = kws
.keys()
417 Validator(**kws
).update(self
._mlist
, request
)
418 except ValueError as error
:
419 bad_request(response
, str(error
))
423 def on_put(self
, request
, response
):
424 """Update all the archiver statuses."""
425 self
.patch_put(request
, response
, is_optional
=False)
427 def on_patch(self
, request
, response
):
428 """Patch some archiver statueses."""
429 self
.patch_put(request
, response
, is_optional
=True)
434 """Simple resource representing actions on a list's digest."""
436 def __init__(self
, mlist
):
439 def on_get(self
, request
, response
):
441 next_digest_number
=self
._mlist
.next_digest_number
,
442 volume
=self
._mlist
.volume
,
444 okay(response
, etag(resource
))
446 def on_post(self
, request
, response
):
448 validator
= Validator(
452 _optional
=('send', 'bump', 'periodic'))
453 values
= validator(request
)
454 except ValueError as error
:
455 bad_request(response
, str(error
))
457 if values
.get('send', False) and values
.get('periodic', False):
458 # Send and periodic and mutually exclusive options.
460 response
, 'send and periodic options are mutually exclusive')
463 # There's nothing to do, but that's okay.
466 if values
.get('bump', False):
467 bump_digest_number_and_volume(self
._mlist
)
468 if values
.get('send', False):
469 maybe_send_digest_now(self
._mlist
, force
=True)
470 if values
.get('periodic', False) and self
._mlist
.digest_send_periodic
:
471 maybe_send_digest_now(self
._mlist
, force
=True)
477 """Simple resource representing all list styles."""
480 manager
= getUtility(IStyleManager
)
481 styles
= [dict(name
=style
.name
, description
=style
.description
)
482 for style
in manager
.styles
]
483 style_names
= sorted(style
.name
for style
in manager
.styles
)
484 self
._resource
= dict(
485 # TODO (maxking): style_name is meant for backwards compatibility
486 # and should be removed in 3.3 release.
487 style_names
=style_names
,
489 default
=config
.styles
.default
)
491 def on_get(self
, request
, response
):
492 okay(response
, etag(self
._resource
))