* The column `mailinglist.new_member_options` was unused, and has been
[mailman.git] / src / mailman / model / mailinglist.py
blob344cc7f8edac44cf19bfc3eb82fd413f889db478
1 # Copyright (C) 2006-2012 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 """Model for mailing lists."""
20 from __future__ import absolute_import, print_function, unicode_literals
22 __metaclass__ = type
23 __all__ = [
24 'MailingList',
28 import os
30 from storm.locals import (
31 And, Bool, DateTime, Float, Int, Pickle, RawStr, Reference, Store,
32 TimeDelta, Unicode)
33 from urlparse import urljoin
34 from zope.component import getUtility
35 from zope.event import notify
36 from zope.interface import implementer
38 from mailman.config import config
39 from mailman.database.model import Model
40 from mailman.database.types import Enum
41 from mailman.interfaces.action import Action, FilterAction
42 from mailman.interfaces.address import IAddress
43 from mailman.interfaces.archiver import ArchivePolicy
44 from mailman.interfaces.autorespond import ResponseAction
45 from mailman.interfaces.bounce import UnrecognizedBounceDisposition
46 from mailman.interfaces.digests import DigestFrequency
47 from mailman.interfaces.domain import IDomainManager
48 from mailman.interfaces.languages import ILanguageManager
49 from mailman.interfaces.mailinglist import (
50 IAcceptableAlias, IAcceptableAliasSet, IMailingList, Personalization,
51 ReplyToMunging)
52 from mailman.interfaces.member import (
53 AlreadySubscribedError, MemberRole, MissingPreferredAddressError,
54 SubscriptionEvent)
55 from mailman.interfaces.mime import FilterType
56 from mailman.interfaces.nntp import NewsgroupModeration
57 from mailman.interfaces.user import IUser
58 from mailman.model import roster
59 from mailman.model.digests import OneLastDigest
60 from mailman.model.member import Member
61 from mailman.model.mime import ContentFilter
62 from mailman.model.preferences import Preferences
63 from mailman.utilities.filesystem import makedirs
64 from mailman.utilities.string import expand
67 SPACE = ' '
68 UNDERSCORE = '_'
72 @implementer(IMailingList)
73 class MailingList(Model):
74 """See `IMailingList`."""
76 id = Int(primary=True)
78 # XXX denotes attributes that should be part of the public interface but
79 # are currently missing.
81 # List identity
82 list_name = Unicode()
83 mail_host = Unicode()
84 _list_id = Unicode(name='list_id')
85 allow_list_posts = Bool()
86 include_rfc2369_headers = Bool()
87 advertised = Bool()
88 anonymous_list = Bool()
89 # Attributes not directly modifiable via the web u/i
90 created_at = DateTime()
91 admin_member_chunksize = Int()
92 # Attributes which are directly modifiable via the web u/i. The more
93 # complicated attributes are currently stored as pickles, though that
94 # will change as the schema and implementation is developed.
95 next_request_id = Int()
96 next_digest_number = Int()
97 digest_last_sent_at = DateTime()
98 volume = Int()
99 last_post_at = DateTime()
100 # Implicit destination.
101 acceptable_aliases_id = Int()
102 acceptable_alias = Reference(acceptable_aliases_id, 'AcceptableAlias.id')
103 # Attributes which are directly modifiable via the web u/i. The more
104 # complicated attributes are currently stored as pickles, though that
105 # will change as the schema and implementation is developed.
106 accept_these_nonmembers = Pickle() # XXX
107 admin_immed_notify = Bool()
108 admin_notify_mchanges = Bool()
109 administrivia = Bool()
110 archive_policy = Enum(ArchivePolicy)
111 # Automatic responses.
112 autoresponse_grace_period = TimeDelta()
113 autorespond_owner = Enum(ResponseAction)
114 autoresponse_owner_text = Unicode()
115 autorespond_postings = Enum(ResponseAction)
116 autoresponse_postings_text = Unicode()
117 autorespond_requests = Enum(ResponseAction)
118 autoresponse_request_text = Unicode()
119 # Content filters.
120 filter_action = Enum(FilterAction)
121 filter_content = Bool()
122 collapse_alternatives = Bool()
123 convert_html_to_plaintext = Bool()
124 # Bounces.
125 bounce_info_stale_after = TimeDelta() # XXX
126 bounce_matching_headers = Unicode() # XXX
127 bounce_notify_owner_on_disable = Bool() # XXX
128 bounce_notify_owner_on_removal = Bool() # XXX
129 bounce_score_threshold = Int() # XXX
130 bounce_you_are_disabled_warnings = Int() # XXX
131 bounce_you_are_disabled_warnings_interval = TimeDelta() # XXX
132 forward_unrecognized_bounces_to = Enum(UnrecognizedBounceDisposition)
133 process_bounces = Bool()
134 # Miscellaneous
135 default_member_action = Enum(Action)
136 default_nonmember_action = Enum(Action)
137 description = Unicode()
138 digest_footer_uri = Unicode()
139 digest_header_uri = Unicode()
140 digest_is_default = Bool()
141 digest_send_periodic = Bool()
142 digest_size_threshold = Float()
143 digest_volume_frequency = Enum(DigestFrequency)
144 digestable = Bool()
145 discard_these_nonmembers = Pickle()
146 emergency = Bool()
147 encode_ascii_prefixes = Bool()
148 first_strip_reply_to = Bool()
149 footer_uri = Unicode()
150 forward_auto_discards = Bool()
151 gateway_to_mail = Bool()
152 gateway_to_news = Bool()
153 goodbye_message_uri = Unicode()
154 header_matches = Pickle()
155 header_uri = Unicode()
156 hold_these_nonmembers = Pickle()
157 info = Unicode()
158 linked_newsgroup = Unicode()
159 max_days_to_hold = Int()
160 max_message_size = Int()
161 max_num_recipients = Int()
162 member_moderation_notice = Unicode()
163 mime_is_default_digest = Bool()
164 moderator_password = RawStr()
165 newsgroup_moderation = Enum(NewsgroupModeration)
166 nntp_prefix_subject_too = Bool()
167 nondigestable = Bool()
168 nonmember_rejection_notice = Unicode()
169 obscure_addresses = Bool()
170 owner_chain = Unicode()
171 owner_pipeline = Unicode()
172 personalize = Enum(Personalization)
173 post_id = Int()
174 posting_chain = Unicode()
175 posting_pipeline = Unicode()
176 _preferred_language = Unicode(name='preferred_language')
177 private_roster = Bool()
178 display_name = Unicode()
179 reject_these_nonmembers = Pickle()
180 reply_goes_to_list = Enum(ReplyToMunging)
181 reply_to_address = Unicode()
182 require_explicit_destination = Bool()
183 respond_to_post_requests = Bool()
184 scrub_nondigest = Bool()
185 send_goodbye_message = Bool()
186 send_reminders = Bool()
187 send_welcome_message = Bool()
188 subject_prefix = Unicode()
189 subscribe_auto_approval = Pickle()
190 subscribe_policy = Int()
191 topics = Pickle()
192 topics_bodylines_limit = Int()
193 topics_enabled = Bool()
194 unsubscribe_policy = Int()
195 welcome_message_uri = Unicode()
197 def __init__(self, fqdn_listname):
198 super(MailingList, self).__init__()
199 listname, at, hostname = fqdn_listname.partition('@')
200 assert hostname, 'Bad list name: {0}'.format(fqdn_listname)
201 self.list_name = listname
202 self.mail_host = hostname
203 self._list_id = '{0}.{1}'.format(listname, hostname)
204 # For the pending database
205 self.next_request_id = 1
206 # We need to set up the rosters. Normally, this method will get
207 # called when the MailingList object is loaded from the database, but
208 # that's not the case when the constructor is called. So, set up the
209 # rosters explicitly.
210 self.__storm_loaded__()
211 makedirs(self.data_path)
213 def __storm_loaded__(self):
214 self.owners = roster.OwnerRoster(self)
215 self.moderators = roster.ModeratorRoster(self)
216 self.administrators = roster.AdministratorRoster(self)
217 self.members = roster.MemberRoster(self)
218 self.regular_members = roster.RegularMemberRoster(self)
219 self.digest_members = roster.DigestMemberRoster(self)
220 self.subscribers = roster.Subscribers(self)
221 self.nonmembers = roster.NonmemberRoster(self)
223 def __repr__(self):
224 return '<mailing list "{0}" at {1:#x}>'.format(
225 self.fqdn_listname, id(self))
227 @property
228 def fqdn_listname(self):
229 """See `IMailingList`."""
230 return '{0}@{1}'.format(self.list_name, self.mail_host)
232 @property
233 def list_id(self):
234 """See `IMailingList`."""
235 return self._list_id
237 @property
238 def domain(self):
239 """See `IMailingList`."""
240 return getUtility(IDomainManager)[self.mail_host]
242 @property
243 def scheme(self):
244 """See `IMailingList`."""
245 return self.domain.scheme
247 @property
248 def web_host(self):
249 """See `IMailingList`."""
250 return self.domain.url_host
252 def script_url(self, target, context=None):
253 """See `IMailingList`."""
254 # XXX Handle the case for when context is not None; those would be
255 # relative URLs.
256 return urljoin(self.domain.base_url, target + '/' + self.fqdn_listname)
258 @property
259 def data_path(self):
260 """See `IMailingList`."""
261 return os.path.join(config.LIST_DATA_DIR, self.fqdn_listname)
263 # IMailingListAddresses
265 @property
266 def posting_address(self):
267 """See `IMailingList`."""
268 return self.fqdn_listname
270 @property
271 def no_reply_address(self):
272 """See `IMailingList`."""
273 return '{0}@{1}'.format(config.mailman.noreply_address, self.mail_host)
275 @property
276 def owner_address(self):
277 """See `IMailingList`."""
278 return '{0}-owner@{1}'.format(self.list_name, self.mail_host)
280 @property
281 def request_address(self):
282 """See `IMailingList`."""
283 return '{0}-request@{1}'.format(self.list_name, self.mail_host)
285 @property
286 def bounces_address(self):
287 """See `IMailingList`."""
288 return '{0}-bounces@{1}'.format(self.list_name, self.mail_host)
290 @property
291 def join_address(self):
292 """See `IMailingList`."""
293 return '{0}-join@{1}'.format(self.list_name, self.mail_host)
295 @property
296 def leave_address(self):
297 """See `IMailingList`."""
298 return '{0}-leave@{1}'.format(self.list_name, self.mail_host)
300 @property
301 def subscribe_address(self):
302 """See `IMailingList`."""
303 return '{0}-subscribe@{1}'.format(self.list_name, self.mail_host)
305 @property
306 def unsubscribe_address(self):
307 """See `IMailingList`."""
308 return '{0}-unsubscribe@{1}'.format(self.list_name, self.mail_host)
310 def confirm_address(self, cookie):
311 """See `IMailingList`."""
312 local_part = expand(config.mta.verp_confirm_format, dict(
313 address = '{0}-confirm'.format(self.list_name),
314 cookie = cookie))
315 return '{0}@{1}'.format(local_part, self.mail_host)
317 @property
318 def preferred_language(self):
319 """See `IMailingList`."""
320 return getUtility(ILanguageManager)[self._preferred_language]
322 @preferred_language.setter
323 def preferred_language(self, language):
324 """See `IMailingList`."""
325 # Accept both a language code and a `Language` instance.
326 try:
327 self._preferred_language = language.code
328 except AttributeError:
329 self._preferred_language = language
331 def send_one_last_digest_to(self, address, delivery_mode):
332 """See `IMailingList`."""
333 digest = OneLastDigest(self, address, delivery_mode)
334 Store.of(self).add(digest)
336 @property
337 def last_digest_recipients(self):
338 """See `IMailingList`."""
339 results = Store.of(self).find(
340 OneLastDigest,
341 OneLastDigest.mailing_list == self)
342 recipients = [(digest.address, digest.delivery_mode)
343 for digest in results]
344 results.remove()
345 return recipients
347 @property
348 def filter_types(self):
349 """See `IMailingList`."""
350 results = Store.of(self).find(
351 ContentFilter,
352 And(ContentFilter.mailing_list == self,
353 ContentFilter.filter_type == FilterType.filter_mime))
354 for content_filter in results:
355 yield content_filter.filter_pattern
357 @filter_types.setter
358 def filter_types(self, sequence):
359 """See `IMailingList`."""
360 # First, delete all existing MIME type filter patterns.
361 store = Store.of(self)
362 results = store.find(
363 ContentFilter,
364 And(ContentFilter.mailing_list == self,
365 ContentFilter.filter_type == FilterType.filter_mime))
366 results.remove()
367 # Now add all the new filter types.
368 for mime_type in sequence:
369 content_filter = ContentFilter(
370 self, mime_type, FilterType.filter_mime)
371 store.add(content_filter)
373 @property
374 def pass_types(self):
375 """See `IMailingList`."""
376 results = Store.of(self).find(
377 ContentFilter,
378 And(ContentFilter.mailing_list == self,
379 ContentFilter.filter_type == FilterType.pass_mime))
380 for content_filter in results:
381 yield content_filter.filter_pattern
383 @pass_types.setter
384 def pass_types(self, sequence):
385 """See `IMailingList`."""
386 # First, delete all existing MIME type pass patterns.
387 store = Store.of(self)
388 results = store.find(
389 ContentFilter,
390 And(ContentFilter.mailing_list == self,
391 ContentFilter.filter_type == FilterType.pass_mime))
392 results.remove()
393 # Now add all the new filter types.
394 for mime_type in sequence:
395 content_filter = ContentFilter(
396 self, mime_type, FilterType.pass_mime)
397 store.add(content_filter)
399 @property
400 def filter_extensions(self):
401 """See `IMailingList`."""
402 results = Store.of(self).find(
403 ContentFilter,
404 And(ContentFilter.mailing_list == self,
405 ContentFilter.filter_type == FilterType.filter_extension))
406 for content_filter in results:
407 yield content_filter.filter_pattern
409 @filter_extensions.setter
410 def filter_extensions(self, sequence):
411 """See `IMailingList`."""
412 # First, delete all existing file extensions filter patterns.
413 store = Store.of(self)
414 results = store.find(
415 ContentFilter,
416 And(ContentFilter.mailing_list == self,
417 ContentFilter.filter_type == FilterType.filter_extension))
418 results.remove()
419 # Now add all the new filter types.
420 for mime_type in sequence:
421 content_filter = ContentFilter(
422 self, mime_type, FilterType.filter_extension)
423 store.add(content_filter)
425 @property
426 def pass_extensions(self):
427 """See `IMailingList`."""
428 results = Store.of(self).find(
429 ContentFilter,
430 And(ContentFilter.mailing_list == self,
431 ContentFilter.filter_type == FilterType.pass_extension))
432 for content_filter in results:
433 yield content_filter.pass_pattern
435 @pass_extensions.setter
436 def pass_extensions(self, sequence):
437 """See `IMailingList`."""
438 # First, delete all existing file extensions pass patterns.
439 store = Store.of(self)
440 results = store.find(
441 ContentFilter,
442 And(ContentFilter.mailing_list == self,
443 ContentFilter.filter_type == FilterType.pass_extension))
444 results.remove()
445 # Now add all the new filter types.
446 for mime_type in sequence:
447 content_filter = ContentFilter(
448 self, mime_type, FilterType.pass_extension)
449 store.add(content_filter)
451 def get_roster(self, role):
452 """See `IMailingList`."""
453 if role is MemberRole.member:
454 return self.members
455 elif role is MemberRole.owner:
456 return self.owners
457 elif role is MemberRole.moderator:
458 return self.moderators
459 else:
460 raise TypeError(
461 'Undefined MemberRole: {0}'.format(role))
463 def subscribe(self, subscriber, role=MemberRole.member):
464 """See `IMailingList`."""
465 store = Store.of(self)
466 if IAddress.providedBy(subscriber):
467 member = store.find(
468 Member,
469 Member.role == role,
470 Member.list_id == self._list_id,
471 Member._address == subscriber).one()
472 if member:
473 raise AlreadySubscribedError(
474 self.fqdn_listname, subscriber.email, role)
475 elif IUser.providedBy(subscriber):
476 if subscriber.preferred_address is None:
477 raise MissingPreferredAddressError(subscriber)
478 member = store.find(
479 Member,
480 Member.role == role,
481 Member.list_id == self._list_id,
482 Member._user == subscriber).one()
483 if member:
484 raise AlreadySubscribedError(
485 self.fqdn_listname, subscriber, role)
486 else:
487 raise ValueError('subscriber must be an address or user')
488 member = Member(role=role,
489 list_id=self._list_id,
490 subscriber=subscriber)
491 member.preferences = Preferences()
492 store.add(member)
493 notify(SubscriptionEvent(self, member))
494 return member
498 @implementer(IAcceptableAlias)
499 class AcceptableAlias(Model):
500 """See `IAcceptableAlias`."""
502 id = Int(primary=True)
504 mailing_list_id = Int()
505 mailing_list = Reference(mailing_list_id, MailingList.id)
507 alias = Unicode()
509 def __init__(self, mailing_list, alias):
510 self.mailing_list = mailing_list
511 self.alias = alias
515 @implementer(IAcceptableAliasSet)
516 class AcceptableAliasSet:
517 """See `IAcceptableAliasSet`."""
519 def __init__(self, mailing_list):
520 self._mailing_list = mailing_list
522 def clear(self):
523 """See `IAcceptableAliasSet`."""
524 Store.of(self._mailing_list).find(
525 AcceptableAlias,
526 AcceptableAlias.mailing_list == self._mailing_list).remove()
528 def add(self, alias):
529 if not (alias.startswith('^') or '@' in alias):
530 raise ValueError(alias)
531 alias = AcceptableAlias(self._mailing_list, alias.lower())
532 Store.of(self._mailing_list).add(alias)
534 def remove(self, alias):
535 Store.of(self._mailing_list).find(
536 AcceptableAlias,
537 And(AcceptableAlias.mailing_list == self._mailing_list,
538 AcceptableAlias.alias == alias.lower())).remove()
540 @property
541 def aliases(self):
542 aliases = Store.of(self._mailing_list).find(
543 AcceptableAlias,
544 AcceptableAlias.mailing_list == self._mailing_list)
545 for alias in aliases:
546 yield alias.alias