1# Copyright (C) 2006-2020 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
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.
9#
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.
14#
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/>.
17
18"""Model for mailing lists."""
19
20import os
21
22from mailman.config import config
23from mailman.database.model import Model
24from mailman.database.transaction import dbconnection
25from mailman.database.types import Enum, SAUnicode, SAUnicodeLarge
26from mailman.interfaces.action import Action, FilterAction
27from mailman.interfaces.address import IAddress, InvalidEmailAddressError
28from mailman.interfaces.archiver import ArchivePolicy
29from mailman.interfaces.autorespond import ResponseAction
30from mailman.interfaces.bans import IBanManager
31from mailman.interfaces.bounce import UnrecognizedBounceDisposition
32from mailman.interfaces.digests import DigestFrequency
33from mailman.interfaces.domain import IDomainManager
34from mailman.interfaces.languages import ILanguageManager
35from mailman.interfaces.mailinglist import (
36    DMARCMitigateAction, IAcceptableAlias, IAcceptableAliasSet,
37    IHeaderMatch, IHeaderMatchList, IListArchiver, IListArchiverSet,
38    IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy)
39from mailman.interfaces.member import (
40    AlreadySubscribedError, MemberRole, MembershipIsBannedError,
41    MissingPreferredAddressError, SubscriptionEvent)
42from mailman.interfaces.mime import FilterType
43from mailman.interfaces.nntp import NewsgroupModeration
44from mailman.interfaces.user import IUser
45from mailman.model import roster
46from mailman.model.digests import OneLastDigest
47from mailman.model.member import Member
48from mailman.model.mime import ContentFilter
49from mailman.model.preferences import Preferences
50from mailman.utilities.filesystem import makedirs
51from mailman.utilities.string import expand
52from public import public
53from sqlalchemy import (
54    Boolean, Column, DateTime, Float, ForeignKey, Integer, Interval,
55    LargeBinary, PickleType)
56from sqlalchemy.event import listen
57from sqlalchemy.ext.hybrid import hybrid_property
58from sqlalchemy.ext.mutable import MutableList
59from sqlalchemy.orm import relationship
60from sqlalchemy.orm.exc import NoResultFound
61from zope.component import getUtility
62from zope.event import notify
63from zope.interface import implementer
64
65
66SPACE = ' '
67UNDERSCORE = '_'
68
69
70@public
71@implementer(IMailingList)
72class MailingList(Model):
73    """See `IMailingList`."""
74
75    __tablename__ = 'mailinglist'
76
77    id = Column(Integer, primary_key=True)
78
79    # XXX denotes attributes that should be part of the public interface but
80    # are currently missing.
81
82    # List identity
83    list_name = Column(SAUnicode, index=True)
84    mail_host = Column(SAUnicode, index=True)
85    _list_id = Column('list_id', SAUnicode, index=True, unique=True)
86    allow_list_posts = Column(Boolean)
87    include_rfc2369_headers = Column(Boolean)
88    advertised = Column(Boolean)
89    anonymous_list = Column(Boolean)
90    # Attributes not directly modifiable via the web u/i
91    created_at = Column(DateTime)
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 = Column(Integer)
96    next_digest_number = Column(Integer)
97    digest_last_sent_at = Column(DateTime)
98    volume = Column(Integer)
99    last_post_at = Column(DateTime)
100    # Attributes which are directly modifiable via the web u/i.  The more
101    # complicated attributes are currently stored as pickles, though that
102    # will change as the schema and implementation is developed.
103    accept_these_nonmembers = Column(MutableList.as_mutable(PickleType))  # XXX
104    admin_immed_notify = Column(Boolean)
105    admin_notify_mchanges = Column(Boolean)
106    administrivia = Column(Boolean)
107    archive_policy = Column(Enum(ArchivePolicy))
108    # Automatic responses.
109    autoresponse_grace_period = Column(Interval)
110    autorespond_owner = Column(Enum(ResponseAction))
111    autoresponse_owner_text = Column(SAUnicode)
112    autorespond_postings = Column(Enum(ResponseAction))
113    autoresponse_postings_text = Column(SAUnicode)
114    autorespond_requests = Column(Enum(ResponseAction))
115    autoresponse_request_text = Column(SAUnicode)
116    # Content filters.
117    filter_action = Column(Enum(FilterAction))
118    filter_content = Column(Boolean)
119    collapse_alternatives = Column(Boolean)
120    convert_html_to_plaintext = Column(Boolean)
121    # Bounces.
122    bounce_info_stale_after = Column(Interval)
123    bounce_matching_headers = Column(SAUnicode)                  # XXX
124    bounce_notify_owner_on_disable = Column(Boolean)
125    bounce_notify_owner_on_removal = Column(Boolean)
126    bounce_score_threshold = Column(Integer)
127    bounce_you_are_disabled_warnings = Column(Integer)
128    bounce_you_are_disabled_warnings_interval = Column(Interval)
129    forward_unrecognized_bounces_to = Column(
130        Enum(UnrecognizedBounceDisposition))
131    process_bounces = Column(Boolean)
132    # DMARC
133    dmarc_mitigate_action = Column(Enum(DMARCMitigateAction))
134    dmarc_mitigate_unconditionally = Column(Boolean)
135    dmarc_moderation_notice = Column(SAUnicodeLarge)
136    dmarc_wrapped_message_text = Column(SAUnicodeLarge)
137    # Miscellaneous
138    default_member_action = Column(Enum(Action))
139    default_nonmember_action = Column(Enum(Action))
140    description = Column(SAUnicode)
141    digests_enabled = Column(Boolean)
142    digest_is_default = Column(Boolean)
143    digest_send_periodic = Column(Boolean)
144    digest_size_threshold = Column(Float)
145    digest_volume_frequency = Column(Enum(DigestFrequency))
146    discard_these_nonmembers = Column(MutableList.as_mutable(PickleType))
147    emergency = Column(Boolean)
148    encode_ascii_prefixes = Column(Boolean)
149    first_strip_reply_to = Column(Boolean)
150    forward_auto_discards = Column(Boolean)
151    gateway_to_mail = Column(Boolean)
152    gateway_to_news = Column(Boolean)
153    hold_these_nonmembers = Column(MutableList.as_mutable(PickleType))
154    info = Column(SAUnicode)
155    linked_newsgroup = Column(SAUnicode)
156    max_days_to_hold = Column(Integer)
157    max_message_size = Column(Integer)
158    max_num_recipients = Column(Integer)
159    member_moderation_notice = Column(SAUnicode)
160    # FIXME: There should be no moderator_password
161    moderator_password = Column(LargeBinary)             # TODO : was RawStr()
162    newsgroup_moderation = Column(Enum(NewsgroupModeration))
163    nntp_prefix_subject_too = Column(Boolean)
164    nonmember_rejection_notice = Column(SAUnicode)
165    obscure_addresses = Column(Boolean)
166    owner_chain = Column(SAUnicode)
167    owner_pipeline = Column(SAUnicode)
168    personalize = Column(Enum(Personalization))
169    post_id = Column(Integer)
170    posting_chain = Column(SAUnicode)
171    posting_pipeline = Column(SAUnicode)
172    _preferred_language = Column('preferred_language', SAUnicode)
173    display_name = Column(SAUnicode)
174    reject_these_nonmembers = Column(MutableList.as_mutable(PickleType))
175    reply_goes_to_list = Column(Enum(ReplyToMunging))
176    reply_to_address = Column(SAUnicode)
177    require_explicit_destination = Column(Boolean)
178    respond_to_post_requests = Column(Boolean)
179    member_roster_visibility = Column(Enum(roster.RosterVisibility))
180    scrub_nondigest = Column(Boolean)
181    send_goodbye_message = Column(Boolean)
182    send_welcome_message = Column(Boolean)
183    subject_prefix = Column(SAUnicode)
184    subscription_policy = Column(Enum(SubscriptionPolicy))
185    topics = Column(PickleType)
186    topics_bodylines_limit = Column(Integer)
187    topics_enabled = Column(Boolean)
188    unsubscription_policy = Column(Enum(SubscriptionPolicy))
189    usenet_watermark = Column(Integer)
190    # ORM relationships.
191    header_matches = relationship(
192        'HeaderMatch', backref='mailing_list',
193        cascade="all, delete-orphan",
194        order_by="HeaderMatch._position")
195
196    def __init__(self, fqdn_listname):
197        super().__init__()
198        listname, at, hostname = fqdn_listname.partition('@')
199        assert hostname, 'Bad list name: {0}'.format(fqdn_listname)
200        self.list_name = listname
201        self.mail_host = hostname
202        self._list_id = '{0}.{1}'.format(listname, hostname)
203        # For the pending database
204        self.next_request_id = 1
205        # We need to set up the rosters.  Normally, this method will get called
206        # when the MailingList object is loaded from the database, but when the
207        # constructor is called, SQLAlchemy's `load` event isn't triggered.
208        # Thus we need to set up the rosters explicitly.
209        self._post_load()
210        makedirs(self.data_path)
211
212    def _post_load(self, *args):
213        # This hooks up to SQLAlchemy's `load` event.
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)
222
223    @classmethod
224    def __declare_last__(cls):
225        # SQLAlchemy special directive hook called after mappings are assumed
226        # to be complete.  Use this to connect the roster instance creation
227        # method with the SA `load` event.
228        listen(cls, 'load', cls._post_load)
229
230    def __repr__(self):
231        return '<mailing list "{}" at {:#x}>'.format(
232            self.fqdn_listname, id(self))
233
234    @property
235    def fqdn_listname(self):
236        """See `IMailingList`."""
237        return '{}@{}'.format(self.list_name, self.mail_host)
238
239    @property
240    def list_id(self):
241        """See `IMailingList`."""
242        return self._list_id
243
244    @property
245    def domain(self):
246        """See `IMailingList`."""
247        return getUtility(IDomainManager)[self.mail_host]
248
249    @property
250    def data_path(self):
251        """See `IMailingList`."""
252        return os.path.join(config.LIST_DATA_DIR, self.list_id)
253
254    # IMailingListAddresses
255
256    @property
257    def posting_address(self):
258        """See `IMailingList`."""
259        return self.fqdn_listname
260
261    @property
262    def no_reply_address(self):
263        """See `IMailingList`."""
264        return '{}@{}'.format(config.mailman.noreply_address, self.mail_host)
265
266    @property
267    def owner_address(self):
268        """See `IMailingList`."""
269        return '{}-owner@{}'.format(self.list_name, self.mail_host)
270
271    @property
272    def request_address(self):
273        """See `IMailingList`."""
274        return '{}-request@{}'.format(self.list_name, self.mail_host)
275
276    @property
277    def bounces_address(self):
278        """See `IMailingList`."""
279        return '{}-bounces@{}'.format(self.list_name, self.mail_host)
280
281    @property
282    def join_address(self):
283        """See `IMailingList`."""
284        return '{}-join@{}'.format(self.list_name, self.mail_host)
285
286    @property
287    def leave_address(self):
288        """See `IMailingList`."""
289        return '{}-leave@{}'.format(self.list_name, self.mail_host)
290
291    @property
292    def subscribe_address(self):
293        """See `IMailingList`."""
294        return '{}-subscribe@{}'.format(self.list_name, self.mail_host)
295
296    @property
297    def unsubscribe_address(self):
298        """See `IMailingList`."""
299        return '{}-unsubscribe@{}'.format(self.list_name, self.mail_host)
300
301    def confirm_address(self, cookie):
302        """See `IMailingList`."""
303        local_part = expand(config.mta.verp_confirm_format, self, dict(
304            address='{}-confirm'.format(self.list_name),
305            cookie=cookie))
306        return '{}@{}'.format(local_part, self.mail_host)
307
308    @property
309    def preferred_language(self):
310        """See `IMailingList`."""
311        return getUtility(ILanguageManager)[self._preferred_language]
312
313    @preferred_language.setter
314    def preferred_language(self, language):
315        """See `IMailingList`."""
316        # Accept both a language code and a `Language` instance.
317        try:
318            self._preferred_language = language.code
319        except AttributeError:
320            self._preferred_language = language
321
322    @dbconnection
323    def send_one_last_digest_to(self, store, address, delivery_mode):
324        """See `IMailingList`."""
325        digest = OneLastDigest(self, address, delivery_mode)
326        store.add(digest)
327
328    @property
329    @dbconnection
330    def last_digest_recipients(self, store):
331        """See `IMailingList`."""
332        results = store.query(OneLastDigest).filter(
333            OneLastDigest.mailing_list == self)
334        recipients = [(digest.address, digest.delivery_mode)
335                      for digest in results]
336        results.delete()
337        return recipients
338
339    @property
340    @dbconnection
341    def filter_types(self, store):
342        """See `IMailingList`."""
343        results = store.query(ContentFilter).filter(
344            ContentFilter.mailing_list == self,
345            ContentFilter.filter_type == FilterType.filter_mime)
346        for content_filter in results:
347            yield content_filter.filter_pattern
348
349    @filter_types.setter
350    @dbconnection
351    def filter_types(self, store, sequence):
352        """See `IMailingList`."""
353        # First, delete all existing MIME type filter patterns.
354        results = store.query(ContentFilter).filter(
355            ContentFilter.mailing_list == self,
356            ContentFilter.filter_type == FilterType.filter_mime)
357        results.delete()
358        # Now add all the new filter types.
359        for mime_type in sequence:
360            content_filter = ContentFilter(
361                self, mime_type, FilterType.filter_mime)
362            store.add(content_filter)
363
364    @property
365    @dbconnection
366    def pass_types(self, store):
367        """See `IMailingList`."""
368        results = store.query(ContentFilter).filter(
369            ContentFilter.mailing_list == self,
370            ContentFilter.filter_type == FilterType.pass_mime)
371        for content_filter in results:
372            yield content_filter.filter_pattern
373
374    @pass_types.setter
375    @dbconnection
376    def pass_types(self, store, sequence):
377        """See `IMailingList`."""
378        # First, delete all existing MIME type pass patterns.
379        results = store.query(ContentFilter).filter(
380            ContentFilter.mailing_list == self,
381            ContentFilter.filter_type == FilterType.pass_mime)
382        results.delete()
383        # Now add all the new filter types.
384        for mime_type in sequence:
385            content_filter = ContentFilter(
386                self, mime_type, FilterType.pass_mime)
387            store.add(content_filter)
388
389    @property
390    @dbconnection
391    def filter_extensions(self, store):
392        """See `IMailingList`."""
393        results = store.query(ContentFilter).filter(
394            ContentFilter.mailing_list == self,
395            ContentFilter.filter_type == FilterType.filter_extension)
396        for content_filter in results:
397            yield content_filter.filter_pattern
398
399    @filter_extensions.setter
400    @dbconnection
401    def filter_extensions(self, store, sequence):
402        """See `IMailingList`."""
403        # First, delete all existing file extensions filter patterns.
404        results = store.query(ContentFilter).filter(
405            ContentFilter.mailing_list == self,
406            ContentFilter.filter_type == FilterType.filter_extension)
407        results.delete()
408        # Now add all the new filter types.
409        for mime_type in sequence:
410            content_filter = ContentFilter(
411                self, mime_type, FilterType.filter_extension)
412            store.add(content_filter)
413
414    @property
415    @dbconnection
416    def pass_extensions(self, store):
417        """See `IMailingList`."""
418        results = store.query(ContentFilter).filter(
419            ContentFilter.mailing_list == self,
420            ContentFilter.filter_type == FilterType.pass_extension)
421        for content_filter in results:
422            yield content_filter.filter_pattern
423
424    @pass_extensions.setter
425    @dbconnection
426    def pass_extensions(self, store, sequence):
427        """See `IMailingList`."""
428        # First, delete all existing file extensions pass patterns.
429        results = store.query(ContentFilter).filter(
430            ContentFilter.mailing_list == self,
431            ContentFilter.filter_type == FilterType.pass_extension)
432        results.delete()
433        # Now add all the new filter types.
434        for mime_type in sequence:
435            content_filter = ContentFilter(
436                self, mime_type, FilterType.pass_extension)
437            store.add(content_filter)
438
439    def get_roster(self, role):
440        """See `IMailingList`."""
441        if role is MemberRole.member:
442            return self.members
443        elif role is MemberRole.owner:
444            return self.owners
445        elif role is MemberRole.moderator:
446            return self.moderators
447        elif role is MemberRole.nonmember:
448            return self.nonmembers
449        else:
450            raise ValueError('Undefined MemberRole: {}'.format(role))
451
452    def _get_subscriber(self, store, subscriber, role):
453        """Get some information about a user/address.
454
455        Returns a 2-tuple of (member, email) for the given subscriber.  If the
456        subscriber is is not an ``IAddress`` or ``IUser``, then a 2-tuple of
457        (None, None) is returned.  If the subscriber is not already
458        subscribed, then (None, email) is returned.  If the subscriber is an
459        ``IUser`` and does not have a preferred address, (member, None) is
460        returned.
461        """
462        member = None
463        email = None
464        if IAddress.providedBy(subscriber):
465            member = store.query(Member).filter(
466                Member.role == role,
467                Member.list_id == self._list_id,
468                Member._address == subscriber).first()
469            email = subscriber.email
470        elif IUser.providedBy(subscriber):
471            if subscriber.preferred_address is None:
472                raise MissingPreferredAddressError(subscriber)
473            email = subscriber.preferred_address.email
474            member = store.query(Member).filter(
475                Member.role == role,
476                Member.list_id == self._list_id,
477                Member._user == subscriber).first()
478        return member, email
479
480    @dbconnection
481    def is_subscribed(self, store, subscriber, role=MemberRole.member):
482        """See `IMailingList`."""
483        member, email = self._get_subscriber(store, subscriber, role)
484        return member is not None
485
486    @dbconnection
487    def subscribe(self, store, subscriber, role=MemberRole.member,
488                  send_welcome_message=None):
489        """See `IMailingList`."""
490        member, email = self._get_subscriber(store, subscriber, role)
491        test_email = email or subscriber.lower()
492        # Allow list posting address only for nonmember role.
493        if (test_email == self.posting_address and
494                role != MemberRole.nonmember):
495            raise InvalidEmailAddressError('List posting address not allowed')
496        if member is not None:
497            raise AlreadySubscribedError(self.fqdn_listname, email, role)
498        if IBanManager(self).is_banned(test_email):
499            raise MembershipIsBannedError(self, test_email)
500        member = Member(role=role,
501                        list_id=self._list_id,
502                        subscriber=subscriber)
503        member.preferences = Preferences()
504        store.add(member)
505        notify(SubscriptionEvent(
506            self, member, send_welcome_message=send_welcome_message))
507        return member
508
509
510@public
511@implementer(IAcceptableAlias)
512class AcceptableAlias(Model):
513    """See `IAcceptableAlias`."""
514
515    __tablename__ = 'acceptablealias'
516
517    id = Column(Integer, primary_key=True)
518
519    mailing_list_id = Column(
520        Integer, ForeignKey('mailinglist.id'),
521        index=True, nullable=False)
522    mailing_list = relationship('MailingList', backref='acceptablealias')
523    alias = Column(SAUnicode, index=True, nullable=False)
524
525    def __init__(self, mailing_list, alias):
526        super().__init__()
527        self.mailing_list = mailing_list
528        self.alias = alias
529
530
531@public
532@implementer(IAcceptableAliasSet)
533class AcceptableAliasSet:
534    """See `IAcceptableAliasSet`."""
535
536    def __init__(self, mailing_list):
537        self._mailing_list = mailing_list
538
539    @dbconnection
540    def clear(self, store):
541        """See `IAcceptableAliasSet`."""
542        store.query(AcceptableAlias).filter(
543            AcceptableAlias.mailing_list == self._mailing_list).delete()
544
545    @dbconnection
546    def add(self, store, alias):
547        if not (alias.startswith('^') or '@' in alias):
548            raise ValueError(alias)
549        alias = AcceptableAlias(self._mailing_list, alias.lower())
550        store.add(alias)
551
552    @dbconnection
553    def remove(self, store, alias):
554        store.query(AcceptableAlias).filter(
555            AcceptableAlias.mailing_list == self._mailing_list,
556            AcceptableAlias.alias == alias.lower()).delete()
557
558    @property
559    @dbconnection
560    def aliases(self, store):
561        aliases = store.query(AcceptableAlias).filter(
562            AcceptableAlias.mailing_list_id == self._mailing_list.id)
563        for alias in aliases:
564            yield alias.alias
565
566
567@public
568@implementer(IListArchiver)
569class ListArchiver(Model):
570    """See `IListArchiver`."""
571
572    __tablename__ = 'listarchiver'
573
574    id = Column(Integer, primary_key=True)
575
576    mailing_list_id = Column(
577        Integer, ForeignKey('mailinglist.id'),
578        index=True, nullable=False)
579    mailing_list = relationship('MailingList')
580
581    name = Column(SAUnicode, nullable=False)
582    _is_enabled = Column(Boolean)
583
584    def __init__(self, mailing_list, archiver_name, system_archiver):
585        self.mailing_list = mailing_list
586        self.name = archiver_name
587        self._is_enabled = system_archiver.is_enabled
588
589    @property
590    def system_archiver(self):
591        for archiver in config.archivers:           # pragma: no branch
592            if archiver.name == self.name:
593                return archiver
594        raise AssertionError('Archiver not found: {}'.format(self.name))
595
596    @property
597    def is_enabled(self):
598        return self.system_archiver.is_enabled and self._is_enabled
599
600    @is_enabled.setter
601    def is_enabled(self, value):
602        self._is_enabled = value
603
604
605@public
606@implementer(IListArchiverSet)
607class ListArchiverSet:
608    @dbconnection
609    def __init__(self, store, mailing_list):
610        self._mailing_list = mailing_list
611        system_archivers = {}
612        for archiver in config.archivers:
613            system_archivers[archiver.name] = archiver
614        # Add any system enabled archivers which aren't already associated
615        # with the mailing list.
616        for archiver_name in system_archivers:
617            exists = store.query(ListArchiver).filter(
618                ListArchiver.mailing_list == mailing_list,
619                ListArchiver.name == archiver_name).one_or_none()
620            if exists is None:
621                store.add(ListArchiver(mailing_list, archiver_name,
622                                       system_archivers[archiver_name]))
623
624    @property
625    @dbconnection
626    def archivers(self, store):
627        entries = store.query(ListArchiver).filter(
628            ListArchiver.mailing_list == self._mailing_list)
629        yield from entries
630
631    @dbconnection
632    def get(self, store, archiver_name):
633        return store.query(ListArchiver).filter(
634            ListArchiver.mailing_list == self._mailing_list,
635            ListArchiver.name == archiver_name).one_or_none()
636
637
638@public
639@implementer(IHeaderMatch)
640class HeaderMatch(Model):
641    """See `IHeaderMatch`."""
642
643    __tablename__ = 'headermatch'
644
645    id = Column(Integer, primary_key=True)
646
647    mailing_list_id = Column(
648        Integer,
649        ForeignKey('mailinglist.id'),
650        index=True, nullable=False)
651
652    _position = Column('position', Integer, index=True, default=0)
653    header = Column(SAUnicode)
654    pattern = Column(SAUnicode)
655    chain = Column(SAUnicode, nullable=True)
656    tag = Column(SAUnicode, nullable=True)
657
658    def __init__(self, **kw):
659        position = kw.pop('position', None)
660        if position is not None:
661            kw['_position'] = position
662        super().__init__(**kw)
663
664    @hybrid_property
665    def position(self):
666        """See `IHeaderMatch`."""
667        return self._position
668
669    @position.setter
670    @dbconnection
671    def position(self, store, value):
672        """See `IHeaderMatch`."""
673        if value < 0:
674            raise ValueError('Negative indexes are not supported')
675        if value == self.position:
676            # Nothing to do.
677            return
678        existing_count = store.query(HeaderMatch).filter(
679            HeaderMatch.mailing_list == self.mailing_list).count()
680        if value >= existing_count:
681            raise ValueError(
682                'There are {count} header matches for this list, '
683                'the new position cannot be {count} or higher'.format(
684                    count=existing_count))
685        if value < self.position:
686            # Moving up: header matches between the new position and the
687            # current one must be moved down the list to make room. Those
688            # after the current position must not be changed.
689            for header_match in store.query(HeaderMatch).filter(
690                    HeaderMatch.mailing_list == self.mailing_list,
691                    HeaderMatch.position >= value,
692                    HeaderMatch.position < self.position):
693                header_match._position = header_match.position + 1
694        elif value > self.position:
695            # Moving down: header matches between the current position and the
696            # new one must be moved up the list to make room. Those after the
697            # new position must not be changed.
698            for header_match in store.query(HeaderMatch).filter(
699                    HeaderMatch.mailing_list == self.mailing_list,
700                    HeaderMatch.position > self.position,
701                    HeaderMatch.position <= value):
702                header_match._position = header_match.position - 1
703        self._position = value
704
705
706@public
707@implementer(IHeaderMatchList)
708class HeaderMatchList:
709    """See `IHeaderMatchList`."""
710
711    # All write operations must mark the mailing list's header_matches
712    # collection as expired:
713    # https://docs.sqlalchemy.org/en/latest/orm/session_state_management.html#refreshing-expiring
714
715    def __init__(self, mailing_list):
716        self._mailing_list = mailing_list
717
718    @dbconnection
719    def clear(self, store):
720        """See `IHeaderMatchList`."""
721        # https://docs.sqlalchemy.org/en/latest/orm/session_basics.html
722        del self._mailing_list.header_matches[:]
723
724    @dbconnection
725    def append(self, store, header, pattern, chain=None, tag=None):
726        header = header.lower()
727        existing = store.query(HeaderMatch).filter(
728            HeaderMatch.mailing_list == self._mailing_list,
729            HeaderMatch.header == header,
730            HeaderMatch.pattern == pattern).count()
731        if existing > 0:
732            raise ValueError('Pattern already exists')
733        last_position = store.query(HeaderMatch.position).filter(
734            HeaderMatch.mailing_list == self._mailing_list
735            ).order_by(HeaderMatch.position.desc()).limit(1).scalar()
736        if last_position is None:
737            last_position = -1
738        header_match = HeaderMatch(
739            mailing_list=self._mailing_list,
740            header=header, pattern=pattern, chain=chain,
741            position=last_position + 1, tag=tag)
742        store.add(header_match)
743        store.expire(self._mailing_list, ['header_matches'])
744
745    @dbconnection
746    def insert(self, store, index, header, pattern, chain=None, tag=None):
747        self.append(header, pattern, chain, tag)
748        # Get the header match that was just added.
749        header_match = store.query(HeaderMatch).filter(
750            HeaderMatch.mailing_list == self._mailing_list,
751            HeaderMatch.header == header.lower(),
752            HeaderMatch.pattern == pattern,
753            HeaderMatch.chain == chain,
754            HeaderMatch.tag == tag).one()
755        header_match.position = index
756        store.expire(self._mailing_list, ['header_matches'])
757
758    @dbconnection
759    def remove(self, store, header, pattern):
760        header = header.lower()
761        # Query.delete() has many caveats, don't use it here:
762        # https://docs.sqlalchemy.org/en/latest/orm/query.html#sqlalchemy.orm.query.Query.delete
763        try:
764            existing = store.query(HeaderMatch).filter(
765                HeaderMatch.mailing_list == self._mailing_list,
766                HeaderMatch.header == header,
767                HeaderMatch.pattern == pattern).one()
768        except NoResultFound:
769            raise ValueError('Pattern does not exist')
770        else:
771            store.delete(existing)
772        self._restore_position_sequence()
773        store.expire(self._mailing_list, ['header_matches'])
774
775    @dbconnection
776    def __getitem__(self, store, index):
777        if index < 0:
778            index = len(self) + index
779        try:
780            return store.query(HeaderMatch).filter(
781                HeaderMatch.mailing_list == self._mailing_list,
782                HeaderMatch.position == index).one()
783        except NoResultFound:
784            raise IndexError
785
786    @dbconnection
787    def __delitem__(self, store, index):
788        try:
789            existing = store.query(HeaderMatch).filter(
790                HeaderMatch.mailing_list == self._mailing_list,
791                HeaderMatch.position == index).one()
792        except NoResultFound:
793            raise IndexError
794        else:
795            store.delete(existing)
796        self._restore_position_sequence()
797        store.expire(self._mailing_list, ['header_matches'])
798
799    @dbconnection
800    def __len__(self, store):
801        return store.query(HeaderMatch).filter(
802            HeaderMatch.mailing_list == self._mailing_list).count()
803
804    @dbconnection
805    def __iter__(self, store):
806        yield from store.query(HeaderMatch).filter(
807            HeaderMatch.mailing_list == self._mailing_list
808            ).order_by(HeaderMatch.position)
809
810    @dbconnection
811    def _restore_position_sequence(self, store):
812        # Restore a continuous position sequence for this mailing list's
813        # header matches.
814        #
815        # The header match positions may not be continuous after deleting an
816        # item.  It won't prevent this component from working properly, but
817        # it's cleaner to restore a continuous sequence.
818        for position, match in enumerate(store.query(HeaderMatch).filter(
819                HeaderMatch.mailing_list == self._mailing_list
820                ).order_by(HeaderMatch.position)):
821            match._position = position
822        store.expire(self._mailing_list, ['header_matches'])
823
824    @dbconnection
825    def get_by_tag(self, store, tag):
826        # Get all the header matches that correspond to a single tag.
827        entries = store.query(HeaderMatch).filter(
828            HeaderMatch.mailing_list == self._mailing_list,
829            HeaderMatch.tag == tag
830            ).order_by(HeaderMatch.position)
831        yield from entries
832
833    @dbconnection
834    def filter(self, store, header=None, chain=None, tag=None):
835        entries = store.query(HeaderMatch).filter(
836            HeaderMatch.mailing_list == self._mailing_list)
837        if tag:
838            entries = entries.filter(HeaderMatch.tag == tag)
839        if header:
840            entries = entries.filter(HeaderMatch.header == header)
841        if chain:
842            entries = entries.filter(HeaderMatch.chain == chain)
843        yield from entries.order_by(HeaderMatch.position)
844