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)
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 <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 (
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 (
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 (
61 list_of_strings_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
74 if len(segments
) != 2:
77 role
= MemberRole
[segments
[0]]
81 return (), dict(role
=role
, email
=segments
[1]), ()
84 def roster_matcher(segments
):
85 """A matcher of all members URLs inside mailing lists.
89 if len(segments
) != 2 or segments
[0] != 'roster':
92 return (), dict(role
=MemberRole
[segments
[1]]), ()
98 def config_matcher(segments
):
99 """A matcher for a mailing list's configuration resource.
102 e.g. /config/description
104 if len(segments
) < 1 or segments
[0] != 'config':
106 if len(segments
) == 1:
108 if len(segments
) == 2:
109 return (), dict(attribute
=segments
[1]), ()
110 # More segments are not allowed.
114 class _ListBase(CollectionMixin
):
115 """Shared base class for mailing list representations."""
117 def _resource_as_dict(self
, mlist
):
118 """See `CollectionMixin`."""
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
,
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')
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
):
154 def _get_collection(self
, request
):
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
),
175 _optional
=('role', 'page', 'count'))
177 data
= validator(request
)
178 except ValueError as error
:
179 bad_request(response
, str(error
))
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.
194 return not_found(response
)
195 resource
= _ListOfLists(lists
, self
.api
)
196 okay(response
, etag(resource
._make
_collection
(request
)))
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
207 manager
= getUtility(IListManager
)
208 if '@' in list_identifier
:
209 self
._mlist
= manager
.get(list_identifier
)
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:
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:
225 remove_list(self
._mlist
)
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
)
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
)
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
)
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
)
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
)
275 def digest(self
, context
, segments
):
276 if self
._mlist
is None:
277 return NotFound(), []
278 return ListDigest(self
._mlist
)
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
)
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
), []
313 class AllLists(_ListBase
):
314 """The mailing lists."""
316 def on_post(self
, request
, response
):
317 """Create a new mailing list."""
319 validator
= Validator(fqdn_listname
=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'))
337 location
= self
.api
.path_to('lists/{0}'.format(mlist
.list_id
))
338 created(response
, location
)
340 def on_get(self
, request
, response
):
342 resource
= self
._make
_collection
(request
)
343 okay(response
, etag(resource
))
347 class MembersOfList(MemberCollection
):
348 """The members of a mailing list."""
350 def __init__(self
, mailing_list
, role
):
352 self
._mlist
= mailing_list
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
,
363 def on_delete(self
, request
, response
):
364 """Delete the members of the named mailing list."""
367 validator
= Validator(emails
=list_of_strings_validator
)
368 arguments
= validator(request
)
369 except ValueError as error
:
370 bad_request(response
, str(error
))
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
))
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
)
400 class ArchiverGetterSetter(GetterSetter
):
401 """Resource for updating archiver statuses."""
403 def __init__(self
, mlist
):
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
411 archiver
= self
._archiver
_set
.get(attribute
)
412 assert archiver
is not None, attribute
413 archiver
.is_enabled
= as_boolean(value
)
418 """The archivers for a list, with their enabled flags."""
420 def __init__(self
, 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
}
437 # For a PATCH, all attributes are optional.
438 kws
['_optional'] = kws
.keys()
440 Validator(**kws
).update(self
._mlist
, request
)
441 except ValueError as error
:
442 bad_request(response
, str(error
))
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)
457 """Simple resource representing actions on a list's digest."""
459 def __init__(self
, mlist
):
462 def on_get(self
, request
, response
):
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
):
471 validator
= Validator(
475 _optional
=('send', 'bump', 'periodic'))
476 values
= validator(request
)
477 except ValueError as error
:
478 bad_request(response
, str(error
))
480 if values
.get('send', False) and values
.get('periodic', False):
481 # Send and periodic and mutually exclusive options.
483 response
, 'send and periodic options are mutually exclusive')
486 # There's nothing to do, but that's okay.
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)
500 """Simple resource representing all list styles."""
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
,
512 default
=config
.styles
.default
)
514 def on_get(self
, request
, response
):
515 okay(response
, etag(self
._resource
))